Gift subscriptions¶
Generated from a canonical source
This page is a read-only projection of docs/handoff-corpus/gift-subscriptions.md.
Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.
What gift subscriptions is for¶
A gift recipient's subscription must never acquire a charges row or a
non-NULL next_charge_at while it is gift-delivery-driven. That is the one
invariant this domain must not break — a recipient who never provided a
payment method and never consented to being charged must never be billed or
dunned for a gift they didn't pay for. onTokenClaim sets next_charge_at =
NULL and payment_method_id = NULL explicitly at creation — not copied from
the giver's record. The only sanctioned exit is the explicit AC4 convert path,
itself compare-and-set gated so it can only fire once
(extensions/gift.ts:162-176; extensions/gift-delivery.ts:128-179; proven
by gift-delivery-sweep.scenario.ts's full-cron-tick zero-charge assertion).
The domain breaks into five features, each anchored to its build story (US-6.1) for traceability:
- Buy a subscription as a gift — a logged-in gifter pays upfront for N cycles of a plan and sends a recipient a reveal email; no PDP toggle exists yet, so the entry is the portal-authenticated checkout route (US-6.1)
- Claim your gift — the recipient opens the reveal link, clicks "Activate your gift," and gets an active subscription in their own BC customer account with no card required (US-6.1)
- Free gifted deliveries, N cycles, no dunning — every gifted delivery ships at $0 with no backing charge, and an exhausted-but-unconverted gift produces zero payment-retry emails because the recipient never owed money (US-6.1 AC5)
- "One delivery left — want to keep it going?" — the recipient is notified before their gift runs out and can add a card to continue on paid billing (US-6.1 AC3)
- Opt-in conversion to paid billing — an explicit consent action (not merely adding a card) converts an exhausted gift to a normal paid subscription; silence always lapses the subscription, never auto-charges (US-6.1 AC4)
The decisions that carry the most weight¶
1. Delivery path, not charge path — structural, not incidental. The
recipient subscription never touches charges by construction, not by
convention. next_charge_at = NULL on claim, and every renewal/OOS/bundle
scan filters next_charge_at IS NOT NULL, so the row is permanently
invisible to the billing scheduler. Deliveries flow through a dedicated
fulfillment-sweep branch anchored on current_period_end, counting fulfilled
delivery_instances rows instead of a mutable counter
(docs/architecture/process-flows.md §5). No ADR was filed for this
mechanism — the AC3/AC4/AC5 build shipped via Hive [Spec] #1797 / synthesis
1798 / PRs #1804 + #1812, not a decision record. That absence is itself a¶
gap this page flags, not fills.
2. Shared subscription_extensions substrate, not a bespoke gift table.
gift and gift_delivery are two of the polymorphic extension types riding
the JSONB substrate + per-type service-layer convention this domain reuses
rather than reinvents
(ADR-0033).
3. Consent-marker discriminator for AC4 vs AC5, not payment-method
presence. convert_consent_at in extension_data is the sole gate for the
convert path. The generic stored-instrument PM-select endpoint can attach a
card without AC4 intent, so keying off PM presence would silently convert
recipients who just wanted to update a card — an FTC Negative Option Rule
violation (routes/portal/gift-convert.ts header, synthesis #1798).
4. Atomic compare-and-set claim, not read-then-write. The single-use
claim token is consumed via UPDATE ... WHERE claimed_at IS NULL before any
subscription is created, closing a replay/race window a read-then-check
pattern would leave open (Hive #1754, extensions/gift.ts:134-154).
How it actually works¶
Read the deciding record before the code, not after. The canonical-framing attestation on this domain (
_input-b/gift-subscriptions.md) found thatBRD.md's own §US-6.1 data-contract prose still describes this mechanism as reusing "the prepaid extension mechanism (cycles_remainingdriven by the US-6.2skip_advance+cycles_remaining--machinery)." That was the original synthesis #1798 plan. A 2026-06-27 grounding pass disproved it before build — the shipped mechanism below is what actually runs. If you read the BRD prose first, you will derive the wrong mechanism.
Purchase. handleGiftPurchase
(routes/storefront/checkout/gift.ts)
charges the giver once, upfront, for plan.amount_cents * cycles through the
normal adapter.charge() path (order-first, ADR-0025), then creates the gift
subscription in status='paused' (a Phase-1 proxy for pending_claim;
migration 0019) with gift_from_customer_id set. A gift extension row
records the recipient email, cycles_total, and a 32-byte hex claim_token
with a 30-day expiry. sendGiftRevealEmail fires best-effort — its failure
does not roll back the charge.
Claim. handleGiftClaim
(routes/portal/gifts/[token]/claim.ts)
takes no auth beyond the token itself and calls
giftService.onTokenClaim (extensions/gift.ts:115-227).
That function:
- Looks up the extension row by scanning
claim_tokenin the JSONB (json_extract, LIKE-compatible with Workers D1). - Consumes the token atomically:
UPDATE subscription_extensions SET claimed_at = ... WHERE claimed_at IS NULL. Zero rows changed means a race loser or a replay — both are rejected withGift already claimed. - Creates the recipient subscription with
status='active',payment_method_id = NULL,next_charge_at = NULL— both explicitly not copied from the giver's row — andgift_origin_subscription_idpointing back to the giver's subscription for audit trail. - Attaches a
gift_deliveryextension carryingcycles_total. - Seeds up to
cycles_totaldelivery-instance rows up front viafillForSubscription, since gifts have no charge-success event to drive the normal event-driven refill.
Delivery. extensions/gift-delivery.ts
is read/validate-only on the extension-registry contract — it has no
charge-path hooks, because gifts never reach the scheduler. Two counting
functions are the actual state:
countFulfilledDeliveries—COUNT(*)overdelivery_instances WHERE status='fulfilled'. This is cycles-remaining, and it is a count, never a mutable counter — a shared JSON counter would race under concurrent sweeps and lose a decrement (an extra free shipment); counting rows is race-free.maybeEndGiftSubscription— oncefulfilled >= cycles_total, branches on whetherconvert_consent_atis set in the extension data. AC4 convert: CASUPDATE subscriptions SET next_charge_at = ... WHERE status='active' AND next_charge_at IS NULL— the only place a gift-delivery sub'snext_charge_atis ever set to non-NULL, and it can fire exactly once. AC5 lapse: CASUPDATE subscriptions SET status='cancelled', cancel_reason='gift_exhausted' WHERE status='active'. Both paths delete any surplus pendingdelivery_instancesso nothing can ship after the transition.
processGiftEndingSoon runs each scheduler tick (wired before
findDueCharges) and, for active gift subs where fulfilled >= cycles_total
- 1 with no convert_consent_at yet, emits gift.ending_soon once (an
events-table anti-join, not a CAS column — a deliberate downgrade since a
notification's TOCTOU window is annoying, not dangerous) and sends the "add a
card" email.
Convert. handlePortalGiftConvertOptIn
(routes/portal/gift-convert.ts)
is the only path that can set convert_consent_at. It requires an
authenticated portal session owning the subscription, an active gift
recipient sub, an existing payment_method_id, and a { consent: true,
disclosed_amount_cents } body — disclosed_amount_cents is mandatory for
audit-trail completeness. Calling it twice is idempotent (already_consented:
true, 200) rather than erroring.
flowchart TD
A[Giver: POST /api/v1/storefront/checkout/gift<br/>plan_id, cycles, recipient_email, payment_method_id] --> B[handleGiftPurchase]
B --> C[Giver charged ONCE, upfront, for N cycles<br/>via normal adapter.charge path — ADR-0025 test-mode/synthetic in CI]
C --> D[createIncompleteOrderForGift +<br/>gift subscription created, status='paused',<br/>gift_from_customer_id set]
D --> E[subscription_extensions row, extension_type='gift'<br/>claim_token + claim_expires_at + cycles]
E --> F[Recipient emailed reveal link — out of scope here]
F --> G[Recipient: POST /api/v1/portal/gifts/:token/claim — no auth, token is the credential]
G --> H[handleGiftClaim → giftService.onTokenClaim]
H --> I{Token valid, unclaimed,<br/>within claim window?}
I -- no --> I1[404 / 409 conflict / 412 precondition_failed]
I -- yes --> J[ATOMIC claim: UPDATE subscription_extensions<br/>SET claimed_at WHERE claimed_at IS NULL<br/>— compare-and-set, race-safe]
J --> K[Create RECIPIENT subscription:<br/>status='active', next_charge_at=NULL,<br/>gift_origin_subscription_id set]
K --> L[Attach gift_delivery extension:<br/>seed N delivery instances, one per prepaid cycle]
L --> M[200: recipient subscription_id]
M --> N[runFulfillmentSweep sweeps gift_delivery instances<br/>on schedule — ships each cycle as a $0/no-charge BC order]
N --> O{cycles exhausted?}
O -- no --> N
O -- yes --> P[processGiftEndingSoon fires at N-1<br/>notifies recipient; gift-convert opt-in offered]
P --> Q{recipient opts in?<br/>convert_consent_at set}
Q -- yes --> Q1["convert to paid billing<br/>(separate flow — sets next_charge_at, exits delivery-only path)"]
Q -- no / lapses --> R[subscription ends — NO charge, NO dunning, ever]
Diagram provenance. Transcluded verbatim from the canonical, code-sourced
docs/architecture/process-flows.md§5 "Gift — DELIVERY path, NOT a charge path" (derives_from:routes/storefront/checkout/gift.ts,routes/portal/gifts/[token]/claim.ts,extensions/gift.ts,extensions/gift-delivery.ts,cron/fulfillment-sweep.ts, plus the three gift scenario files). It carriessign_off: pending— accurate to the code, not yet human-attested, so read it as the current mechanism, not a ratified contract. In the handoff pipeline this is a build-time include of that one source, never a hand-copied fork.
The data model backing this flow — the polymorphic extension row and the two
gift-specific columns on subscriptions:
erDiagram
subscription_extensions {
TEXT subscription_id PK
TEXT store_hash
TEXT extension_type PK
TEXT extension_data
INTEGER extension_version
TIMESTAMP created_at
TIMESTAMP updated_at
}
subscriptions {
TEXT gift_from_customer_id
TEXT gift_origin_subscription_id
}
subscriptions ||--o{ subscription_extensions : "subscription_id"
Diagram provenance. Excerpt of the canonical, code-sourced
docs/architecture/data-model-erd.md(2 of ~85 tables — thesubscription_extensionstable this domain's two extension types live in, plus the two gift-specific columns onsubscriptions; every other column and every other table is omitted). Unlikeprocess-flows.md, this source carries nosign_offfield; its own staleness marker isas_of_commit: 80fc35f4,staleness_threshold_days: infinite. Same rule applies: this is one transcluded source, not a hand-drawn fork.
Where intent and reality diverge¶
The derived coverage matrix
(_coverage-matrix.json) reports
US-6.1 at terminal_gate: "G4", g4_status: "pass", dod_bucket: "tested".
That is true, and — as with every domain in this corpus — it is not the whole
truth. Four honest deltas, each typed:
1. Superseded-framing residue — the BRD's own data-contract prose is
wrong. BRD.md §US-6.1 (surfaced in
docs/audits/derived/brd-epics/epic-06-advanced-subscription-types.md)
still describes the mechanism as reusing "the prepaid extension mechanism
(cycles_remaining driven by the US-6.2 skip_advance +
cycles_remaining-- machinery)." That was the original synthesis #1798
plan; a 2026-06-27 grounding pass disproved it before build — gifts never
touch the charge path at all, and the shipped code counts fulfilled
delivery_instances rows, never a mutable counter. The BRD prose was not
corrected after the pivot. This reconciliation is tracked as Hive issue
1887; **this page's Input-B canonical-framing attestation is the authority¶
for the mechanism, not the BRD prose** — do not re-import the BRD's
cycles_remaining description into new work.
2. Named-deferred — no storefront purchase-initiation UI exists. A real
"gift this subscription" form is filed as a standalone gap (Hive #1753) and
not built. The only storefront gift surfaces today are
GiftClaimView.svelte
(recipient claim) and
GiftPanel.svelte
(giver-side, read-only status — it cannot initiate a purchase).
gift-preview/+page.svelte
is an explicitly dev-only mock harness (if (!dev) error(404, ...)) for
visually QA'ing GiftClaimView states — it does not exercise the real
purchase route. BRD itself documents the honest current entry as the
portal-authenticated checkout route, with a PDP toggle named as a separate
future slice (synthesis #1798).
3. Contract-verified, not live-verified — the entire domain is G4-tier
only. Purchase, claim, AC3 ending-soon, AC4 convert, AC5 lapse, and the
full-cron-tick zero-charge guarantee are all proven against real D1 via
applySchema and dispatched through worker.fetch
(gtm-gift.scenario.ts)
— but no live BC sandbox or Stripe network call is exercised.
test_mode_enabled=1 bypasses the real charge call even in the one scenario
that drives handleGiftPurchase through the real route. No G5 (live-sandbox)
run exists for gift purchase or claim, unlike dunning's
cit-to-mit-sandbox.spec.ts decline variant or the canonical-charge-rail
domain's ADR-0082 live executions. p3-jordan-gifting.scenario.ts calls
handlers directly and does not, on its own, prove HTTP reachability — that
proof is gtm-gift.scenario.ts's alone, and it is the scenario that found
and drove the fix for the reveal-link 404 bug below.
4. Built-but-untrodden — the reveal-link email never traverses its real
delivery path in test. sendGiftRevealEmail → EVENTS_QUEUE outbox →
email-consumer Worker → provider is real code with a regression-guarded
claim-URL shape
(gift-reveal-email.test.ts,
added after a real bug: the reveal link was missing a path segment and
404'd on every real link, found by gtm-gift.scenario.ts driving the real
route before it shipped). In every test environment env.EVENTS_QUEUE is
unbound, so the code takes the console.log-only branch — live delivery
through the full queue → consumer → provider path for this template is not
exercised by any test in this domain. This mirrors dunning's identical gap
for a different email template.
Verified-but-incomplete, named for completeness: AC3's near-end notify
(processGiftEndingSoon) and AC4's convert opt-in endpoint are both built
and scenario-proven
(gift-conversion.test.ts),
and wired into runScheduledTick — but there is no storefront/portal UI
surfacing the "add a card to keep them coming" prompt or a convert-consent
affordance. The endpoint exists; the recipient-facing consent UI to reach it
does not (no file under apps/storefront-svelte implements a gift-convert
form as of this trace).
How to operate & extend¶
- Change the number of cycles a gift covers:
cyclesin the purchase request body (handleGiftPurchase); the recipient'sgift_deliveryextension stores it ascycles_totaland every downstream count derives fromdelivery_instances, never a stored remaining-count. - The invariant you must not break:
next_charge_atandpayment_method_idstayNULLon a gift-delivery recipient sub until the single CAS-gated AC4 convert write. Any code path that setsnext_charge_aton a still-gift-delivery sub, or mints achargesrow for a delivery, puts a recipient who never consented to a charge into the billing/dunning path — both a money-path bug and a negative-option-billing violation. - Recipient claiming fails unexpectedly? Start at
giftService.onTokenClaim(extensions/gift.ts:115) — it returnsInvalid claim token,Claim window expired, orGift already claimedas distinct thrown errors that the route maps to 404 / 412 / 409 respectively. - Deliveries not shipping or not ending?
runFulfillmentSweep(cron/fulfillment-sweep.ts) is what ships gift deliveries;reconcileGiftDeliveries(extensions/gift-delivery.ts) is the belt-and-suspenders reconciliation pass that recovers a failedmaybeEndGiftSubscriptioncall or tops up under-seeded gifts (the 60/call horizon clamp onfillForSubscriptionmeans a long gift converges over several ticks, not one). - Extension seams: the
gift/gift_deliveryextension-type pair onsubscription_extensionsis the pattern to follow for a new polymorphic subscription behavior — see ADR-0033 for the substrate convention. The AC4-vs-AC5 discriminator (convert_consent_atinextension_data, not payment-method presence) is the pattern to reuse for any future consent-gated state transition.
Confidence notes¶
- The mechanism-correction has no ADR. The switch from the original
"reuse the prepaid extension" plan to the shipped delivery-count mechanism
shipped via Hive [Spec] #1797 / synthesis #1798 / PRs #1804 + #1812, not a
decision record. Both this page's Input-B and
docs/architecture/process-flows.mdnote the same gap independently. If you are the one to close it, the deciding record should also carry the fix for delta #1 above (correcting the BRD'scycles_remainingprose). - No live-sandbox (G5) proof exists for this domain. Unlike dunning and
the canonical-charge-rail domain, there is no
cit-to-mit-sandbox-style live run for gift purchase or claim in this repo as of this trace (a targetedgrepfore2e/*gift*and anyapps/storefront-sveltee2e gift specs returned zero results). Treat "gift purchase actually charges a giver's real card" as unproven beyond the G4 stub-mode scenario. docs/audits/route-orphan-audit-2026-06-23.mddocuments a historical finding — gift routes were HTTP-unrouted until commit7d7ee6d1(2026-06-23) wired them. Both gift routes are present in the currentworker.tsdispatch table (confirmed by direct read: lines 859-895 for claim + the gift-convert route, lines 1554-1567 for purchase) — the audit document should be read as history, not current state.