Patient catalog & access — implementation plan
Status (2026-06-19): Phases 1, 1.5, 2, 3.1 shipped + verified live. Catalog browse (clinic curation + portal "Programe ghidate" tab), patient self-enroll (the deep-copy 500 fixed → atomic admin tx; live
201), the access engine (play gate free/premium/grant/prescription+sub, browse PRO lock state, entitlement resolver, capture column onprotocols), and per-patient content grants (3.1) are all in. Build plan for the design in patient-catalog-and-access.md (authoritative for the why; this doc is the how + build order). See "Paid-launch plan" below for what's left — the access engine is built and everything except Bucket 3 shipped (enroll-capture + enforcement; therequired_entitlementwriter + clinic Free/Premium control; the tier-entitlement-assignment endpoint, all originally landed 2026-05-31). A clinic can now mark content premium and build a tier that grantscontent.premiumthrough the product — and the clinic patient-tier-management UI (1D.2.D / L17) shipped, so it's all self-serve. Remaining (September-launch-gating): Bucket 3 (the D2C first-party-clinic + ~20k legacy-patient migration + Free/Everything tier seed + legacy-ownership grant backfill) — a pre-launch task, not an active workstream yet (see headline below). The platform is launch-capable for paid self-enroll.
Paid-launch plan (decided 2026-05-31 — paid/premium self-enroll IS a September full-launch requirement)
September-launch-gating headline — Bucket 3 (D2C launch data). The one remaining paid-launch item is a pre-launch task, not an active current workstream: stand up the RestartiX-operated first-party clinic org, seed its Free / Everything tiers (incl. the
content.premiumgrant), run the ~20k legacy-patient migration (patients + profiles +patient_subscriptions, ~11k treatment plans, ~5k subscriptions), and backfillpatient_content_grantsfrom legacy per-program ownership (each legacy purchase → a lifetime grant under the first-party clinic). This depends on the legacy DB, the billing/SKU mapping decision (§3.2 open item), and a dedicated session — it can't be built/tested in this environment. This is the same ~20k migration the portal-auth custom-domain handoff feeds (the legacy→new self-migration bridge provisions accounts; this seeds their content/subscription state). Detail lives in §3.2/§3.3 + Bucket 3 below.
The access engine is built + tested; the controls that make content cost a tier are not all wired — so nothing is paywalled yet in practice (no content carries a premium marking, because the authoring path doesn't exist; a free program enrolls + plays for anyone; onboarding no longer auto-subscribes — originally it auto-subscribed to the patient tier granting content.prescription_play, then to a nothing-granting free tier, and is now revised so the paywall lives in provisioning: the default tier is renamed standard and carries content.prescription_play, but a self-signup patient gets no subscription at all and must be put on a tier to play prescriptions — see the paywall note below). The loose wires discovered 2026-05-31:
Enroll-capture + enforce NOT wired.CLOSED 2026-05-31 (Bucket 1).protocols.Service.Createnow resolves the source program's catalog requirement on self-enroll (catalog.RequiredEntitlementForProgram, platform-default + own-org, least-restrictive — mirroring the standalone gate), runs the access check (free OR grant OR tierHas) → 402content_lockedif blocked, and captures the least-restrictive code onprotocols.required_entitlementso the play gate re-checks it against the live subscription. Enrollment protocols are self-enroll-only by RLS (protocols_insert_self), so the gate keys onin.SelfEnroll. A direct API call can no longer enroll into premium content.No premium-authoring path.CLOSED 2026-05-31 (Bucket 2a).catalog.{Create,Update}EntryInputnow carryrequired_entitlement(update is tri-state viaSetRequiredEntitlement— absent = unchanged, null = free, code = set; the COALESCE patch model can't express "set NULL" so the UPDATE uses a CASE), api-clientCreate/UpdateCatalogEntryRequestexpose it, and the clinic entry editor has a "Make premium / Make free" toggle (→content.premium/ null) + a Premium badge. An unknown code trips theentitlements(code)FK → 400.- Tier-entitlement-assignment API CLOSED 2026-05-31 (Bucket 2b); clinic patient-tier UI deferred. The
patienttiersdomain gainedGET/POST/DELETE /v1/organizations/{id}/patient-tiers/{tierId}/entitlements(writes gatedpatient_tiers.manage, RLS-enforced) writingpatient_tier_entitlements+ api-client methods. A clinic can now build a paid "Everything" tier by granting itcontent.premium; the grant flows to new subscriptions via the subscribe-time snapshot. The clinic patient-tier-management UI (1D.2.D / L17) shipped 2026-05-31 — a/patient-tiersClinic surface (SWR-owned, gatedpatient_tiers.manage): list tiers, create, set-default, archive/activate, and per-tier toggles for the content entitlements (content.premium/content.prescription_play). Deferred within L17: the limits picker (L17 also lists it, but there's no runtimepatient_tier_limits-assignment endpoint — metering caps are orthogonal to paid content) and tier rename (create + the toggles cover the launch need).
Plus: standalone session play from the catalog isn't built (portal tab's session cards are display-only; backend gate exists).
Build order:
- Bucket 1 — Runtime. ✅ SHIPPED 2026-05-31. Enroll-capture + enroll-time access enforcement:
protocolsself-enroll looks up the entry'srequired_entitlement(least-restrictive across the patient's visible entries, matching the standalone gate), runs the access check (free OR grant OR tierHas) → 402 if blocked, and captures the code on the protocol so play stays gated. Reuses the Phase-2 access engine. Backend-only (no portal change: browse already hides the enroll link on locked PRO cards, andenrollInProgramActionsurfaces the 402 message; the gate is defense-in-depth for direct API calls). Newcatalog.RequiredEntitlementForProgramrepo+service lookup;protocols.Servicegains the entitlement/catalog/grant resolver trio (nil-disabled in crons/tests);evalEnrollmentCapturecollapses multi-entry requirements to a single captured code (free wins; else a held code; else first-code on grant; else locked). Tests:TestEvalEnrollmentCapture(unit) +TestProtocols_SelfEnrollBlocksLockedPremiumContent/TestProtocols_SelfEnrollFreeContentCapturesNil(integration).make check+ unit + integration green. - Bucket 2 — Authoring. ✅ SHIPPED 2026-05-31 (incl. the clinic tier-mgmt UI). (a)
required_entitlementwrite path — GoCreate/UpdateEntry+ api-client + the clinic "Free / Premium" toggle on the entry editor. Done. Test:TestCatalog_RequiredEntitlementWriter. (b) tier-entitlement-assignment endpoint (writespatient_tier_entitlements, gatedpatient_tiers.manage) + api-client. Done. Tests:TestPatientTiers_EntitlementAssignment+TestPatientTiers_EntitlementGrantReachesSubscription. (c) the clinic patient-tier-management UI = 1D.2.D / L17 — a/patient-tiersClinic surface (SWR-owned,patient_tiers.manage): list / create / set-default / archive-activate + per-tier content-entitlement toggles. Done (pnpm checkgreen). Deferred within L17: the limits picker (no runtimepatient_tier_limitsendpoint; orthogonal to paid content) + tier rename. Shortcut for the D2C first-party clinic: seed the premium markings + Free/Everything tiers (incl. thecontent.premiumgrant) via migration in Bucket 3 — the UI now also makes this fully self-serve for any B2B clinic. - Bucket 3 — D2C launch data (needs the legacy DB + SKU/billing mapping; dedicated session). First-party clinic org + Free/Everything tier seed; the ~20k patient migration (patients/profiles/subscriptions); legacy per-program ownership →
patient_content_grantsbackfill; rebuild legacy programs as platform/org content. Build the migration tooling, dry-run, cutover. (This is §3.2/§3.3 below.)
Decisions locked (recap from the design note)
- Copy-on-derive for both prescribe + enroll; no sharing, no versioning.
- Catalog membership = eligibility — no
self_enrollableboolean on content; a per-org catalog layer carries it. - Per-clinic gating — each (white-labeled) clinic curates which content is shown + premium.
- Capture-at-enroll keeps the play gate catalog-free.
- Two tier bundles —
FreeandEverything; Annual/Lifetime/Founders share theEverythingbundle, differing by term/cohort. Prescriptions are inEverything, notFree. - Audio =
sessions.kind='audio'(000023:175) — the catalog/access substrate handles it for free; only the portal audio player is "later." - Products/services are a separate axis — own catalog, reuse the pattern, bridged at appointments.
Dependencies & touch surface
- Substrate already shipped: content model + copy-on-derive +
_select_selfRLS;patient_tiers(000005),patient_subscriptions+ entitlements/limits/snapshots/overrides (000007); entitlements catalog (000004). - New domain:
catalog(internal/core/domain/catalog/). Modified:programs,sessions,exercises(browse endpoints + access hook),protocols(capture column). - Run
/new-domain catalogand/new-migrationfor the per-step checklists (permission seeding, RLS, route gating, classification entries).
Prerequisite — sessions gain a published state (shared with the render-pipeline chat)
Sessions adopt the program lifecycle so the catalog can gate on readiness and the render trigger is uniform. Owned by the async-render-pipeline chat (it also fixes their standalone-session render break); Phase 1's standalone-session surfacing depends on it. SHIPPED 2026-05-30 (commit 6f5fe93).
- [x]
sessions.status→draft | published | archived(defaultdraft; map existingactive→published, pre-prod).session_exercisesstaysactive/archived(dose rows follow the parent). - [x]
PublishSessionbecomes a durable flip + enqueue renders (not a readiness gate); readiness moves to attach/play, like a published program. The builder's render-state poller already watchespending → ready. - [x]
AttachSessionrequiressource.status='published'; the copy's renders are content-addressed cache hits. - [x] Edit-after-publish mirrors programs (content edits require a draft —
ErrSessionNotDraft; settingpublishedvia PATCH is rejected, use the publish endpoint), keeping the two lifecycles symmetric.
Phase 1 — Catalog layer (sections + entries)
Goal: clinics curate a per-org catalog (incl. selected platform content) into ordered, featured sections; patients browse the union of platform-default + their clinic's catalog.
Phase 1 shipped (backend + 1.5 UI). Migration
000032(sections/entries + clinic RLS/perms + the cross-domain patient-visibility policies for catalogued org content) + thecatalogdomain (clinic CRUD +GET /v1/me/catalog) + the clinic curation surface + the portal "Programe ghidate" tab.make check+ integration + frontend (typecheck/lint/build) green. Carried into later phases: program session-count in the browse summary (needs the same broader subtree RLS that self-enroll deep-copy will) and the enroll-time subtree read (Phase 2/3). Sessions carry directexercise_count/estimated_duration_s. Open in Phase 1 still: the target-contentstatus='published'browse/play gate (line below) — the browse join already filters it; the standalone-play gate lands with Phase 2.
1.1 Schema
- [ ]
catalog_sections—id,organization_id(NULL = platform-default),code(slug),name,description,translationsJSONB (P21),sort_order,status(draft|published|archived),created_by_principal_id,created_at/updated_at,deleted_at. Unique(organization_id, code); index(organization_id, sort_order). - [ ]
catalog_entries—id,organization_id(NULL = platform-default),section_id→catalog_sections(id)ON DELETE CASCADE,content_typeCHECK (program|session|educational_video) — audio issession(the entry filters by the session'skind),content_idUUID (typed-polymorphic, no FK — a dangling entry just doesn't render),sort_order,featuredBOOL, presentation overrides (cover_content_file_id→content_files,badge,subtitle,translationsJSONB),status,required_entitlementTEXT NULL →entitlements(code)(reserved here, enforced in Phase 2; NULL = free), audit cols. Unique(section_id, content_type, content_id); index(organization_id, content_type, content_id)— the "is this content catalogued for me" lookup the access check + standalone gate hit. - [ ] Classification registry entries for every new column (CI gate).
1.2 RLS
- [ ]
catalog_sections_select/catalog_entries_select(PG permissive-OR):- platform-default (
organization_id IS NULL AND status='published' AND current_app_principal_id() IS NOT NULL) — any authenticated principal (incl. patients); - org published (
organization_id = current_app_org_id() AND status='published') — patients + staff of the clinic see the live catalog; - org all incl. drafts (
organization_id = current_app_org_id() AND current_app_has_permission('catalog','read')) — staff curation.
- platform-default (
- [ ] INSERT/UPDATE/DELETE: org-tier only,
catalog.manage; platform-default rows via AdminPool (Console).
1.3 Permissions
- [ ] Seed
catalog.read,catalog.manage; grant toadmin+specialist(read also tocustomer_support).
1.4 Endpoints
- [ ] Clinic (gated
catalog.manage/catalog.read):GET/POST /v1/catalog/sections,PATCH/DELETE /v1/catalog/sections/{id},PUT .../sections/reorder;POST/PATCH/DELETE /v1/catalog/sections/{id}/entries,PUT .../entries/arrange(mirror the programsarrangepattern). - [x] Patient browse:
GET /v1/me/catalog— a unified catalog read unioning platform-default + own-org published sections/entries, grouped + ordered, content-resolved (program/session summaries). Session entries cover standalone sessions, so no separate/me/library/sessions. Supersedes the platform-only/me/library/programs(left in place; the portal migrates in 1.5). - [ ] Browse gates on the target content's
status='published', not just the entry's. Editing a published session/program unpublishes it (→ draft;ErrSessionNotDraft/ unpublish-to-edit, shipped in the prerequisite), so a published entry can point at a now-draft target — the browse join must hide those, and the standalone-play gate must reject them. (Surfaced verifying the session-lifecycle prerequisite.)
1.5 UI (separate PRs)
- [x] Clinic curation surface (sections + entries: create/rename/publish/archive sections, add published programs/sessions as entries, feature/unfeature, publish/unpublish, remove) — follows the SWR-owned program-builder pattern.
/library/catalog+/library/catalog/{sectionId}. Shipped (commits667ce7e,7b2fba9). Deferred (cosmetic): drag-reorder, cover-image picker, async-typeahead content picker, i18n of the editor strings (currently English) — all post-Phase-1. - [x] Portal "Programe ghidate" catalog tab — migrated the
/librarysurface off the platform-only/me/library/programsontoGET /v1/me/catalog; renders sections → program/session cards (localized name/subtitle/description, featured highlight, session exercise-count/duration badges). Program cards link to enroll (/my-programs/{id}?source=library); session cards are display-only until Phase 2 gates standalone play. Relabeled the tab to "Programe ghidate" (freeing "Biblioteca" for the Phase 4 exercise library). Deferred: the featured hero = prescription/Today card pinned above is the Today-gate roadmap item (not built); featured entries get a soft in-section highlight for now.
Phase 2 — Access enforcement (tier gating)
Goal: gate play by tier; render shown-but-locked (PRO) cards on browse.
Shipped 2026-05-30. Migration
000033+ theaccessdecision package + thepatientsubscriptionsresolver + capture-on-protocols+ theenforcePlayGateaccess branch + browse lock state + portal PRO cards.make checkgreen.Decision recorded — gate rules. The two specs only ever disagreed on the code list, not the gate rules. Catalog content gates per-entry (
required_entitlement— NULL = free for any patient, no subscription needed; elsetierHas(code)). Prescriptions gate oncontent.prescription_play+ an active subscription. Seeded exactly two codes (content.prescription_play,content.premium); the orphancontent.enrollment_playwas dropped and the Phase-4 exercise-library codes (content.library_browse, exercise play) are deferred to Phase 4 (they gate the faceted exercise library, not catalog content — which is also why the clinic tier editor shows exactly the two content toggles today).Revised 2026-05-31 → paywall = no auto-subscribe. The gate moved twice. (1) Originally the seeded default tier granted
content.prescription_playand onboarding auto-subscribed everyone ("default-open"). (2) Interim: the default tier was renamed →freeand granted nothing, but onboarding still auto-subscribed (the sub just carried no entitlements). (3) Final: the paywall lives in provisioning — onboarding does not auto-subscribe a self-signup patient (nopatient_subscriptionsrow →EvalPrescriptiondeniessubscription_required). The default tier is renamed →standardand does carrycontent.prescription_play(000009 seeds it on the live junction + the v1 snapshot), so it's the meaningful base tier patients subscribe to (self-checkout / staff Abonament tab / a tier-pinning invite). A self-signup patient gets full portal access (browse free catalog, see the upsell) but plays no prescriptions until put on a tier. Rationale in decisions.md → Why onboarding does not auto-subscribe.
2.1 Entitlement codes
- [x] Seed
content.prescription_play+content.premium(000033, non-regulated). Phase-4 exercise codes deferred;enrollment_playdropped. - [x]
Default "Patient" tier grantsRevised → paywall = no auto-subscribe (2026-05-31): the seeded default tier is renamed →content.prescription_playstandardand does carrycontent.prescription_play(000009 companion function: live junction + v1 snapshot). The paywall is that onboarding does not auto-subscribe (ResolveDefaultTierfallback removed; only an invite/share-link tier pin provisions a sub at signup). A self-signup patient has no subscription → prescriptions deny until they're put onstandard(or higher). Per-clinic premium tier configuration is clinic-side data, not a platform seed.
2.2 Access-check service
- [x]
internal/core/access(leaf):EvalCatalogContent+EvalPrescription→Decision{Allowed, LockReason}(requires_upgrade/requires_tier/subscription_expired/content_unavailable).patientsubscriptions.Service.ResolveForPatient(admin pool, by id) /ResolveForCurrentPatientAtOrg(RLS) build theEntitlementSet(active sub within period + snapshot merged with active overrides; period gap fixed). Grant path is a Phase-3 no-op param. - [x] Mode rules wired in
enforcePlayGate: catalog content →free(entry) OR tierHas(req); prescription →EvalPrescription. Exercise browse/play → Phase 4.
2.3 Capture-at-enroll
- [x]
protocols.required_entitlement(column in 000023 CREATE TABLE per the editable-migration convention; FK toentitlements(code)). Set inprotocols.Service.Create: prescription →content.prescription_play; enrollment → caller's source-entry code (nil today). Re-checked against the current subscription at play.
2.4 Hook the gate
- [x]
enforcePlayGaterestructured: the access check runs for every mode (standalone catalog lookup / enrollment captured code / prescription rule), cadence stays prescription-only. Access locks → 402 Payment Required (cadence stays 409). Standalone play does one indexed catalog lookup (RequiredEntitlementForSession); uncatalogued/unpublished standalone →content_unavailable. - [x]
GET /v1/me/catalogreturns per-entrylocked+lock_reason; the portal "Programe ghidate" tab renders shown-but-locked PRO cards (no enroll link, muted, lock icon).
2.5 Prescription rule
- [x] Prescription playability bounded by the live subscription — the resolver only returns codes for an active, in-period subscription, so playability lapses when
current_period_ends_atpasses; never grantable (the grant path is catalog-content-only). - [x] Subscription expiry write path + sweep — SHIPPED 2026-05-31. The access window is now actually settable (it was a dormant column before):
CreateParams/CreateOnTxcarry the period,SetSubscriptionParamsgainsperiod_ends_at(nil = perpetual; handler rejects a past cutoff), and the clinic Abonament dialog offers duration presets / custom date / "Indefinite" + an "Active until / Expires soon / Expired" indicator. A daily sweep (cmd/patient-subscriptions-expiry-sweep+crons.tf, mirroringexpire-hard-cap-protocols) flips lapsed live subs →expired(audited, system actor) — hygiene only; the resolver's live date-check already revokes access the moment the window passes. Renewal is manual (re-run Set-subscription); auto-renew/Stripe deferred. Tests:TestPatientSubscriptions_ExpirySweep+TestPatientSubscriptions_PeriodGate. Rationale in decisions.md → Why subscription expiry is per-subscription + manual-renewal.
Phase 3 — Grants + the 20k migration
3.1 shipped 2026-05-30 (migration
000034+ thepatientcontentgrantsdomain +access.GrantKey+ gate/browse wiring + staff CRUD API). 3.2 + 3.3 remain launch-gate — they need the legacy DB + the billing/SKU decision (§3.2 open item) and can't be built/tested in this environment. The clinic grant-management UI is also a separate follow-up.
3.1 patient_content_grants
- [x] Table:
id,organization_id,patient_id,content_type,content_id,granted_from,granted_untilTIMESTAMPTZ NULL (NULL = lifetime; revoke = set it to now, no DELETE),source(legacy_migration|comp|promo),granted_by_principal_id,reason, audit cols. RLS: patient-self read (viacurrent_human_patient_profile_ids()— what the RLS browse resolver reads), staff SELECT/INSERT/UPDATE (content.grantperm). Index(organization_id, patient_id, content_type, content_id). Keyed onpatient_id(per-org), not the portablepatient_profile_id. - [x] Wired into the access check (catalog content only — never prescriptions): the third OR-branch (
granted) inaccess.EvalCatalogContent. Thepatientcontentgrants.Serviceresolves active grant keys for the play gate (ResolveGrantKeysForPatient, admin pool, by id — enrollment keys onprotocols.source_program_id, standalone on the session id) and the browse (ResolveGrantKeysForCurrentPatientAtOrg, RLS). Staff CRUD:POST/GET /v1/patients/{patientId}/content-grants+POST .../{id}/revoke(gatedcontent.grant; mintscomp/promo—legacy_migrationis admin-pool only). api-clientcontent-grantsmodule +PERM_CONTENT_GRANT.
3.2 First-party clinic + tiers
- [ ] Create the RestartiX-operated first-party clinic org (one tenant). Seed its
patient_tiers:Free,Everything(represent Annual/Lifetime/Founders as term/cohort — separate SKU rows sharing the Everything entitlement set, or one Everything tier + term on the subscription + afoundercohort marker; decide with the billing/SKU mapping). - [ ] Migrate the ~20k patients (patients + patient_profiles +
patient_subscriptionsper their legacy plan).
3.3 Legacy-ownership backfill
- [ ] Backfill
patient_content_grantsfrom legacy per-program ownership (each legacy purchase → a lifetime grant under the first-party clinic). - [ ] (Separate effort) rebuild legacy programs as platform/org programs — the content migration the legacy covers reuse.
Phase 4 — Exercise library ("Biblioteca")
Faceted browse over the taxonomy — not the catalog.
- [ ]
GET /me/library/exercises?zona=&obiectiv=&dificultate=&durata=&echipament=&q=&sort=&limit=&cursor=— server-side filter over the 000027 taxonomy + pagination;qusesunaccenttrigram. Gated bycontent.library_browse. - [ ]
patient_exercise_favorites(patient_id,exercise_id,created_at; patient-self RLS) — the heart. - [ ] "Recomandate pentru tine" sort — start with a heuristic (the patient's active program's body-regions/conditions); the AI ranking is a later hook.
- [ ] Standalone exercise play gated by
content.standalone_play(Free can browse, not play).
Phase 5+ — Out of scope here (separate axes / later)
- [ ] Portal audio player (audio sessions are catalogable now; only playback waits).
- [ ]
educational_videosentity + player + catalogcontent_type. - [ ] Service axis —
service_plans,patient_tier_inclusions, per-patient consumption counter, appointments. See tiers-and-subscriptions.md + appointments-substrate.md. - [ ] Products/services catalog (reuse the Phase-1 pattern) + cross-sell promo-slot.
Open items
- [ ] Annual/Lifetime/Founders as separate SKU rows vs. one tier + term — settle with the external-billing SKU mapping (§3.2).
- [ ]
patient_content_grantskeyed onpatient_id(per-org) — confirmed org-scoped (legacy ownership lives under the first-party clinic), not the portablepatient_profile_id. - [ ] Granularity of content gate codes — one
content.premium_contentvs. per-tier codes (clinic choice; the schema allows anyentitlements(code)on the entry).
Patient-tier versioning — made load-bearing (publish-on-save). ✅ CLOSED 2026-05-31
The patient_tiers.version / published / published_at + patient_tier_versions apparatus was dead scaffolding (no Go read, nothing bumped version, the UI couldn't set published → every UI-created tier was a silently-un-subscribable Draft, and marking one default wedged onboarding's ResolveDefaultTier). Now real, on the publish-on-save model:
- Three states from two booleans: Draft (
version=0, published=false) → Published v{N} (published=true, is_active=true) → Retired (is_active=false). Create makes a Draft; the first "Save & publish" cuts v1. PublishVersion(Go, AdminPool tx) replaces the entitlement junction with the submitted set, freezes apatient_tier_versionssnapshot (entitlements + resolved limits + metadata +changed_by_principal_id), and bumpsversion/published/published_at. The granularPOST/DELETE …/entitlementsendpoints (Bucket 2b) were superseded byPOST …/patient-tiers/{id}/versions;GET …/versionsreturns history.- Correctness:
patientsubscriptions.CreateOnTxnow snapshots from the published version (patient_tier_versionsat the resolved version, viajsonb_to_recordset), not the live junction — so unpublished draft edits never leak to new subscribers. (Grandfathering still rides the per-subscription snapshot; existing subs are untouched.) - Guard:
service.Updaterejects setting an unpublished tier as default (cannot_default_unpublished) — the root fix for the wedge; the editor also disables "Set default" on Drafts. - Clinic UI: the
/patient-tierseditor reworked to batched local edit + Save & publish, state badges, and a per-tier version-history disclosure (lazyGET …/versions). - Tests:
TestPatientTiers_PublishVersion,TestPatientTiers_PublishedVersionReachesSubscription(rlstest). Deferred: "migrate v1→v2 subscribers" admin op (data model supports it); deliberate draft-staging on a live tier; the parallel org-sidetiers/tier_versionscarry the identical dormant pattern (out of scope here).
Deferred / parked (re-verified 2026-06-19 against current code; originally logged 2026-05-31 after Buckets 1–2 + L17 shipped)
Carried so we don't lose them when attention moves to feedback work. None block the D2C paid-self-enroll loop, which is functional end-to-end. Each item below is tagged September-blocking (must close before the full launch) or post-launch polish.
- [ ] Bucket 3 — D2C launch data. SEPTEMBER-BLOCKING (the real remaining launch-gating work): first-party clinic org + ~20k patient/subscription migration + seed the Free/Everything tiers (incl. the
content.premiumgrant) + the legacy-ownership grant backfill. Needs the legacy DB + the billing/SKU mapping decision; dedicated session. Pre-launch task, not an active workstream yet — see the headline at the top of "Paid-launch plan." (§3.2/§3.3.) - [ ] L17 limits picker. Post-launch polish — the clinic tier surface configures entitlements but not limits. Verified 2026-06-19:
patient_tier_limitsis still migration-only (no runtime writer; no limits control in the/patient-tierseditor). Closing it needs a…/patient-tiers/{id}/limitsassignment endpoint (mirror of the Bucket-2b entitlements endpoint) + api-client + a UI section. Orthogonal to paid content (limits = metering caps); reopen when per-tier usage caps are a need — not required for the September paid-self-enroll loop. - [ ] L17 tier rename. Post-launch polish — pure UI gap; the Go API (
UpdateParams.Name), api-client (updatePatientTier), andupdatePatientTierActionalready support it, the/patient-tierseditor just has no rename control yet. Verified 2026-06-19: still no rename control inapps/clinic/components/patient-tiers/patient-tiers-editor.tsx. Cheap finish-the-job; doesn't gate launch (tiers are nameable at create). - [ ]
generated.tsis stale / can't be cleanly regenerated (PLATFORM-WIDE, not just catalog). Post-launch polish. The committedpackages/api-client/src/generated.tswas built with a differentopenapi-typescriptversion, sopnpm openapirewrites the file wholesale. Verified 2026-06-19: still un-regenerated (~15k lines; only unrelated feature commits have touched it since). Consequence: the published OpenAPI contract and the generated TS types drift from the Go API and nobody can cleanly fix the spec for any domain. Concrete symptoms:generated(resolved — the Go API now returns them, see versioning above), andPatientTierdeclaresversion/published/published_atthe GotierToJSONomitsCreatePatientTierRequestmarksis_default/sort_order/currencyrequired though the Go API treats them optional (worked around by sending defaults); the catalog + tier-versioning types were hand-rolled to dodge the same issue. Fix: a one-time version-aligned regen (accept the big mechanical diff once, isolated commit, coordinate with other in-flight api-client work) → spec becomes authoritative again and future spec edits diff cleanly. Low runtime impact today (no consumer reads the phantom fields) so it doesn't gate launch — but it's a latent type-vs-runtime trap and blocks clean spec maintenance, worth a pre-launch cleanup window. - [ ] Standalone session play from the catalog. Post-launch polish (verify against the September content plan) — the portal "Programe ghidate" session cards are display-only; the backend standalone-play gate exists but the portal doesn't play a standalone catalog session yet. Verified 2026-06-19:
apps/portal/app/(patient)/library/page.tsxstill comments the session cards are display-only. Reopen and reclassify as September-blocking only if standalone sessions are a launch content type — programs are the primary one, so this is polish unless that changes. - [ ] Portal
content_locked(402) upgrade prompt. Post-launch polish — on a locked enroll/play the portal shows a generic message, not a tailored "this needs the X plan" message. Verified 2026-06-19:apps/portal/lib/session/gate.tsnow surfaces the 402 access-locks gracefully (no raw crash overlay), but the message catalog still has a single genericcontent_lockedstring (apps/portal/messages/{en,ro}.json). UX polish only: browse already hides the enroll link on locked PRO cards (so the normal path rarely hits the 402), and the platform's model is external billing (subscriptions are provisioned, not bought in-app), so there's no missing in-portal checkout — just a nicer message.