Access Offers — Provisioning (Shop + Campaign)
Status: Built end-to-end (committed on
staging, not pushed) as F14. Commerce & Access Offers — both triggers + the refund lifecycle. The provisioning slice that both the shop-commerce connectivity slice and marketing campaigns converge on — "one provisioning core, two triggers."Scope. Define the shared access-offers core + the provisioner that turns a (patient, offer) into platform access, plus the two front-door flows that drive it: the shop post-checkout onboarding (paid trigger, consuming
commerce.order_paid) and the marketing campaign claim (free trigger). It stops where access is minted — i.e. when apatient_content_grantsrow or apatient_subscriptionsrow exists. Playback gating is unchanged: the existing play gate (sessions/playgate.go, access/access.go) already honors grants + subscriptions, so nothing new is built at the access-check layer.
The convergence
FREE trigger PAID trigger
──────────── ────────────
campaign page (public) commerce.order_paid (shop slice)
→ pick offers → match line-item SKUs → offers
→ Clerk signup + consents (portalonboarding) → redirect link → onboarding page
→ POST /me/offers/{id}/claim → Clerk signup + consents
│ → POST /me/orders/{ref}/fulfill
│ │
└──────────────┬─────────────────────────────────────┘
▼
┌─────────────────────────────┐
│ ProvisionOffer(patient, │ ← the shared core. Trigger-agnostic.
│ offer, trigger, source) │ Idempotent via access_offer_fulfillments.
└─────────────────────────────┘
│
┌─────────────┴──────────────┐
▼ ▼
patient_content_grants patient_subscriptions ← existing primitives. No new
(000034) (000007, SetSubscription) access-check path.
│
▼
play gate (already honors both)Locked decisions (carried from the design conversation)
- One core, two bindings, one provisioner. An
access_offersrow (target content/tier + terms) is the convergence point. A campaign binding and a shop-SKU binding both point at it; the provisioner is trigger-agnostic. - The provisioner mints existing primitives —
patient_content_grants(per-content) andpatient_subscriptions(tier). It adds no new access-check path; the play gate already consults both. - The confidential content link never egresses. The public campaign face (
access_offer_campaigns) carries only clinic-authored presentation;offer_id/content_idstayorg_internaland are read server-side only. (Resolves the earlier marketing data-classification concern.) - Buyer-present provisioning. Even for a paid order, the buyer must complete Clerk signup + GDPR consents (via
portalonboarding) before access is minted. The orderkeyis ownership proof (they hold the order URL); the billing email is a profile-prefill hint only, never auth and never an auto-link to a stranger's existing account. - Idempotent via a fulfillment ledger.
access_offer_fulfillmentskeyed by(org, offer, patient, trigger_ref)makes re-claims / webhook replays / double-clicks no-ops, and is the forensic record (grants have no unique constraint of their own — see 000034). - Content-only. Programs / sessions / bundles + tier subscriptions. The "buy an appointment" path stays deferred until a booking domain exists.
Data model (new, all per-org RLS-scoped)
access_offers—id, organization_id, name (internal), status (draft|active|archived), created_by_principal_id, timestamps. Permissionoffers.manage.access_offer_items— the grant set (a bundle = one offer, N items):offer_id, organization_id, grant_kind (content_grant | tier_subscription), content_type (program|session|webinar) + content_idortier_id (FK patient_tiers),term_kind (duration | lifetime), duration_count + duration_unit (day | month | year). CHECK: exactly one target pergrant_kind; count and unit present iff the term is a duration. The unit is a calendar unit: a month ends on the same date next month, clamped to the month's last day (31 Jan + 1 month = 28/29 Feb).access_offer_campaigns(public binding) —id, organization_id, offer_id, headline, blurb, image_url, published, published_at, sort_order, featured. Public SELECT when published (AdminPool read path); authored viaoffers.manage. Classification:headline/blurb/image_url→ public-egress target;offer_id→org_internal(never egressed).access_offer_sku_bindings(shop binding) —id, organization_id, integration_id (FK organization_integrations — the connected shop), external_product_ref, external_variation_ref, offer_id.UNIQUE (organization_id, integration_id, external_product_ref, external_variation_ref).access_offer_fulfillments(ledger / idempotency / audit) —id, organization_id, patient_id, offer_id, trigger_type (campaign | shop_order), trigger_ref, source (promo | purchase), provisioned_at, result (grant/subscription ids).UNIQUE (organization_id, offer_id, patient_id, trigger_ref). It records occurrences — evaluate monthly partitioning (P41) vs flat-with-unique-index given idempotency-by-entity lookups + modest volume; decide at migration.- Migration edit: add
'purchase'topatient_content_grants.sourceCHECK (early-dev, edit the original migration).'promotion'already covers campaigns. (sourcevalues:purchase/promotion/complimentary/migration.)
The provisioner (shared core)
ProvisionOffer(ctx, patient, offerID, trigger{type, ref}, source) → fulfillment:
- Insert the
access_offer_fulfillmentsrow first (ON CONFLICT DO NOTHING on the unique key) → on conflict, return the existing fulfillment (idempotent no-op). - For each
access_offer_items:- content_grant →
granted_until = lifetime ? NULL : now + duration; INSERTpatient_content_grantswithsource. - tier_subscription → compute the window here (
SetSubscriptionreplaces the one live row and returns the previous — see patientsubscriptions/service.go:135), so the extend-vs-replace policy for repeat purchases lives in the provisioner, not the subscription service.periodEndsAt = lifetime ? nil : window; callSetSubscription.
- content_grant →
- Audit each mint. Emit
offer.provisioned(Cat E) → the notify primitive sends the welcome/credentials email (replacing legacy Make.com).
Validation at provision time (the "validate the link, not just at authoring" rule): offer status='active', each item's target still published-and-grantable. A stale campaign card or SKU binding cannot mint access to archived/unpublished content.
Trigger A — shop post-checkout (paid)
- Match. Consume
commerce.order_paid→ for each line item, resolveaccess_offer_sku_bindings (integration_id, external_product_ref, external_variation_ref)→ the set of offers. Record a pending fulfillment intent (order known, awaiting the buyer). No binding matches → the order is recordedno_match.external_product_refis the shop's numeric product id, not its SKU text, and a binding counts only on a live connection (the API refuses one on a revoked connection). The webhook ignores a repeat delivery of an order it already handled, so after adding the missing binding staff use Re-check on the Clinic's Shop orders page (POST …/order-intents/{orderId}/recheck): it pulls the order from the shop again and re-matches it. Re-check works on an order in any status and changes nothing when the shop agrees with it. It is also the way to catch a paid order cancelled or refunded later in the shop: MerchantPro sends no webhook for that, so the order stays pending (or fulfilled) here until a re-check takes back its access and marks it refunded. Activation email (000037). The claim link reaches the buyer only through the shop, and the platform keeps no buyer email from it. When the shop's email did not carry the link, staff press Email on a pending order (POST …/order-intents/{orderId}/send-activation): the platform sends the samepurchase_activationemail a purchase made without an account gets (F14.7), to the shop's billing address for the order or one staff typed. Its link,{portal}/claim/{token}(7 days, a resend replaces it), is one button: the login for the address it was sent to is found or created without a password (the inbox is the proof, so the address is kept on the order asclaim_email), and the buyer continues on the order page below. The token's hash is stored, never the token. - Resume. Buyer clicks the redirect link
{order_ref, key, shop}→ Portal onboarding page → a Portal endpoint resolves the verified order (org via the shop'sorganization_integrationsconnection) + the matched offers for display. - Onboard. Buyer signs up (Clerk) + completes
portalonboarding(portable profile + per-clinicpatientsrow + platform/org consents). Idempotent: an existing patient just signs in. - Fulfill.
POST /v1/me/orders/{order_ref}/fulfill(key as ownership proof) →ProvisionOffer(..., source='purchase')per matched offer, idempotent byorder_ref. - Access is live; the play gate already honors it. (The legacy "appointment step" is deferred — content-only.)
Trigger B — marketing campaign (free or paid)
A campaign sells one or more options, each an offer (access_offer_campaign_options, 000062). An option is free when its offer has no price, paid when it has one.
- Browse. Public campaign page on
{slug}.portal.restartix.pro→GET /v1/public/campaigns/...reads published campaigns and their options with prices (AdminPool, per-IP rate-limited, org-scoped, presentation columns only — options are named by their own id, never the offer's). Org resolved from hostname. - Free option. Clerk signup +
portalonboarding, thenPOST /v1/me/campaign-items/{id}/claim {option_id}→ProvisionOffer(..., source='promotion'), idempotent by(offer, patient). A paid option is refused. - Paid option. Bought through checkout — by a patient, by somebody signed in, or by a guest with no account, whose purchase waits until they activate it from an emailed link. Detail in features.md → F14.7.
Trigger C — staff, on the patient's Subscription tab
A prescription plays only with a live subscription, so the front desk gives access with Apply offer on the patient's Subscription tab, in the Applied offers card beside Copy from the old platform (POST /access-offers/patient-fulfillments). The order is: send the prescription, then apply the offer. The Send dialog only shows the patient's access, and points a sender who manages access at the Subscription tab.
- Source is the staff member's choice: paid at the clinic (
purchase, default) or free (complimentary). The clinic invoices by hand; the ledger keeps the record honest. trigger_ref=apply:{idempotency key}. The dialog mints a key each time it opens and sends it with the request, so a double click or a retried request applies the offer once. Orders made before 2026-11-02 carryprotocol:{id}.- One transaction. The order line is claimed first, then the subscription granted, then the fulfillment and its content grants written, and all of it commits together. A failure leaves nothing behind, and a replay of the same key waits on the claim and then grants nothing.
- Gated on
patient_subscriptions.manage, notproducts.manage. Applying an offer is subscription work (front desk); authoring one is not.
History (2026-10-06 → 2026-11-02). The offer used to be applied by the prescription hand-over itself, before the patient was emailed, so the email could state the end date. It was removed after it failed in production: the offer's order was written on a second connection while the hand-over held the protocol row, so the send locked against itself until the request timed out. And because the tier grant committed on its own before that, each failed attempt left a subscription with no order behind it, and a retry stacked another term on it.
Clerk identity linking (the crux)
The buyer arrives from a cold shop checkout with a billing email but no Clerk account. Decision (v1): standard Clerk signup/sign-in on the onboarding/campaign page; provisioning binds to the authenticated patient; the order key proves order ownership; the billing email only prefills the profile. This is the GDPR-correct, simplest, most secure path — and it is literally the "onboarding link" legacy already sent, now landing on Clerk instead of a generated-password autologin. Enhancement (deferred): pre-provision a Clerk invitation / sign-in token from commerce.order_paid (Clerk Backend API) for a one-click experience closer to legacy autologin — adds a Clerk Backend API dependency (SOUP row). Designed to bolt onto the proven spine without rework.
Onboarding reuse
Both flows funnel through the existing portalonboarding (idempotent). The shop flow adds an order-resolve step in front; the campaign flow adds an offer-claim step after. Existing patient → access extends on their account; new user → profile + patient created. No second onboarding stack.
Where an offer is public
An offer is internal — staff apply it — until something makes it public: a campaign option (claimed free, or bought when the offer is priced) or a catalog placement (catalog_entries.access_offer_id, F14.6), which is the offer a library page sells to a patient it locks, with its online price and a Buy button. There is no list of every priced offer for patients; checkout sells only an offer a published placement or a published campaign option names. One offer may be all three at once — on a page, in a campaign, and applied by staff. Detail in features.md → F14.6.
Clinic authoring UI (net-new)
Mirror the shipped patient-tiers-editor.tsx SWR pattern:
- Offer builder — create an offer; add items (pick program/session/webinar or tier; set duration / lifetime). Webinar appears while a webinars integration is connected; buying one also enrolls the patient (F17,
000060). - Shop bindings — per connected shop (from the connectivity slice), map a product/variation SKU → an offer.
- Campaign authoring — write the public
access_offer_campaigns(headline/blurb/image), set publish + featured + order.
Compliance & security
- No content-link egress — the public campaign endpoint uses
classification.Filterand returns presentation columns only;offer_id/content_idclassifiedorg_internal. Every new column gets a data-classification.md entry in the same PR;access_offer_campaignspresentation columns get the public/marketing egress target (the one egress decision flagged in the marketing design). - Consents enforced before provisioning —
portalonboardinggates platform + org consents; a paid order does not bypass consent. - Audit — grants, subscriptions, and fulfillments are state transitions, audited normally.
- RLS — all new tables per-org; the public campaign read is the only AdminPool-bypass path, secured by published-only filtering + rate limit.
- PII minimization — billing email prefills profile only; no broad order-PII retention beyond the connectivity slice's forensic record.
- SOUP — any new dep (e.g. Clerk Backend API SDK for the invitation enhancement) → SOUP row same PR.
Build order — risk-first
Both triggers ship for launch. Build order is hardest/riskiest first, as vertical slices that de-risk the shared spine before either trigger's surrounding machinery exists.
- Step 0 — spine spike (the hardest part). The riskiest, most cross-system path: a cold entry point (stub offer) → Clerk signup/sign-in →
portalonboarding(consents) →ProvisionOffermints a grant → access live (play gate honors it). Proves the Clerk ↔ onboarding ↔ provisioner round-trip, new-vs-existing, and idempotency before any tables/UI/connectivity. This is where the Clerk identity model gets settled in code. - Step 1 — offers core. Harden the spike into real schema + service:
access_offers/access_offer_items/access_offer_fulfillments+ theProvisionOfferservice + Clinic offer authoring. - Step 2 — shop trigger (the harder adapter). Cat B/D connectivity (the connectivity slice, first framework consumers, per-connection token) +
access_offer_sku_bindings+ order→offer matching + the post-checkout onboarding page feeding the proven spine. - Step 3 — campaign trigger (the lighter adapter).
access_offer_campaigns+ the public campaign read endpoint (needs the egress-classification decision) + the claim flow + Portal campaign page + Clinic campaign authoring.
The two triggers are deliberately last because by then the spine is proven; each is a thin front door onto it.
Dependencies & open decisions
Depends on: the connectivity slice (commerce.order_paid); existing portalonboarding, patient_content_grants, patient_subscriptions, patient_tiers, and the play gate (all shipped). The public campaign endpoint depends on the egress-classification decision.
Open decisions:
- Clerk identity model — standard signup (recommended v1) vs pre-provisioned invitation (UX enhancement).
- Repeat-purchase tier policy — extend vs replace vs stack the window (legacy extended; the provisioner owns this).
- Refund → revoke — SHIPPED (F14.5): content grants lapse precisely via
patient_content_grants.fulfillment_id. Auto tier-revoke deferred (replace-semantics ambiguous). - Repeat-purchase tier policy — the provisioner uses
SetSubscription(replace); extend/stack stays unbuilt. access_offer_fulfillmentspartitioning — P41 event-log treatment vs flat. Kept flat (idempotency-by-entity lookups + modest volume).- Share-link unification —
share_linksalready pin a tier + drive onboarding; consider folding them into campaign offers later (they're the thin cousin ofaccess_offer_campaigns). - F-number — registered as F14. Commerce & Access Offers (2026-06-01).