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, and again at every play against the catalog's current requirement |
| 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 |
An enrollment follows the catalog's CURRENT price (2026-10-08). The enroll decision resolves the catalog, and the requirement is still captured on the protocol — but ongoing play re-reads what the source program requires in the catalog today (one batched indexed lookup, RequiredEntitlementForPrograms) and checks it against the current subscription and grants. So a programme made premium after a patient enrolled locks for that patient unless their tier or a content grant covers it. Keeping an existing patient on is the clinic's explicit act — a grant, with a reason, from the patient's Abonament tab — so grants are the one record of "this person owns this programme" (the legacy migration lands the same way, as migration grants). The captured code is the fallback only when the source program is no longer catalogued, so pulling a premium programme from the catalog does not make every existing enrollment free. This replaces the earlier capture-only rule and its "holding the programme beats the price tag" carve-out, which let anyone who enrolled while a programme was free bypass grants forever. 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.