Patient content catalog & access model
Status: Design, mostly not built. The pieces underneath it are shipped — the content substrate (programs / sessions / exercises, three-tier copy-on-derive, the patient-self
_select_selfRLS family) and the tier substrate (patient_tiers000005,patient_subscriptions000007 with entitlements/limits + snapshots + overrides). The patient self-serve catalog, the content-access enforcement (tier-gating at the play gate), and per-content grants are net-new. This doc is the authoritative design for that layer and the boundaries around it. Build plan + phasing: implementation-plan/patient-catalog-and-access.md. Companions: tiers-and-subscriptions.md, cadence-and-supervision.md, programs-and-assignments, appointments-substrate.md, exercise-taxonomy-pose-tracking.md.
What this is
How a patient discovers and accesses content: the catalog (what's shown), tier gating (what's unlocked), and the boundary between digital content (this doc) and clinical services (service_plans, future). It is the layer the legacy direct-to-consumer (D2C) ecommerce catalog migrates onto — the legacy model sold individual programs (Udemy-style, per-product paywall); the new model sells platform access by tier, and content is gated by that tier.
The three patient surfaces (don't conflate them)
| Surface | What it is | Backed by |
|---|---|---|
| Your plan ("Programul meu") | the active prescription / Today card | cadence engine + protocols; not in the catalog (it has its own place) |
| Guided catalog ("Programe ghidate") | curated, merchandised programs (sections, featured, covers, tier-lock) | the catalog layer below |
| Exercise library ("Biblioteca") | faceted browse of the full exercise set (filters, favorites, "for you") | the clinical taxonomy (000027), not the catalog |
The catalog and the exercise library are different mechanisms: programs are curated (merchandised sections), exercises are faceted (taxonomy filters: zonă / obiectiv / dificultate / durată / echipament). Exercises mostly do not get catalog entries. Keep these two engines separate.
The catalog / merchandising layer (net-new)
A per-org presentational layer that decouples how content is presented from what the content is. A boolean self_enrollable flag on the content row cannot work, because platform content is one shared row and each white-labeled clinic curates it differently (Clinic A features a program, Clinic B hides it). That per-clinic state needs its own home.
catalog_sections— per-org (+ a platform-default set), named, ordered,status, P21 translations. The "Programe / Coloană / Relaxare …" groupings.catalog_entries— places one content item in a section:organization_id,section_id,content_type(program | session | audio | educational_video | …),content_id,sort_order,featured, presentation overrides (covercontent_file_id, badge, subtitle), and the gating fieldrequired_entitlement(NULL = free). Entry existence = the content is in the patient catalog; the entry'srequired_entitlement= the per-clinic tier gate.
content_id is a typed-polymorphic ref (constrained by content_type) — acceptable for a presentation layer where a dangling entry simply doesn't render; per-type entry tables are the FK-strict alternative if wanted.
This layer subsumes the earlier "self_enrollable flag + visibility RLS branch + catalog endpoint" plan — membership is the flag, featured/sections/ordering come with it, and it's the only structure that expresses per-clinic curation of shared content.
Sessions gain the program lifecycle
A standalone session must be published to appear in the catalog or be played — a draft being authored must not. So sessions adopt the program lifecycle (draft → published → archived, replacing today's active | archived), making the two standalone units — session and program — symmetric. This also settles the render trigger: renders enqueue when a session flips draft → published (honoring "render at publish, not during authoring"). A standalone session triggers via its own publish; an in-program session triggers when the program publishes (which publishes its draft sessions). Attached library sessions are content-addressed cache hits, so the double-publish path never re-bakes. The catalog gates standalone-session entries on status='published'. Shared with the async-render-pipeline chat — see the build spec's prerequisite.
Catalog ≠ exercise library ≠ products/services
- Exercise library is faceted browse over the taxonomy, not catalog sections.
- Products & services get their own catalog (a separate planned page), reusing the section/entry pattern — not one polymorphic table that mixes content with commerce. The backends differ too much (enroll/play vs buy/book; tier-unlock vs price/inventory; MDR/GDPR vs payment/tax). Cross-sell ("a brace next to the knee program") is a deliberate future promo-slot that references the products domain once it exists, not a reason to merge.
The access model
Three composable axes decide what a patient can do:
- Patient tier — the entitlement bundle (
patient_tiers, per-org). "Free / paid" is a price classification over tier rows; billing cadence (monthly / annual / lifetime) is the subscription term (current_period_ends_at; lifetime = open-ended), largely external (payment_provider='external'). A grandfathered cohort ("founders") is the top bundle + a cohort marker, not a separate entitlement set. - Per-patient grants —
patient_subscription_overrides(entitlement-level, bounded or lifetime; shipped) and a net-newpatient_content_grants(patient → specific content, period) — the latter also the migration target for legacy per-program ownership. The ~20k legacy D2C patients live under a RestartiX-operated first-party clinic (one tenant org whose tiers are the platform's Free / Annual / Lifetime / Founders), so their legacy purchases become per-content grants under that org. - Content requirement —
freeorrequired_entitlement, carried on the catalog entry (per-clinic), or a blanket capability for non-catalog content.
The check, by content type
| Content | Gated by | Resolves through the catalog? |
|---|---|---|
| Self-enroll program | catalog entry required_entitlement (per-clinic) OR content grant OR free | yes — at enroll, then captured on the protocol |
| Standalone session | same | yes — one indexed lookup at CreateRun |
| Prescription | prescription_play capability + active subscription (no grant, no lifetime) | no — clinician-assigned, never catalogued |
| Exercise — play | exercise_play capability (blanket) | no |
| Exercise — browse | library_browse (Free has it) | no |
Capture-at-enroll keeps the play gate catalog-free. Discovery and the enroll decision resolve the catalog; the required entitlement is then snapshotted onto the protocol (consistent with the subscription-snapshot pattern). Ongoing play checks the captured requirement against the current subscription/grants — so access lapses correctly when a subscription expires, with no catalog join in the hot path. Standalone play (no protocol) does one indexed catalog lookup; prescriptions and exercise-play use blanket capabilities.
The enforcement point already exists: enforcePlayGate in sessions.Service.CreateRun (playgate.go) currently gates cadence only. The access check goes beside the cadence check; browse/catalog endpoints add the lighter "shown-but-locked" (PRO) variant.
Prescriptions are special
A prescription's protocol stays active (clinical record), but its playability is bounded by the live subscription: a 4-week prescription under a subscription expiring in 2 weeks loses playability for the last 2 weeks. Prescription access is never granted lifetime — only an active subscription with prescription_play unlocks it. Whether prescriptions are tier-gated at all (vs. always clinician-authorized) is an open decision; the likely answer is they are tier-gated, self-serve catalog content shares the catalog gate.
Content axis vs. service axis — shared tier, bridged at appointments
A tier provides two structurally different things; keep them in their own slots:
| Axis | Schema slot | Shape | Question |
|---|---|---|---|
| Content (this doc) | patient_tier_entitlements (boolean) ✅ | unlimited use | "can I access this?" |
Services (service_plans, future) | patient_tier_limits ✅ + patient_tier_inclusions (planned) | counted, consumable | "how many free; extras paid/discounted?" |
service_plans are bookable clinical services (consultation, in-clinic recovery program, massage). The planned patient_tier_inclusions (tier_id, service_plan_id, grant_period, grant_quantity, carry_over_unused, prorate_on_upgrade) is "this tier includes N free consultations/year."
Discipline so the two axes don't blur:
- Never model a counted service as a boolean entitlement (or vice-versa). Content → entitlements; services → limits/inclusions.
program(digital content) ≠ "in-clinic recovery program" (service_plan). Same word, different entities. A digital program can be delivered as a service (supervised mode), but the program is the content and the service_plan is the commercial/delivery wrapper.- The per-patient consumption counter is a service-axis need (counting bookings), not a content need (content is unlimited-use) — so it is not built now; it lands with
service_plans+ appointments. (Current metering is org-level only, 000019.) - A tier "discount on extras" is pricing/commerce — largely external; the platform tracks allowance + consumption, the discounted overflow price is an external detail.
The single convergence point is appointments (appointments-substrate.md), already designed for both worlds: it carries protocol_id (a supervised digital-program session) and patient_service_plan_id + plan_session_number (a booked service). Content-delivery and service-delivery both flow through it, billed against the tier's inclusions.
Copy-on-derive: enrollment, recall, retention (decided)
- Copy for both prescribe and enroll — no sharing, no versioning. Sharing trades cheap storage (~$10/mo even at 1M copies on Aurora) for versioning + polymorphic-RLS complexity; not worth it. Copy stays uniform and avoids RLS ambiguity. Scales comfortably to ~500k copies before it's even a conversation; ended-enrollment copies can be purged to track active footprint. Storage cost is negligible (~$10/mo at 1M copies on Aurora) — do not cap enrollments for DB cost; cap (if ever) for product/clinical/packaging reasons (e.g. a per-tier active-enrollment limit).
- Standalone play does not copy. A standalone session run is a protocol-less, engagement-metered
session_runagainst the live session row (nopatient_specificinstance) — so it is engagement-tracked, never in adherence (adherence is protocol-derived). - Globally harmful exercise recall is one shared-row archive + a play-time guard (exercises are referenced, not copied) — applies to all copies, no ripple.
- "Wrong exercise in this program" → (a) template-only for new enrollees is the chosen default; (b) a program-scoped recall guard keyed on
(program-family, exercise)and (c) a lineage ripple (source_program_id+session_exercises.deleted_at) are future options, to stay non-breaking on the existing substrate. - Retention valve (future): purge a completed/ended enrollment's session structure — delete the copied
sessions(CASCADE →session_exercises), keep the program shell + protocol; runs survive viasession_runs.session_id ON DELETE SET NULL, events have no FK. ~98% reclaim, stats intact.
Built vs. net-new
| Piece | Status |
|---|---|
Content model, copy-on-derive, _select_self family, play-time + Today gate | ✅ shipped |
patient_tiers / patient_subscriptions / entitlements / limits / snapshots / overrides | ✅ shipped |
/me/library/programs (platform-only program catalog) | ✅ shipped (platform-only) |
Catalog layer (catalog_sections / catalog_entries) | ❌ net-new |
| Content-access enforcement (tier-gating at the play gate + catalog lock state) | ❌ net-new |
patient_content_grants (per-content grants + legacy migration) | ❌ net-new |
Catalog endpoints (clinic-union programs, /me/library/sessions, /me/library/exercises) | ❌ net-new |
| Exercise-library faceted endpoint + favorites + patient exercise preview | ❌ net-new |
service_plans / patient_tier_inclusions / per-patient consumption / appointments | ❌ future (separate axis) |
Open decisions
- Tier modeling for the 4 legacy offerings — Free, Annual, Lifetime, Founders. If Annual and Lifetime share the access bundle (everything), that's one tier at two terms; if Lifetime includes more (prescriptions) than Annual, two tiers. Founders = Lifetime bundle + cohort marker.
- Gating reach — confirmed per-clinic (clinic decides what's premium; matches the schema). Platform-uniform would be a deliberate narrowing.
- Catalog flag shape — resolved: catalog membership +
required_entitlementon the entry (not a content-row boolean/enum). - First build cut — programs + standalone sessions; audio/educational-videos plug in later.