Skip to content

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_self RLS family) and the tier substrate (patient_tiers 000005, patient_subscriptions 000007 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)

SurfaceWhat it isBacked by
Your plan ("Programul meu")the active prescription / Today cardcadence 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 (cover content_file_id, badge, subtitle), and the gating field required_entitlement (NULL = free). Entry existence = the content is in the patient catalog; the entry's required_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:

  1. 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.
  2. Per-patient grantspatient_subscription_overrides (entitlement-level, bounded or lifetime; shipped) and a net-new patient_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.
  3. Content requirementfree or required_entitlement, carried on the catalog entry (per-clinic), or a blanket capability for non-catalog content.

The check, by content type

ContentGated byResolves through the catalog?
Self-enroll programcatalog entry required_entitlement (per-clinic) OR content grant OR freeyes — at enroll, then captured on the protocol
Standalone sessionsameyes — one indexed lookup at CreateRun
Prescriptionprescription_play capability + active subscription (no grant, no lifetime)no — clinician-assigned, never catalogued
Exercise — playexercise_play capability (blanket)no
Exercise — browselibrary_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:

AxisSchema slotShapeQuestion
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_run against the live session row (no patient_specific instance) — 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 via session_runs.session_id ON DELETE SET NULL, events have no FK. ~98% reclaim, stats intact.

Built vs. net-new

PieceStatus
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

  1. 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.
  2. Gating reach — confirmed per-clinic (clinic decides what's premium; matches the schema). Platform-uniform would be a deliberate narrowing.
  3. Catalog flag shape — resolved: catalog membership + required_entitlement on the entry (not a content-row boolean/enum).
  4. First build cut — programs + standalone sessions; audio/educational-videos plug in later.