An internal audit of the per-store verification lanes found that the Meta Horizon asynchronous half (subscriptions + reconciler) has structural defects. The synchronous verify path (/v1/purchase/verify with store:"horizon") is wired correctly; everything below concerns what happens after.
Blockers
1. Reconciler can expire real Google Play subscriptions on mixed projects
listHorizonSubscriptions filters sub.platform === "Android" && !!sub.userId — the subscriptions table has no store discriminator. On a project with both Google Play verification and horizonEnabled, every user-bound Google Play subscription matches, and the reconciler POSTs each (userId, productId) to graph.oculus.com/{APP_ID}/verify_entitlement. If Meta answers HTTP 200 {success:false}, the transition logic marks the Google subscription Expired.
packages/kit/convex/subscriptions/horizonInternal.ts:156-165 (platform filter)
packages/kit/convex/subscriptions/horizon.ts:144-156 (expiry transition)
Fix direction: add a store/provenance column to subscriptions (or a Horizon-scoped table) and filter the reconciler on it. Requires a staged migration (see #241 for the pattern).
2. Horizon subscriptions are never created — the reconciler polls a phantom population
verifyMetaHorizonReceiptInternalV1 only calls saveReceiptInternal (purchases row). recordVerifiedSubscription's only callers are purchases/ios.ts and purchases/android.ts, and no Horizon webhook receiver exists, so no subscriptions row with Horizon provenance can exist. Horizon subscription tracking is effectively an unfinished feature.
packages/kit/convex/purchases/horizon.ts:108,132
packages/kit/convex/subscriptions/internal.ts:399-449
Major
- Transient Meta failures overwrite ENTITLED → INAUTHENTIC: the verify action's catch-all persists
state:INAUTHENTIC for every failure including network errors/5xx; the deterministic remoteId ({userId}:{sku}) makes the dedup path patch the existing row. The Google lane deliberately avoids this — mirror that pattern. (packages/kit/convex/purchases/horizon.ts:104-125, purchases/internal.ts:333-395)
- Unvalidated Meta response-shape assumption: both
parseHorizonResponse and checkHorizonEntitlement treat non-entitlement as HTTP 200 {success:false}. If Meta actually signals it as HTTP 400 with an {error:{...}} body (Graph API convention), the verify route returns a 400 error instead of {isValid:false, state:INAUTHENTIC}, and reconciler expiry detection never fires. (purchases/horizon.ts:31,89-102,182-202)
- Events mislabeled as Android:
recordHorizonStatus hard-codes platform:'Android' + environment:'Production' on webhookEvents, inflating Android dashboard renewals; revenueMetricsDaily inherits the same conflation. (subscriptions/horizonInternal.ts:233-245, convex/schema.ts:569,699,820)
Minor
reconcileHorizonNow is a dead public action (no callers in dashboard/server/mcp-server) duplicating ~85 lines of the cron probe loop — fold or remove.
- No sandbox/test-environment concept in the lane; the first manual E2E will pollute Production metrics.
Manual E2E checklist (requires Meta developer account + Quest test user)
Note: no first-party SDK currently sends store:"horizon" to kit (the GQL RequestVerifyPurchaseWithIapkitProps has no horizon member — SDKs call Meta S2S directly), so this lane is only reachable via raw REST today. Worth deciding whether to add the spec member or document the lane as backend-only.
🤖 Findings from an internal multi-agent audit; file:line references verified against main at the time of filing.
An internal audit of the per-store verification lanes found that the Meta Horizon asynchronous half (subscriptions + reconciler) has structural defects. The synchronous verify path (
/v1/purchase/verifywithstore:"horizon") is wired correctly; everything below concerns what happens after.Blockers
1. Reconciler can expire real Google Play subscriptions on mixed projects
listHorizonSubscriptionsfilterssub.platform === "Android" && !!sub.userId— thesubscriptionstable has no store discriminator. On a project with both Google Play verification andhorizonEnabled, every user-bound Google Play subscription matches, and the reconciler POSTs each(userId, productId)tograph.oculus.com/{APP_ID}/verify_entitlement. If Meta answers HTTP 200{success:false}, the transition logic marks the Google subscriptionExpired.packages/kit/convex/subscriptions/horizonInternal.ts:156-165(platform filter)packages/kit/convex/subscriptions/horizon.ts:144-156(expiry transition)Fix direction: add a store/provenance column to
subscriptions(or a Horizon-scoped table) and filter the reconciler on it. Requires a staged migration (see #241 for the pattern).2. Horizon subscriptions are never created — the reconciler polls a phantom population
verifyMetaHorizonReceiptInternalV1only callssaveReceiptInternal(purchases row).recordVerifiedSubscription's only callers arepurchases/ios.tsandpurchases/android.ts, and no Horizon webhook receiver exists, so nosubscriptionsrow with Horizon provenance can exist. Horizon subscription tracking is effectively an unfinished feature.packages/kit/convex/purchases/horizon.ts:108,132packages/kit/convex/subscriptions/internal.ts:399-449Major
state:INAUTHENTICfor every failure including network errors/5xx; the deterministic remoteId ({userId}:{sku}) makes the dedup path patch the existing row. The Google lane deliberately avoids this — mirror that pattern. (packages/kit/convex/purchases/horizon.ts:104-125,purchases/internal.ts:333-395)parseHorizonResponseandcheckHorizonEntitlementtreat non-entitlement as HTTP 200{success:false}. If Meta actually signals it as HTTP 400 with an{error:{...}}body (Graph API convention), the verify route returns a 400 error instead of{isValid:false, state:INAUTHENTIC}, and reconciler expiry detection never fires. (purchases/horizon.ts:31,89-102,182-202)recordHorizonStatushard-codesplatform:'Android'+environment:'Production'onwebhookEvents, inflating Android dashboard renewals;revenueMetricsDailyinherits the same conflation. (subscriptions/horizonInternal.ts:233-245,convex/schema.ts:569,699,820)Minor
reconcileHorizonNowis a dead public action (no callers in dashboard/server/mcp-server) duplicating ~85 lines of the cron probe loop — fold or remove.Manual E2E checklist (requires Meta developer account + Quest test user)
verify_entitlementresponse for a user who does NOT own the SKU (200{success:false}vs 400 error body) — both halves of the lane depend on thisNote: no first-party SDK currently sends
store:"horizon"to kit (the GQLRequestVerifyPurchaseWithIapkitPropshas no horizon member — SDKs call Meta S2S directly), so this lane is only reachable via raw REST today. Worth deciding whether to add the spec member or document the lane as backend-only.🤖 Findings from an internal multi-agent audit; file:line references verified against main at the time of filing.