Programs & Protocols
The content model + patient-protocol model + adherence engine + stats surface for the rehab pipeline. A session plays one unit of content (exercises or audio). A program bundles sessions, optionally grouped into phases. A protocol (renamed from "assignment" on 2026-05-23) is the workflow wrapper around a patient-instance program — either a specialist-driven prescription (cadence + adherence) or a self-initiated enrollment (course progress, no cadence). Stats roll up from raw event data on demand.
Read order under the 2026-05-22 + 2026-05-23 + 2026-05-28 reworks
This spec was authored against an earlier shape and reworked three times. Top-down read order:
- Template updates & the prescription builder (locked 2026-08-23, not built) — template-updates-and-builder.md is authoritative for template→instance pull versioning (session-as-atom lineage), custom hand-composed prescriptions, and the builder-first flow. It supersedes this doc's "edits never reach existing copies" absolutism: edits still never propagate silently, but an explicit Publish update makes them pullable.
- Cadence + supervision + channel design (locked 2026-05-28) — apps/docs/architecture/cadence-and-supervision.md is authoritative for cadence_kind, supervision_mode, channel, mid-treatment edits, and adherence math. Anything in this doc that contradicts it is stale.
- Three-tier copy-on-derive model (locked + landed 2026-05-22) — current content architecture
- Implementation status — line-by-line ledger of what shipped + what's deferred (authoritative for current schema + Go shape)
- Pre-rework narrative sections below (Domain model, Cadence, Adherence, Stats, API inventory) — describe the original design; column names + table names + FK shapes diverge from what shipped. When sections below disagree with the cadence design doc or with Implementation status above (or the migrations), the cadence design doc + migrations + Implementation status win.
The renamed/dropped identifiers across the doc (when you see them in the pre-rework narrative): patient_assignments → protocols, assignment_pauses → protocol_pauses, assignment_kind → kind, session_runs.assignment_id → dropped (derivable via sessions.program_id), program_sessions junction → dropped (sessions point at parent program via sessions.program_id), program_versions / session_versions → dropped (no snapshot; copy-on-derive provides isolation), modality → supervision_mode (unsupervised | supervised), cadence_kind=appointment_driven → dropped (folded into supervision_mode=supervised), appointments.channel → added (in_person | online_live).
Supersedes / reshapes
This doc supersedes apps/docs/features/treatment-plans/ (the older treatment-plan / patient-treatment-plan / patient-session-completions design). It also reshapes:
- exercise-library — F9.1 Phase 2 (dual-ownership for
exercises) is folded into Phase 1 below - sessions — adds
kindenum (exercise|audio),ownership_kindtiers, plusprogram_id+phase_id+order_in_phaseonsessionsto replace the retiredprogram_sessionsjunction
When a feature spec disagrees with this doc, this doc wins. Architecture impact: introduces the catalog-ownership pattern (P49) in patterns.md.
Tied to June 10 beta launch
The Phase 1 substrate work below ships before the June 10 beta launch. Phase 2 builds the launch features on top. Schema decisions here are made to MINIMIZE post-launch migrations on patient data — the substrate is settled in Phase 1 even where the consumer code lands in Phase 2 or Phase 3.
What this enables
- Clinics can build programs — multi-week, optionally phased, multi-session journeys with cadence. The unit of prescription is always a program (never a single session).
- Clinics can upload their own content (exercises, audio, files) alongside the platform catalog, with strict tenant isolation (substrate Phase 1; UI Phase 3)
- Specialists can prescribe programs with two cadence shapes (patient-anchored
flexibleand calendar-anchoredscheduled) along an orthogonalsupervision_modeaxis (unsupervised home work vs. supervised sessions, where each supervised session is an appointment with per-appointmentin_person/online_livechannel) — see cadence & supervision - Patients can self-enroll in guided programs ("like a course") with course-progress tracking, separate from adherence
- Patients can browse and play standalone sessions from a self-service catalog — engagement-tracked, no formal enrollment, no clinical adherence semantics
- The clinic app surfaces patient stats that join clinical record (API) with playback health (Telemetry) in one coherent view
Two worlds (read this before reasoning about assignments)
The patient-facing model has two completely separate worlds. Conflating them was the original design's mistake.
World 1 — Prescribed work (specialist-driven, clinical)
A specialist prescribes a program. Always a program — never a single session. The program has cadence (flexible patient-anchored — N/week with optional rest cycle, or scheduled calendar-anchored weekdays) along an orthogonal supervision_mode axis (unsupervised at home vs. supervised — each supervised session is an appointment with its own in_person/online_live channel). See the authoritative cadence & supervision design. This is the clinical/adherence lane.
Schema: protocols(kind=prescription, program_id, cadence_*).
World 2 — Self-service play (patient-driven)
Three browsing layers, all distinct UX:
- Exercise catalog — browse + preview library content (single exercises). Optional play with engagement tracking; no enrollment semantics.
- Standalone session catalog — playable one-off sessions ("1 neck-pain release after a stressful day"). Patient browses, plays, gets metered/tracked for engagement. No enrollment ceremony, no "session 1 of 1" framing, no course progress. Just play.
- Guided program catalog — premade multi-session journeys the patient self-enrolls in. Course-progress tracking ("Phase 2 of 4 · session 3 of 5"). Distinct from prescription — patient-driven, no clinic involvement.
Schema:
- Standalone session play →
sessions.program_id IS NULLfor the played session (engagement lane; no protocol row; the droppedsession_runs.assignment_idcolumn previously carried this flag, now derivable via the chain) - Guided program enrollment →
protocols(kind=enrollment, program_id, cadence_kind=NULL)(course progress; no adherence denominator)
Why protocols is programs-only
Earlier drafts of this spec carried a session_id XOR program_id polymorphism — assignments could target a single session OR a program. That polymorphism was deleted in F0 (2026-05-22). The clinical model doesn't have a "single-session prescription" concept; maintenance work is either a maintenance-program or self-service engagement. Either way, no session-direct prescription.
Result: protocols has program_id NOT NULL, no session_id, no session_version_id. Single source of truth: an assignment is always a program-relationship (prescription or enrollment).
The patient nav reflects this directly:
- Home (dashboard)
- My Programs — active prescribed + enrolled programs (from
/v1/me/assignments) - Library — three browse surfaces in one: exercises (preview), standalone sessions (play), guided programs (enroll)
- Progress — own stats (adherence + course-progress + history)
Three-tier copy-on-derive model (locked + landed 2026-05-22)
Architectural pivot after L2/L3 smoke test — rework SHIPPED
The Phase 2 substrate originally shipped against a "shared-by-reference" model where multiple patients could be prescribed pointing at the same programs row (and a program_sessions junction shared sessions across programs). That model was wrong — clinic specialists need to edit a patient's prescribed program independently ("drop this exercise for this patient," "reduce dose"), which is impossible when rows are shared.
The three-tier copy-on-derive rework landed 2026-05-22 on top of c87a904. Schema edits to the pre-prod migrations (000023, 000025, 000026 — no ALTER TABLE stacks); deep-copy primitives implemented in programs.Repository.DeepCopyProgram + CopySessionIntoProgram; assignments domain now calls programs.Service.PrescribeFromTemplate at prescribe + self-enroll; admin-pool bridges deleted; RLS rewritten to walk sessions.program_id directly. The "Implementation status" subsection at the end of this chapter is the running ledger of what's done vs deferred.
Three tiers — same shape for programs and sessions
Both programs and sessions exist at three tiers:
| Tier | Programs | Sessions |
|---|---|---|
Platform-curated template (ownership_kind=platform, organization_id=NULL, patient_id=NULL) | Library catalog — patient self-enrollable, clinic-prescribable | Standalone library — browseable + playable in engagement |
Org-curated template (ownership_kind=org, organization_id=X, patient_id=NULL) | Clinic's own templates — saved-variants of platform programs OR built from scratch; reusable for prescribing | Clinic's own standalone library sessions OR program-instance sessions inside org programs |
Patient-instance (ownership_kind=patient_specific, organization_id=X, patient_id=Y) | One per (patient, prescribe/enroll). Specialist edits this directly — patient read-only. | Program-instance sessions inside a patient-instance program |
Copy-on-derive — every transition between tiers is a server-side deep copy
Library template (platform)
│
▼ copy-on-attach-to-org-program / copy-as-org-variant
Org-curated template
│
│ AND (also from library directly for self-enroll)
▼ copy-on-prescribe (clinic) / copy-on-self-enroll (patient)
Patient instanceSpecific copy moments (every one is a server-side deep copy inside one transaction):
| Trigger | What gets copied |
|---|---|
| Clinic attaches library session L to org program X | new sessions row (program_id=X) + all child session_exercises rows. No lineage column — session origin is not tracked. |
| Clinic clicks "Save as variant" on program A → org variant A_v1 | new programs row (derived_from_program_id=A) + all phases + all sessions + their exercises + all assets |
| Clinic copies org A_v1 → A_v2 | same as above with derived_from_program_id=A (the root, not A_v1 — see "Flat lineage" below) |
| Clinic prescribes program T to patient | new programs (patient_specific, patient_id=..., derived_from_program_id=NULL) + full deep copy of children + new protocols row with source_program_id=T |
| Patient self-enrolls in library program L | same as prescribe, with kind=enrollment + source_program_id=L on the assignment |
A typical 4-phase × 5-sessions × 8-exercises program copy = ~200 inserts. Bounded, single-tx.
Exercises are NEVER copied. They're the atomic catalog content, referenced by ID. session_exercises rows (the dose: sets, reps_per_set, rest_*, mode, side) ARE copied — that's where per-patient mutability lives. Specialist re-doses a patient by UPDATE-ing the patient-instance's session_exercises row.
Sessions do NOT carry lineage. A session is a session — standalone library row, program-instance row, or patient-instance row. If you copy a session, it's a new session; we don't track where it came from. The only structural metadata on a session is program_id (which immediate parent program it belongs to, if any).
Lineage column on programs (org-tier saved variants only)
ALTER TABLE programs
ADD COLUMN derived_from_program_id UUID
REFERENCES programs(id) ON DELETE SET NULL;Single column. Only programs — sessions have no lineage column.
ON DELETE SET NULL— if a parent template is retired, derivatives survive as their own thing (don'tCASCADE— destroys clinic work; don'tRESTRICT— locks platform from ever retiring anything once forked).- Set only when a clinic explicitly clicks "Save as variant of program X." Cleared (NULL) for platform programs, root org programs, and patient-instance programs.
- Points at the family root (not the immediate parent). All variants of a root are siblings in a flat list — no recursive tree walks.
Flat lineage — variants point at the root, not at the chain
When the clinic does "Save as variant" on a row that's itself already a variant, the new variant points at the root of the existing family, not at the row it was copied from:
def save_as_variant(source_program_id):
source = get_program(source_program_id)
root_id = source.derived_from_program_id or source.id # already a variant? use its root. else use itself.
new_program = deep_copy(source)
new_program.derived_from_program_id = root_id
save(new_program)So:
- A (root) —
derived_from_program_id = NULL - A_v1 (clinic save-as-variant of A) —
derived_from_program_id = A - A_v2 (clinic save-as-variant of A_v1) —
derived_from_program_id = A← same root, not A_v1 - A_v3 (save-as-variant of A_v2) —
derived_from_program_id = A— still same root
"Show all variants of A" is one flat query: SELECT WHERE derived_from_program_id = $A. No recursion, no tree walks.
Source-tracking — separate from lineage
For "how many patients are on Knee Rehab?" analytics, the assignment row records which template was prescribed:
ALTER TABLE protocols
ADD COLUMN source_program_id UUID
REFERENCES programs(id) ON DELETE SET NULL;- Set on every prescribe and every self-enroll — records the template (org or platform) that was the source.
ON DELETE SET NULL— same rationale as lineage.- The patient-instance program row itself carries NO origin pointer (
derived_from_program_idis NULL on patient_specific rows). The assignment is the right home for source metadata.
Combined analytics query — patients on Knee Rehab including all variants of the family:
SELECT count(*)
FROM protocols pa
WHERE pa.source_program_id = $1 -- the root template
OR pa.source_program_id IN (
SELECT id FROM programs
WHERE derived_from_program_id = $1 -- + all variants in the family
);Lineage (on programs) and source-tracking (on protocols) are two separate concepts, two different homes, that join cleanly when analytics need both.
Curated promote-to-root — derived_from_program_id cleared explicitly
When a specialist's variant has diverged enough that it's clinically distinct, they explicitly cut the lineage:
POST /v1/programs/{id}/promote-to-rootThis is a curated clinical event — gates on content.write and gets its own audit row. NEVER automatic; "too much change" is judgment, not a schema fact.
Patient library catalog filter
The patient self-enrollable catalog (/v1/me/library/programs) filters to platform published programs:
WHERE ownership_kind = 'platform'
AND status = 'published'No lineage predicate needed — ownership_kind='platform' already excludes org-tier saved variants. (If platform team wants a "v2" of an existing library program, they publish it as a separate root program — they don't save it as a variant of v1.)
What gets deleted from the current substrate
| Current | Why it goes |
|---|---|
program_sessions junction table | Replaced by sessions.program_id (nullable; set when session is inside a program). One-to-many; junction is redundant. |
session_versions snapshot table | Templates don't need versioning (nothing depends on history at the live-data level); patient instances ARE the snapshot. |
program_versions snapshot table | Same reason. Patient instance is the snapshot. |
protocols.program_version_id | No version pin needed when each patient has their own program row. |
protocols.session_version_id | Already gone in F0. |
What new substrate the rework adds
| New | Purpose |
|---|---|
programs.derived_from_program_id | Lineage — org-tier variant family. NULL for platform / root org / patient_specific programs. Points at family root (flat, not tree). |
protocols.source_program_id | Source-tracking — which template the patient was prescribed/enrolled from. Separate concept from lineage. |
sessions.program_id | Replaces the junction table. Sessions have NO lineage column. |
POST /v1/programs/{id}/promote-to-root | Curated lineage clearing (org variants only) |
Server-side deep-copy logic on attach-session-to-program, prescribe, self-enroll, save-as-variant | The actual copy operations |
Adherence resolver simplifies
-- Was (current):
SELECT pa.id FROM protocols pa
JOIN program_sessions ps ON ps.program_id = pa.program_id
WHERE pa.organization_id = $1 AND pa.patient_id = $2
AND ps.session_id = $3 AND pa.status IN ('active', 'paused')
ORDER BY pa.created_at DESC LIMIT 1
-- (needed ROW_NUMBER tie-break across overlapping programs)
-- Becomes (post-rework):
SELECT pa.id FROM sessions s
JOIN protocols pa ON pa.program_id = s.program_id
WHERE s.id = $1 AND pa.patient_id = $2
AND pa.status IN ('active', 'paused')
-- One row exactly. No tie-break, no fallback. Each patient owns their own sessions
-- so session_id → program_id → assignment_id is unambiguous.What dissolves naturally
- Programs
HandleMyGetProgramadmin-pool fallback (the assignment-proof second path +GetProgramByIDForAssignedPatient+ the 3*Adminnested loaders) — dissolved in this rework. Patient-instance programs arepatient_specifictier and the newprograms_select_selfRLS policy (000026) permits the patient natively.HandleMyGetProgramis now a single RLS-scoped call. - Org-tier program patient-detail-view gap in the launch-gaps list — dissolved.
- TV pair-claim explicit
assignment_idgap — dissolved.session_runs.assignment_idwas dropped in the 2026-05-23 rename pass; the protocol a run satisfies is derivable server-side viasession_runs.session_id → sessions.program_id → protocols.program_id, deterministically since copy-on-derive means no two protocols share aprogram_id. The portal?aid=plumbing and theassignment_idrequest-body field were removed together; see [[feedback-copy-on-derive-three-tier]].
Edits to a template never back-propagate or forward-propagate
- Library
Knee Rehabedits → don't touch existing org variants (each variant was a copy) - Org
Knee Rehab — My Clinic Editionedits → don't touch existing patient prescriptions (each prescription was a copy) - Library or org edits DO affect FUTURE copies — next prescribe/enroll picks up the latest
This matches how authoring-tool products (Figma components, Notion templates, Webflow symbols) handle "what was a copy, stays a copy." Right model for clinical work where each patient's program is owned by their clinical relationship, not by a shared template.
exercises_select_self RLS substrate (rewritten 2026-05-22)
Phase 3 will introduce org-uploaded exercises. The patient-self RLS branch landed in 000026 alongside the other patient-self policies in the rework, mirroring the sessions_select_self + session_exercises_select_self shape on the new direct chain:
CREATE POLICY exercises_select_self ON exercises FOR SELECT USING (
EXISTS (
SELECT 1
FROM session_exercises sx
JOIN sessions s ON s.id = sx.session_id
JOIN protocols pa ON pa.program_id = s.program_id
JOIN patients p ON p.id = pa.patient_id
WHERE sx.exercise_id = exercises.id
AND s.deleted_at IS NULL
AND s.program_id IS NOT NULL
AND pa.status IN ('active', 'paused')
AND p.patient_profile_id = ANY (current_human_patient_profile_ids())
AND p.deleted_at IS NULL
)
);No program_sessions JOIN — sessions.program_id is the direct chain to the patient's patient-instance program.
Implementation status (2026-05-22 + 2026-05-23 protocols rename)
Shipped end-to-end across two commits — three-tier copy-on-derive rework (2026-05-22) + protocols rename + session_runs.assignment_id drop + cascade fix (2026-05-23). make check + frontend pnpm check both green.
The 2026-05-23 follow-up renamed patient_assignments → protocols (the "assignment" naming was a relic of the shared-by-reference model where patients were "assigned to" shared programs; under copy-on-derive the row is the workflow wrapper around a patient-instance program, which "protocol" matches). Adjacent renames: assignment_pauses → protocol_pauses; assignment_kind → kind; Go domain package internal/core/domain/assignments/ → protocols/; permission codes assignments.* → protocols.*; URL paths /v1/patients/{id}/assignments → /v1/patients/{id}/protocols, /v1/me/assignments → /v1/me/protocols; TS Assignment types → Protocol. Same commit dropped the now-redundant session_runs.assignment_id column (derivable via session_id → sessions.program_id → protocols.program_id) and changed session_runs.session_id + session_runs.patient_id to ON DELETE SET NULL so the patient-erasure cascade chain (patients → programs → sessions → session_runs) terminates cleanly with anonymized run rows instead of being blocked.
Shipped (2026-05-22 three-tier copy-on-derive):
- Schema (000023 / 000025 / 000026 edits in place, no
ALTER TABLEstacks per pre-prod discipline):sessions.program_id+sessions.phase_id+sessions.order_in_phasecolumns added (000023); partial unique indexes for(program_id, phase_id, order_in_phase)and the flat-phase case; CHECK couplings (patient_id IS NULL OR program_id IS NOT NULL, order pairing with program_id)protocols.source_program_idadded (000023);program_version_idremovedprograms.derived_from_program_idadded (000025) withON DELETE SET NULL; CHECKs (derived_from_program_id IS NULL OR ownership_kind = 'org', self-reference guard); partial index for variant lookupsprogram_sessions+program_versions+session_versions+programs.published_version_iddropped (000025)- Three FK closures swapped in 000026 (
protocols.program_id → programs(id) ON DELETE CASCADE,protocols.source_program_id → programs(id) ON DELETE SET NULL,sessions.program_id → programs(id) ON DELETE CASCADE,sessions.phase_id → program_phases(id) ON DELETE SET NULL) - Patient-self RLS in 000026:
programs_select_self+program_phases_select_self+program_assets_select_self+sessions_select_self(rewritten to direct chain) +session_exercises_select_self(rewritten) +session_audio_items_select_self+session_assets_select_self+exercises_select_self(rewritten)
- Go domain code:
programs.Repository.DeepCopyProgram— full subtree copy (program + phases + sessions + session_exercises + session_audio_items + session_assets + program_assets) in one tx, pre-generates new IDs in Go so cross-row FK references fill in without a round-trip per rowprograms.Repository.CopySessionIntoProgram— single session subtree (header + exercises + audio + assets) for the silent-copy-on-attach UXprograms.Service.PrescribeFromTemplate— validates sourcestatus='published'+ not archived, deep-copies into patient_specific; returns the new program_idprograms.Service.AttachSession— silent-copies the library session into the program; clinic UI surfaces "Editing a copy" bannerprograms.Service.Publish— status flip + "no archived sessions inside the program" gate (no version snapshot)programs.Service.UpdateProgram— relaxed draft-only gate forpatient_specificrows (specialist edits patient-instance in place)- Superseded 2026-08-22. The gate was INVERTED: a template is now editable in place at any status, and a patient-instance is draft-only (unpublish first). See patterns.md → P49 → Tier decides editability, which is authoritative.
sessions.Service.PublishSession— render-ready state-check; returns the session, no version row- Three sessions repo queries (
ListAssignedSessionsForPatient/FindActiveAssignmentIDForSession/ValidateAssignmentContainsSession) rewritten to walksessions.program_iddirectly assignments.Service.Createrewritten to callprogramsService.PrescribeFromTemplate→ recordsprogram_id(new patient-instance) +source_program_id(original template) on the assignment row- server.go wires
assignmentsService = assignments.NewService(repo, programsService)(cross-service dependency) - Admin-pool bridges deleted from
programs.handler.HandleMyGetProgram+ repo (GetProgramByIDForAssignedPatient,ResolvePatientIDForPrincipal, the three*Adminloaders,GetProgramDetailForAssignedPatient)
- Classification registry: dropped tables removed, dropped columns removed,
derived_from_program_id+source_program_id+sessions.program_id/phase_id/order_in_phaseadded
Shipped (2026-05-23 protocols rename + assignment_id drop + cascade fix):
- Schema:
protocols→protocols;protocol_pauses→protocol_pauses(file000026_protocol_pauses.up.sql)kind→kindcolumn rename; permission codesassignments.*→protocols.*protocol_pauses.assignment_id→protocol_pauses.protocol_id(FK column rename)session_runs.assignment_idcolumn dropped + its partial index droppedsession_runs.session_idmade nullable + changed toON DELETE SET NULL(cascade fix: patient hard-delete now terminates cleanly leaving anonymized run rows)
- Go domain:
- Package
internal/core/domain/assignments/→internal/core/domain/protocols/ Assignment→Protocol,AssignmentKind→Kind, etc.SessionRun.AssignmentIDfield dropped;CreateRunInput.AssignmentIDdropped;AssignedSessionView.AssignmentID→ProtocolIDFindActiveAssignmentIDForSession+ValidateAssignmentContainsSessionrepo methods dropped (no callers under copy-on-derive)- Stats domain:
ActiveAssignment→ActiveProtocol;ListActiveAssignments→ListActiveProtocols; etc. Activity log + adherence numerator queries rewritten to walksr.session_id → s.program_id → proto.program_id(nosr.assignment_id) CreateRunhandler +CreateRunRequestschema:assignment_idrequest body field dropped (2026-05-23 portal/TV cleanup pass)
- Package
- Frontend:
packages/api-client/src/assignments.ts→protocols.ts; types renamed (Protocol,ProtocolKind, etc.); URL paths swapped; method names renamedapps/clinic/.../patients/[id]/assignments/→protocols/;apps/clinic/components/assignments/→components/protocols/; component file renamed- OpenAPI spec updated (
AssignedSession.assignment_id→protocol_id;StatsAdherenceEntry+StatsActivityRowrenamed);spec.gen.go+generated.tsregenerated - JSON wire keys:
assignment_id/kind/assignment_label→protocol_id/kind/protocol_label
- Spec doc + classification registry updated
Held back from these reworks (explicit decisions, on the docket as follow-ups):
POST /v1/programs/{id}/promote-to-rootendpoint + Console/Clinic UI for clearingderived_from_program_id— needs the Clinic library navigation that ships with the lineage UI surface, per [[feedback-go-ships-with-its-consumer]]POST /v1/programs/{id}/save-as-variantendpoint — same reason; needs the Clinic UI's "Save as variant" button
Phase plan
Three phases, sequenced. Phase 1 ships before June 10; Phase 2 features ride on top for the beta launch; Phase 3 is post-launch evolution. The whole point of separating them this way is to settle the schema in Phase 1 so Phase 2 and 3 ship features without migrating patient data.
Phase 1 — Substrate refactor (ships BEFORE June 10)
Schema + plumbing that makes everything else work. No new user-visible features; the platform behaves the same; the foundation under it is settled.
- All catalog tables get
ownership_kind+ nullable scope columns (P49) content_filestable created as the single registry for consumable media (audio, video, image, document)exercise_rendersrefactored to referencecontent_files(every render gets acontent_filesrow; existing renders backfilled deterministically)sessions.kindenum added (exercise|audio) — audio support latent, not used at launchprograms,program_phases,program_sessions,program_assets,session_audio_items,session_assetstables created (audio/asset tables latent at launch)protocols(renamed frompatient_assigned_sessions) +protocol_pauses+session_runs.assignment_idlink- Versioning tables (
program_versions,session_versions) - Cadence engine package (pure Go, no consumer yet at end of Phase 1)
- RLS, RBAC seeding, audit
entity_typeregistration for everything above - Storage path convention documented (
/platform/...vs/orgs/{organization_id}/...)
Phase 2 — June 10 beta launch features (built on Phase 1 substrate)
Real users at the owners-clinic doing real sessions. Single clinic (shared tenancy). No audio, no org-uploads at launch.
- Clinic UI: create/edit programs with phases, attach sessions, publish
- Clinic UI: prescribe a program or session to a patient (sets up
protocolswith cadence) - Patient UI: library browsing (exercises), guided sessions (platform-curated), execute prescribed sessions
- Tracking/metering: every
session_run,session_exercise_event,session_pain_eventflowing /patients/[id]/stats— clinical vitals + per-exercise breakdown + activity log + operational playback health- Telemetry read API + API proxy
- Catalog content: platform-curated exercises + sessions + programs (no org-tier content created yet)
Phase 3 — Post-launch evolution (multiple orgs, richer content)
- Audio therapy sessions (uses the latent
kind='audio'substrate; ships when first audio content is curated) - Org-uploaded custom content (exercises, audio, documents) — composer routing + Bunny token-authenticated playback for org content
- Library single-exercise engagement tile — depends on telemetry NULL
run_idsupport (see [[telemetry-audio-library-handoff]]) access_modecolumn oncontent_filesif specific use cases emerge (see Access mode below)- Cross-patient roster / alerts dashboard for operational lens
- Pose-derived metrics (ROM, rep count) — when telemetry pose aggregates ship + clinical validation completes
- Patient uploads, generated documents, system exports tables — each their own concern (see Storage section)
Content hierarchy
exercises (atomic, watchable in library)
↓ many-to-many via session_exercises (with dose config)
sessions (kind: exercise | audio)
↓ optionally grouped into
program_phases (ordered subgroup within a program)
↓
programs (container)A patient can encounter content at any level: a single exercise (library), a single session (prescribed or self-enrolled), a phase within a program, or a whole program. The hierarchy is content-side, neutral about how content is consumed.
Catalog ownership (P49)
Catalog tables exist in tiers. Every catalog-style table in this spec uses the same shape: explicit ownership_kind enum + nullable scope columns + CHECK constraint + RLS visibility union.
| Tier | organization_id | patient_id | Visible to | Writable by |
|---|---|---|---|---|
platform | NULL | NULL | every clinic | superadmin only |
org | set | NULL | only that clinic | clinic staff with content:write |
patient_specific | set | set | that patient's care team | clinic staff with content:write (sessions/programs only) |
Tables using two-tier (platform | org): content_files, exercises. Tables using three-tier: sessions, programs.
RLS visibility example (sessions/programs):
USING (
organization_id IS NULL -- platform: all see
OR (organization_id = current_app_org_id() AND patient_id IS NULL) -- org's guided: all org staff
OR (organization_id = current_app_org_id() AND current_app_has_patient_access(patient_id)) -- patient-specific: care team
)Slug uniqueness on entities that have slugs (exercises, sessions, programs — NOT content_files):
CREATE UNIQUE INDEX programs_slug_platform ON programs (slug) WHERE organization_id IS NULL;
CREATE UNIQUE INDEX programs_slug_org ON programs (organization_id, slug) WHERE organization_id IS NOT NULL;A clinic can name their program "lumbar-routine" even if a platform program has the same slug.
Storage
Files live in shared infrastructure (Bunny Stream / Storage, S3) but paths mirror ownership tier. The path convention is enforced from Phase 1 — every S3 read and every Bunny Storage write the composer + API perform today goes through the platform/ prefix. Adding the prefix at launch (rather than at Phase 3) means org-uploaded content slots in as a sibling later with zero retrofit on existing platform URLs.
| Ownership | Path | Phase 1 / 2 access | Phase 3 access |
|---|---|---|---|
platform | platform/... (enforced in code) | Public Bunny URLs OK — catalog content | Same |
org | orgs/{organization_id}/... (reserved; no writers yet) | Schema + path convention only (no org content created at launch) | Signed/tokenized URLs at storage layer (Bunny token-auth) |
Patient uploads, generated documents, system exports are called out but not built — each gets its own table because RLS + classification + lifecycle differ. Shared layer is internal/core/storage/ (single Uploader + signed-URL builder + virus scanner), not the DB table.
Bunny Stream — library-as-tier
Bunny Stream tier-isolates by library: one library per ownership tier. Library 659727 is the platform library and the only one wired in staging; every platform-tier exercise render uploads into it. Per-org libraries are Phase 3 — provisioned at clinic onboarding via Bunny's POST /videolibrary API and stored on organizations.video_library_id. Until then the column is absent and exercise_renders for org-tier content has no writer.
Why library-as-tier (vs. a single library with per-org collections): tenancy isolation. Bunny token-authenticated playback (Phase 3) is scoped per library, not per collection — so cross-tenant signed-URL leakage is impossible by construction once an org has its own library. Per-slug collections inside a library are an editorial grouping for the Bunny dashboard, not a tier discriminator.
Bunny Storage + S3 — prefix-as-tier
Bunny Storage Zone restartix-public-dev and the composer's S3 source bucket are single-zone-per-env; tier isolation here is via path prefix, not zone. The zone name keeps its current shape; the load-bearing thing is the prefix inside:
platform/{slug}/...— sources on S3.platform/exercises/{slug}/{cues,thumbnails,renders}/...— Bunny Storage per-slug content (cue manifests, thumbnail bundles, and reserved-for-TV-Baselinerenders/). One slug root so reconcilers walk one prefix and per-exercise deletes are a single scan.orgs/{organization_id}/{slug}/...— Phase 3; signed-URL access at the storage layer (no public Pull Zone route for this prefix).
What goes in content_files (and what does NOT)
content_files is the registry for consumable artifacts — files the platform serves to users. It is NOT a registry for source/raw materials.
| File type | Goes in content_files? | Notes |
|---|---|---|
| Exercise source clips (raw uploads, pre-composer) | No | Live in S3 private bucket with their own manifest as source of truth; composer reads from there |
| Org-uploaded exercise sources (Phase 3) | No | Same pattern — uploaded to S3 in /orgs/{organization_id}/ private prefix |
| Exercise renders (baked composite MP4 + HLS manifest) | Yes (Phase 1 refactor) | Every exercise_renders row gets a corresponding content_files row; render-specific metadata stays in exercise_renders |
| Audio session items (audio files in a playlist) | Yes (Phase 1 schema; rows from Phase 3 onward) | kind='audio'; referenced by session_audio_items |
| Downloadable documents (ebooks, PDFs) | Yes (Phase 1 schema) | kind='document'; referenced by session_assets / program_assets |
| Thumbnails, preview images | Yes | kind='image'; consumed by catalog browsing UI |
| Patient-uploaded files | No | Future patient_uploads table — different RLS + classification |
| Generated PDFs (signed prescriptions, reports) | No | Future generated_documents table — immutable after signature |
Access mode — deferred decision
v1 does not model access mode as a column. Access is implied from ownership_kind:
- platform-owned → public Bunny URLs (current behavior preserved; catalog content is design-intent shareable)
- org-owned → signed/tokenized URLs (Phase 3, gated at the storage layer with Bunny token-auth carrying
organization_id)
Three modes the platform may eventually need are NOT implemented:
| Mode | Meaning |
|---|---|
anonymous | Internet-public, no auth (e.g., marketing thumbnails on landing pages) |
authenticated | Any authenticated platform user (any clinic, any patient) — what platform catalog content currently behaves like |
org_scoped | Only that org's principals (org-owned content) |
Adding a dedicated access_mode enum is forward-compatible — when the first use case appears (e.g., a marketing thumbnail needing anonymous-public while platform catalog content needs auth-required), one column + backfill migration introduces it without breaking existing rows. Pre-production status makes this safe to defer.
The reason this is documented rather than implemented: every file in Phase 1 + Phase 2 falls cleanly into "platform = public-OK" or "org = signed-only." Two-mode binary is fine; adding three-way granularity now would create code paths with no users.
Domain model — content side
Some column definitions below predate the 2026-05-22 three-tier rework
The data-model tables below were authored against the pre-rework substrate (junction-based, version-pinned). The columns that changed in the rework — programs.published_version_id (gone), programs.derived_from_program_id (new), the program_sessions / program_versions / session_versions tables (gone), sessions.program_id + phase_id + order_in_phase (new) — are tracked authoritatively in § "Three-tier copy-on-derive model" → "Implementation status" above, plus the migration files themselves (000023, 000025, 000026). When this section disagrees with that one, the three-tier section + the migrations win.
content_files — the consumable-media registry
Single registry for platform/clinic-curated playable and downloadable media. Trimmed to the minimum that's load-bearing; descriptive metadata moves to JSONB.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID FK → organizations(id) NULL | NULL = platform-owned |
ownership_kind | ENUM content_ownership (platform | org) NOT NULL | CHECK: matches organization_id nullability |
kind | ENUM content_file_kind (audio | video | image | document) NOT NULL | |
storage_provider | ENUM (bunny_stream | bunny_storage | s3) NOT NULL | |
storage_ref | TEXT NOT NULL | Provider-specific opaque ID (Bunny GUID, S3 key, …) |
mime_type | TEXT NOT NULL | |
file_size_bytes | BIGINT NULL | |
duration_seconds | INT NULL | Audio/video only |
metadata | JSONB NOT NULL DEFAULT '{}' | Display title, alt text, captions URL, etc. — sparse descriptive bits |
uploaded_by_principal_id | UUID FK → principals(id) NOT NULL | Audit trail |
created_at, updated_at | TIMESTAMPTZ |
No slug column — files are internal artifacts, referenced by UUID; slugs belong on entities users browse (exercises, sessions, programs).
No dimensions or page_count — player can probe; not queried.
exercises — atomic clinical content (Phase 1 absorbs F9.1 Phase 2 dual-ownership)
Existing table from F9.1 Phase 1; this spec absorbs F9.1 Phase 2 (dual-ownership) into Phase 1:
| Addition | Notes |
|---|---|
organization_id | New nullable column — NULL = platform-curated (existing rows backfilled NULL) |
ownership_kind | New ENUM column — defaults to platform for existing rows |
| RLS policy | Replace current SELECT-all with visibility union (organization_id IS NULL OR organization_id = current_app_org_id()) |
| Write permission | Platform-tier writes restricted to superadmin (existing AdminPool guard preserved); org-tier writes gated by exercises:write per-org permission |
| Partial unique indexes | slug unique per tier |
exercise_renders — Phase 1 refactor to reference content_files
Existing table; gets one new column in Phase 1:
| Addition | Notes |
|---|---|
content_file_id | New UUID FK → content_files(id) ON DELETE RESTRICT. Every ready render references its file-layer row. Schema-level the column is nullable because pending / failed rows precede the file's existence on Bunny — UpsertPendingRender claims the (exercise, recipe, language) tuple before composer.Compose() returns a video_id. The invariant status='ready' ⇒ content_file_id IS NOT NULL is enforced by the chk_exercise_renders_ready_has_file CHECK constraint, mirroring the existing repo-layer invariant status='ready' ⇔ manifest_url IS NOT NULL — both gates move in lockstep with MarkRenderReady. The content_files row is INSERTed first inside the same transaction as the UPDATE so no observer ever sees a ready render without its file-layer row. |
exercise_renders keeps its render-specific schema (composer config, source clip refs, segment counts, render variant info). content_files captures file-layer concerns (storage_ref, mime, duration, size). Both rows exist per render; they're at different layers:
exercise_renders content_files
───────────── ─────────────
- composer config - storage_ref
- source clip refs - mime_type
- manifest variant info - duration_seconds
- baking metadata - file_size_bytes
- (render-specific stuff) - kind = video
- content_file_id (FK, NEW) ────────► - ownership_kind, organization_id
- (file-layer stuff)The reason for this split (and the reason Phase 1 does it, not deferred): downstream consumers (telemetry events, "what files does this entity reference" queries, the future media-asset browser) key on content_files.id. Audio items, video renders, future educational videos all share one ID space.
sessions — the playable template
Existing table; this spec adds:
| Column | Type | Notes |
|---|---|---|
kind | ENUM session_kind (exercise | audio) NOT NULL DEFAULT exercise | Drives which items table populates this session |
organization_id | (existing) → NULL allowed for ownership_kind=platform | |
patient_id | UUID FK → patients(id) NULL | Set when ownership_kind=patient_specific |
ownership_kind | ENUM session_ownership (platform | org | patient_specific) NOT NULL | CHECK: nullability matches |
session_exercises (existing) populates only when kind=exercise. session_audio_items (new, Phase 3-onward consumer) populates only when kind=audio.
session_audio_items — audio playlist items (Phase 1 schema, Phase 3 consumer)
Ordered playable items in an audio session. No dose, no reps.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
session_id | UUID FK → sessions(id) ON DELETE CASCADE | Must have sessions.kind='audio' |
organization_id | UUID FK | Denormalized for RLS; NULL for platform sessions |
content_file_id | UUID FK → content_files(id) NOT NULL | Must have kind='audio' |
order_in_session | INT NOT NULL | 1-based |
created_at, updated_at | TIMESTAMPTZ |
Unique: (session_id, order_in_session). Display title comes from content_files.metadata.title.
session_assets — downloadables attached to a session (Phase 1 schema)
Side attachments (ebooks, PDFs, reference images). Not in the playback flow.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
session_id | UUID FK → sessions(id) ON DELETE CASCADE | |
organization_id | UUID FK | Denormalized for RLS |
content_file_id | UUID FK → content_files(id) NOT NULL | Typically kind='document' but not enforced |
label | TEXT NOT NULL | Display name (e.g., "Workbook PDF") |
order_in_session | INT NOT NULL DEFAULT 0 | For sorted display |
created_at, updated_at | TIMESTAMPTZ |
programs — multi-session container
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID FK NULL | NULL = platform-owned |
patient_id | UUID FK NULL | Set when ownership_kind=patient_specific |
ownership_kind | ENUM program_ownership (platform | org | patient_specific) NOT NULL | CHECK enforces tier shape |
slug | TEXT NOT NULL | Per-tier unique |
name | TEXT NOT NULL | |
description | TEXT NULL | |
tags | TEXT[] NOT NULL DEFAULT '{}' | Catalog browsing |
estimated_duration_weeks | INT NULL | For UI; not load-bearing |
status | ENUM program_status (draft | published | archived) NOT NULL DEFAULT draft | |
published_version_id | UUID FK → program_versions(id) NULL | Set when status flips to published; see Versioning |
created_by_principal_id | UUID FK NOT NULL | |
created_at, updated_at | TIMESTAMPTZ | |
deleted_at | TIMESTAMPTZ NULL | Soft-delete |
Programs are kind-agnostic — a program can contain exercise sessions, audio sessions, or both (when audio ships in Phase 3). The kind lives on each session.
program_phases — ordered sub-grouping within a program
Optional. Programs without phases just have a flat ordered list of sessions.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
program_id | UUID FK → programs(id) ON DELETE CASCADE | |
organization_id | UUID FK | Denormalized for RLS |
name | TEXT NOT NULL | e.g., "Acute", "Strengthening", "Return to Sport" |
description | TEXT NULL | |
order_in_program | INT NOT NULL | 1-based |
estimated_duration_weeks | INT NULL | UI hint |
entry_criteria | JSONB NULL | Forward-compatible for advancement rules (future feature) |
created_at, updated_at | TIMESTAMPTZ |
Unique: (program_id, order_in_program).
program_sessions — sessions within a program
Junction table. phase_id is nullable — sessions can be in a phase, or directly in the program with no phase.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
program_id | UUID FK → programs(id) ON DELETE CASCADE | |
session_id | UUID FK → sessions(id) ON DELETE RESTRICT | Sessions referenced by published programs can't be hard-deleted |
phase_id | UUID FK → program_phases(id) ON DELETE SET NULL | NULL = flat in program |
organization_id | UUID FK | Denormalized for RLS |
order_in_phase | INT NOT NULL | 1-based; if phase_id IS NULL, treated as order_in_program |
created_at | TIMESTAMPTZ |
Unique: (program_id, phase_id, order_in_phase) — partial-friendly via expression index when phase_id NULL.
program_assets — downloadables attached to a program (Phase 1 schema)
Same shape as session_assets, scoped to a program (program guide, intake materials).
Versioning
programs and sessions get immutable version snapshots on publish:
program_versions(id, program_id, version_number, snapshot JSONB, published_at, published_by_principal_id)session_versions(id, session_id, version_number, snapshot JSONB, published_at, published_by_principal_id)
protocols references the version_id that was active at assignment creation, not the parent template. Editing a published program creates a new version; existing assignments stay on their version. This is load-bearing for historical adherence accuracy — without it, editing a program retroactively breaks past adherence calculations.
Domain model — patient side
Pre-rework column shape described below
The 2026-05-22 + 2026-05-23 reworks reshaped both protocols and session_runs. The table+column shape that actually shipped is in the migrations + Implementation status section above. Notable differences from the pre-rework narrative below:
protocols.kind(wasassignment_kind)protocols.program_idreferences the patient-instance program (a deep copy), NOT a shared template;protocols.source_program_idrecords which template was prescribed fromprogram_version_idcolumn is gone (no version snapshot in the new model)session_runs.assignment_idcolumn is gone (derivable via 2-hop chain throughsessions.program_id)session_runs.session_idandsession_runs.patient_idare both nullable +ON DELETE SET NULL(cascade fix for patient erasure)assignment_pausestable renamed toprotocol_pauses; its FK columnassignment_id→protocol_id
protocols — the workflow wrapper around a patient-instance program
Single table for both prescriptions and enrollments. Programs-only — there is no session-direct assignment in the clinical model (see Two worlds above). Renamed from patient_assignments on 2026-05-23.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID FK → organizations(id) NOT NULL | Always set — assignments are always within an org |
patient_id | UUID FK → patients(id) NOT NULL | |
program_id | UUID FK → programs(id) NOT NULL | The assigned program — every assignment is a program relationship |
program_version_id | UUID FK → program_versions(id) NOT NULL | The version snapshot pinned at assignment creation |
kind | ENUM kind (prescription | enrollment) NOT NULL | |
supervision_mode | ENUM (unsupervised | supervised) NOT NULL | Renamed from modality on 2026-05-28. supervised = each session is an appointments row; unsupervised = patient does at home alone. Discriminator for adherence math. |
cadence_kind | ENUM (flexible | scheduled) NULL | appointment_driven dropped 2026-05-28 (folded into supervised). CHECK: set when kind=prescription, NULL when enrollment. |
cadence_config | JSONB NOT NULL DEFAULT '{}' | Shape depends on cadence_kind; validated by Go discriminated-union code. See cadence & supervision. |
end_date_is_hard_cap | BOOLEAN NOT NULL DEFAULT FALSE | When TRUE, protocol auto-flips to status='ended' at end_date regardless of remaining sessions (pre-surgery deadlines). Default: soft cap — cadence walk continues past end_date. |
completed_at | TIMESTAMPTZ NULL | Set on auto-complete (last active session played). Distinct from projected end_date. |
status | ENUM assignment_status (active | paused | completed | ended) NOT NULL DEFAULT active | Aggregate status — pause history lives in protocol_pauses |
start_date | DATE NOT NULL | |
end_date | DATE NULL | NULL = open-ended |
approval_status | ENUM (auto_approved | pending_approval | approved | rejected) NOT NULL DEFAULT auto_approved | For workflows requiring senior-specialist sign-off |
approved_by_principal_id | UUID FK → principals(id) NULL | |
created_by_principal_id | UUID FK → principals(id) NOT NULL | |
created_at, updated_at | TIMESTAMPTZ |
CHECK constraints:
-- Cadence is set iff this is a prescription (enrollments have no cadence)
CHECK ((kind = 'prescription') = (cadence_kind IS NOT NULL))Earlier drafts carried a session_id XOR program_id polymorphism with related CHECKs (exactly-one-set, version pin matches content). That polymorphism was deleted in F0 (2026-05-22) when the model resolved that prescriptions are always programs (see Two worlds). Adding a future content kind (e.g., exercise_courses) would require a fresh design conversation — the current shape is intentionally single-kind because the clinical workflow is single-kind.
cadence_config shapes: (authoritative shapes in cadence & supervision)
cadence_kind | cadence_config shape | Example |
|---|---|---|
flexible | {"sessions_per_week": int, "rest_pattern": int[] | null, "sessions_per_active_day": int} | {"sessions_per_week": 3} (patient picks days) or {"sessions_per_week": 4, "rest_pattern": [2,4,6], "sessions_per_active_day": 1} (fixed cycle) |
scheduled | {"days_of_week": [string]} | {"days_of_week": ["tue","thu"]} |
| (enrollment) | N/A — column is NULL | Course progress only, no adherence denominator |
Note: appointment_driven cadence_kind was dropped 2026-05-28. The in-clinic appointment-driven semantics live inside supervision_mode='supervised' (any cadence_kind), where the adherence denominator comes from the appointments table for the protocol instead of the cadence walk.
protocol_pauses — full pause history
One row per pause interval. Cadence engine subtracts paused intervals from the expected-occurrences denominator. Pause frequency is itself a clinical signal.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
assignment_id | UUID FK → protocols(id) ON DELETE CASCADE | |
organization_id | UUID FK | Denormalized for RLS |
paused_at | TIMESTAMPTZ NOT NULL | |
resumed_at | TIMESTAMPTZ NULL | NULL = currently paused |
reason | TEXT NULL | Free-text or enum (TBD); clinical context |
paused_by_principal_id | UUID FK → principals(id) NOT NULL | Patient self-pauses are a patient principal |
resumed_by_principal_id | UUID FK → principals(id) NULL | |
created_at | TIMESTAMPTZ |
Aggregate protocols.status='paused' ↔ at least one open protocol_pauses row.
session_runs — additions + lifecycle clarification
Existing table; this spec adds one new column:
| Addition | Notes |
|---|---|
assignment_id | New nullable UUID FK → protocols(id). NULL = ad-hoc / library-driven run not satisfying any assignment |
Lifecycle (clarifying common confusion): a session_run row is created when a patient starts a session and the server transitions its status as they progress (in_progress → ended_naturally / ended_explicit / auto_closed). All terminal transitions are server-driven — see decisions.md → Why session_runs carries both status and completed. The row persists indefinitely — it is the canonical historical record of every playback attempt. The "run" itself is short-lived (~30 minutes); the row lives forever as the data every stats query reads from.
A single 8-week prescription at 3×/week generates ~24 session_runs rows. session_run.session_id says which session the patient played; session_run.assignment_id says which assignment that run satisfies. Assignment is long-lived (weeks–months); runs are many-per-assignment.
Existing fields (feedback_pain_level_now, feedback_perceived_effort, etc.) stay nullable and are captured only when the session kind clinically warrants — exercise sessions yes, audio sessions typically no.
Cadence engine
Authoritative engine design in cadence & supervision. Summary below.
Pure Go package at services/api/internal/core/domain/adherence/. Takes inputs (cadence_kind, cadence_config, supervision_mode, start/end dates, window, pauses, optional appointment counter for supervised), returns expected-occurrences count.
Dispatch (outer: supervision, inner: cadence):
supervision_mode='supervised'→ countappointmentsrows for the protocol in window withstatus IN ('done','noshow','cancelled_late','cancelled_by_patient'). Clinic-attributable cancellations (cancelled_by_clinic) are NOT counted — patient adherence is fair. Cadence_kind defines the prescribed rhythm; appointments are the materialization and the source of truth for actual delivery.supervision_mode='unsupervised',cadence_kind='flexible':rest_pattern: null→floor(sessions_per_week × weeks_in_effective_window)rest_pattern: [...]→ walk the cycle fromstart_date, count active-day slots ×sessions_per_active_day, minus pauses
supervision_mode='unsupervised',cadence_kind='scheduled'→ count concrete weekday slots in window matchingdays_of_week, minus pauseskind='enrollment'(cadence_kind=NULL) → returns nil — adherence is N/A
Unit-testable in isolation with table-driven tests. No fixtures.
Adherence + completed-occurrences
The adherence repo joins protocols × session_runs × appointments for the completed side, then calls the cadence engine for the expected side. Adherence = completed / expected. For enrollment assignments, only the completed side is reported (course progress: "N of M sessions done").
Run → assignment linkage (programs-only model): a session_run row carries assignment_id pointing at a protocols row. Since assignments are programs (never sessions), the conceptual link is "this run played session X, which belongs to program Y, which is the program the patient has been assigned." The server-side resolver (FindActiveAssignmentIDForSession in sessions/repository.go) JOINs through program_sessions to find which active assignment a played session belongs to:
SELECT pa.id FROM protocols pa
JOIN program_sessions ps ON ps.program_id = pa.program_id
WHERE pa.organization_id = $1 AND pa.patient_id = $2
AND ps.session_id = $3 AND pa.status IN ('active', 'paused')
ORDER BY pa.created_at DESC LIMIT 1When a played session is reachable via multiple active assignments (rare — overlapping prescription + enrollment of overlapping programs), the most recently created wins. The portal can also pass assignment_id explicitly on CreateRunRequest when it knows the source (e.g., player launched from a specific program detail view) — F1 work unit.
Runs of standalone catalog sessions (no program path) → assignment_id stays NULL → engagement lane, not adherence.
Completion semantics (which signal is authoritative)
Three different "completion" signals exist across the system. They serve different purposes and must not be mixed in the stats surface:
| Signal | Source | Owner | Used for |
|---|---|---|---|
session_runs.completed=true | clinical record (API) | server-derived from session_exercise_events at terminal-write time (TRUE iff every session_exercise has a terminal event) | Adherence numerator — authoritative |
session_runs.status | clinical record (API) | server-driven terminal transition: ended_naturally / ended_explicit / auto_closed | "How did this run end?" — orthogonal to completion |
session_exercise_events.kind='completed' | clinical record (API) | per-exercise milestone from the patient client | Per-exercise breakdown only |
media_session_metrics.watch_pct_95 IS NOT NULL | telemetry | media playback reached 95% threshold | Playback-health badge + engagement only |
Adherence numerator query (canonical shape — completed, not status):
SELECT COUNT(*) FROM session_runs sr
JOIN sessions s ON s.id = sr.session_id
JOIN protocols p ON p.program_id = s.program_id
WHERE p.id = $1
AND sr.completed = true
AND sr.completed_at <@ $windowWatch-percentage never feeds adherence — a patient could watch the video to 95% without doing the sets/reps, and a patient could do every set without leaving the video paused at 60%. Adherence is the clinical-record state; engagement is the telemetry state.
Three progress lanes
Surfaced as separate sections in the patient-stats UI — never mixed in one chart.
| Lane | Filter | Metric | Surfaced as |
|---|---|---|---|
| Adherence | kind=prescription | completed / expected | Active prescriptions card(s) |
| Course progress | kind=enrollment | session N of M, phase P of Q | Active enrollments card(s) |
| Engagement | runs with assignment_id IS NULL + library exercise views | counts only | Activity log + "library curiosity" tile (Phase 2 — depends on telemetry library-views path shipping) |
Stats surface — clinic /patients/[id]/stats
Patient-detail-first per the design conversation. No roster / cross-patient dashboard at launch.
Header — clinical vitals at a glance:
- Adherence over last 4 weeks per active prescription (single number + sparkline; NULL for enrollment)
- Days since last session (color-coded)
- Pain trend — last 30 days, line chart (VAS 0–10) from
session_runs.feedback_pain_level_now - RPE trend — last 30 days, line chart (1–5) from
session_runs.feedback_perceived_effort - Active prescriptions + enrollments count
Per-exercise breakdown (specialist's main clinical decision input):
- Table of exercises in active prescriptions
- Per exercise: completion rate (from
session_exercise_events.kind='completed'), skip rate, "paused for pain" rate, where pain events cluster (which set/side, derived fromsession_pain_events), set-completion rate, retries (from telemetry'smedia_session_metrics.replay_countsummed per exercise — "patient restarted this video N times across recent runs") - Drives "drop this exercise" or "reduce dose on this one" decisions
Activity log — last 30 runs, chronological:
- Per
session_run: timestamp, prescription/enrollment label, status (ended_naturally/ended_explicit/auto_closed),completedboolean (server-derived — TRUE iff every exercise had a terminal event), exercises_completed/total, pain_now, RPE, wall-time vs media-time (e.g., "45 min wall · 22 min watched" — surfaces pain pauses, distractions; computed ascompleted_at - started_atvsSUM(media_session_metrics.watched_seconds)for the run), playback-health badge (green/amber/red derived from telemetry for that run) - The badge is where clinical and operational lenses meet — "this run ended explicitly" sits next to "this run had 12 buffering events on 4G"
Operational health (collapsed-by-default):
- Playback completion rate (last 30 days)
- Buffering minutes total + per-session worst
- Avg video load time (p95 of
media_session_metrics.video_load_time_ms) + TTFB - Dropped-frame events
- Device class + connection type from latest runs
- Correlation alert: if
completion_pct < 70%ANDbuffering_count > Xover recent runs → "Playback may be limiting this patient's adherence" — actionable for CS
Library curiosity (Phase 2, depends on telemetry library-views path):
- Top exercises the patient watched outside session runs (data from
media_library_viewsvia API → Telemetry proxy) - Renders empty-state in Phase 2 if telemetry library-views path ships later; tile structure is in place from launch
Pose-derived (ROM, rep count) — Phase 3. Section reserved, empty at launch. Class I MDR posture; ship when telemetry pose aggregates produce them and clinical validation completes.
Telemetry read path
API proxies a thin Telemetry read API.
- Telemetry exposes internal read endpoints scoped by
(organization_id, patient_id, window)returning aggregatedmedia_session_metrics+media_library_viewsrows - API calls those endpoints, joins with
session_runsdata, returns a unified DTO to the clinic app - P47 URL-scope guard applied at every per-org route
- RBAC + classification checks live in API; Telemetry trusts the API caller (service-to-service auth)
- Clinic app sees one API surface; Telemetry stays isolated; data-classification egress is enforced in one place
Canonical media_id space. From F9 Phase 1 onward, media_id flowing through telemetry events is content_files.id (UUID). The existing telemetry schema's media_id TEXT accommodates it without change — but client code that constructs telemetry events (Patient Portal, future TV/companion clients) must send content_files.id, not Bunny GUIDs. The pivot happens in F9 Phase 1 PR 3 (exercise_renders → content_files refactor); clients update in the same PR.
Stats — where they live
No patient_stats table. No materialized views. No background rollup job. Stats are computed query-time against raw event tables.
Per-patient queries are inherently bounded — a hyper-adherent patient does ~3 runs/week × 52 weeks = ~150 runs/year. We're querying small slices of large tables, not large slices. With indexes designed for the access pattern, the bounded-query rule holds well past launch.
Indexes added in this work:
session_runs (patient_id, completed_at DESC)session_runs (assignment_id, completed_at DESC) WHERE assignment_id IS NOT NULLsession_exercise_events— already partitioned monthly per P41session_pain_events— already partitioned monthly per P41media_session_metrics (patient_id, started_at DESC)(telemetry side)protocols (patient_id, status) WHERE status IN ('active', 'paused')
Escape hatches when scale demands them:
- P45 Redis cache-aside on the stats repo methods (per
(patient_id, window)) - Rollup tables (
patient_weekly_statswritten onsession_runscompletion) — only if Redis isn't enough
Both are additive — no schema rewrite, no API change.
Permissions
New permissions seeded in Phase 1 (per feedback_rbac — per-org permissions, never role-string compares):
| Permission | Granted to roles | Purpose |
|---|---|---|
content:read | specialist, admin, customer_service, patient (own org) | View catalog + own org content |
content:write | specialist, admin | Create/edit org-owned exercises, sessions, programs, files |
content:publish | specialist with publish role, admin | Flip programs/sessions to published status |
content:platform_write | superadmin only (via Console + AdminPool) | Write platform-tier rows (organization_id NULL) |
assignments:read | specialist, admin, customer_service, patient (own) | Read protocols |
assignments:prescribe | specialist | Create prescription-kind assignments |
assignments:enroll | patient (own) | Self-enroll in guided programs |
assignments:pause | specialist, patient (own) | Insert protocol_pauses row |
assignments:approve | senior_specialist, admin | Flip approval_status |
stats:read | specialist, admin, customer_service | View patient stats page |
Routes gated with middleware.RequirePermission(...). RLS uses current_app_has_permission('resource', 'action').
Audit
Per CLAUDE.md, every state-changing mutation is audit-logged. New audit_log.entity_type values registered in Phase 1:
content_file,program,program_phase,program_session,program_asset,program_versionsession_audio_item,session_asset,session_version(existingsessioncontinues)protocol,protocol_pause— unified 2026-08-23: handler writes usedpatient_assignment/assignment_pausewhile the service sweeps wroteprotocol, splitting one entity's history across two strings. Writers now useprotocol/protocol_pauseexclusively; the activity vocabulary reads the legacy strings too, so pre-unification rows stay on the timeline.
Reads are NOT audited (per operational-metadata-bump rule). Adherence query computations do not produce audit rows.
Activity timelines (2026-08-23)
Programs, protocols and standalone sessions each have an Activity tab (session: a section under the editor), built on the same core/activity reader the appointments timeline uses — a narrow second door onto audit_log, gated on the entity's own read permission (content.read / protocols.read / sessions.read), never audit_log.view_org. Three vocabulary registrations in server.go compose the per-domain describes (each domain phrases only the entity types it owns; programs and sessions deliberately do not import each other). Key mechanics:
- Id collection runs on the admin pool (
ActivityRelatedIDs), after the handler proves root-entity visibility on the caller's RLS transaction — RLS hides soft-deleted rows from ordinary staff, and the rows a timeline most needs are exactly the deleted ones. This is whyprogram_phasesandprogram_assetsbecame soft-delete (folded into000025): a hard-deleted row made every audit line it ever wrote unreachable.DeletePhasealso detaches the phase's sessions into the flat order namespace explicitly, above the high-water mark — the FK'sON DELETE SET NULLused to drop them in with colliding orders. - Summary keys, not prose (
programs.activity.summary.*, shared by all three surfaces); entity names resolve server-side in one batched query per kind (soft-deleted rows resolve too — that is the point). Dose-field allow-list: an UPDATE touching only render plumbing (seed/asset_version/language) is dropped entirely; a REORDER line covers ordering. - One panel component (
ContentActivityPanel) serves all three pages; appointments keeps its own (its details need domain catalogs this one has no business importing).
API endpoint inventory
Content (API):
GET /v1/programs list, paginated, filtered by ownership_kind/scope; `without_protocol=true` keeps only unowned rows (a patient's composing drafts)
POST /v1/programs create org-owned program
GET /v1/programs/{id} detail + phases + sessions
GET /v1/programs/{id}/activity audit-derived timeline (content.read; see Audit → Activity timelines)
PATCH /v1/programs/{id} edit metadata (any status)
POST /v1/programs/{id}/publish publish (+ republish ends an edit window)
POST /v1/programs/{id}/unpublish back to draft — the way to edit a patient's copy
POST /v1/programs/{id}/publish-update template-update version stamp (2026-08-23 pull model)
POST /v1/programs/{id}/apply-structure-update pull a patient copy's STRUCTURE to the template's release
POST /v1/programs/{id}/save-as-template deep-copy into a NEW, UNLINKED org draft
DELETE /v1/programs/{id} PURGE (hard, cascading) when provably never activated; soft-delete otherwise — audit verb PURGE vs DELETE says which
POST /v1/programs/{id}/phases create phase
PUT /v1/programs/{id}/phases/{phaseId} edit phase
DELETE /v1/programs/{id}/phases/{phaseId} delete phase
POST /v1/programs/{id}/sessions attach session to program (with optional phase_id, order)
POST /v1/programs/{id}/sessions/batch ordered bulk attach — the composer's Generate (whole phases from several templates, merged + repeated) into a NEW phase (empty phase_name = flat append), per-copy source-tag renames; atomic incl. the phase
POST /v1/patients/{patientId}/protocols/compose compose-AND-prescribe in one atomic request: program + copies + protocol born in the same tx (protocols.prescribe)
DELETE /v1/programs/{id}/sessions/{psId} detach session (SOFT-deletes — run history keeps resolving)
POST /v1/programs/{id}/sessions/{psId}/apply-update pull one session's CONTENT to the template's release
POST /v1/sessions/{id}/publish-update standalone library session's version stamp
GET /v1/sessions/{id}/activity audit-derived timeline (sessions.read)
GET /v1/protocols/{id}/activity audit-derived timeline (protocols.read) — protocol lifecycle + the instance program's content history
POST /v1/me/programs/{programId}/apply-updates patient pulls everything on offer (own ENROLLMENT only)
POST /v1/programs/{id}/assets attach downloadable
DELETE /v1/programs/{id}/assets/{assetId} detach
GET /v1/sessions (existing) extended with kind/ownership filters
POST /v1/sessions extended to accept kind=audio (Phase 3 consumer)
GET /v1/sessions/{id} extended response shape per kind
... (audio item + asset CRUD parallel to programs)
GET /v1/content-files list (filtered by kind, ownership)
POST /v1/content-files register a new file (after upload to storage)
GET /v1/content-files/{id}
PUT /v1/content-files/{id} metadata edits only
DELETE /v1/content-files/{id} soft-delete; refuses if referencedAssignments + stats (API):
GET /v1/patients/{patientId}/assignments list with filters (incl. `program_id=` — the server-side "does a protocol own this copy?" check)
POST /v1/patients/{patientId}/assignments create prescription or enrollment (program_id required; sessions never assigned directly)
GET /v1/patients/{patientId}/assignments/{assignmentId} detail
PATCH /v1/patients/{patientId}/assignments/{assignmentId} edit cadence, end_date, etc.
POST /v1/patients/{patientId}/assignments/{assignmentId}/pause open pause row
POST /v1/patients/{patientId}/assignments/{assignmentId}/resume close open pause row
POST /v1/patients/{patientId}/assignments/{assignmentId}/end mark ended
POST /v1/patients/{patientId}/assignments/{assignmentId}/approve flip approval_status
GET /v1/organizations/{id}/patients/{patientId}/stats/overview header vitals (adherence, pain, RPE, days-since)
GET /v1/organizations/{id}/patients/{patientId}/stats/exercises per-exercise breakdown
GET /v1/organizations/{id}/patients/{patientId}/stats/activity activity log (paginated)
GET /v1/organizations/{id}/patients/{patientId}/stats/playback operational health (proxies telemetry read API)Stats routes mount under /v1/organizations/{id}/... so middleware.RequireURLOrgMatchesScope("id") per P47 compares the URL {id} param to the calling principal's org UUID. Assignments mount at /v1/patients/{patientId}/... (no {id} org segment) — the handler validates patient-resolves-at-current-org before any RLS-gated SELECT, giving a typed 404 instead of an empty-list response when the patient is at a different org.
Patient-self endpoints (Phase 2 — see Phase 2 final scope for which are wired):
GET /v1/me/sessions/assigned sessions picker (single-session prescriptions; existing — needs assignment_id added to row shape)
GET /v1/me/assignments full assignments list (prescription + enrollment, session + program) — NEW
POST /v1/me/assignments patient self-enrollment (kind=enrollment, content=program) — NEW
GET /v1/me/patient-stats/overview own header vitals — NEW
GET /v1/me/patient-stats/history own activity log — NEW
GET /v1/me/programs catalog of published platform programs (browse → self-enroll) — NEW
POST /v1/me/library-token mint Bunny-scoped library token (existing handler-only)
POST /v1/library/views record library overlay view (NULL run_id telemetry path) — see [[telemetry-audio-library-handoff]]Patient-self stats live under /v1/me/patient-stats/*. These routes mount without a RequirePermission middleware — patients have no role grants by design (decisions.md → Why patients are not memberships); RLS on session_runs / protocols / session_pain_events / session_exercise_events gates via current_human_patient_profile_ids(). Same pattern sessions.MountPatient uses for /v1/me/sessions/assigned. The handler resolves the calling principal's patient_id at the current org server-side and 404s when missing.
Telemetry read API (new in Phase 2):
GET /internal/v1/patients/{patientId}/media-summary?window=... aggregated playback metricsService-to-service auth; called only by API. Not exposed publicly.
Implementation sequence — by phase
Phase 1 PRs (ship BEFORE June 10)
Foundation substrate. No new user-visible features. Heavy on migrations + RLS + RBAC + data-classification.
Go ships with its consumer
Phase 1 PRs land schema-only unless the PR also contains the consumer of the Go code (composer in PR 3; cadence engine in PR 4). Repo + service + handler + routes for tables whose consumer is Phase 2 (programs, content_files, the sessions extensions) land with their UI in PR 7 / PR 8, not earlier. Reason: Go scaffolding with no consumer drifts before its consumer writes proper queries around it — the no-speculation rule from CLAUDE.md applied to substrate work.
Exception — existing live consumer migrating with its schema. When a schema change reshapes a table that is already wired to a live Go consumer (handler / repo / cron / etc.), the Go touching that consumer migrates in the same PR. This is not new scaffolding — it's keeping a live flow alive across a rename or column reshape. The bar is: the consumer existed before this PR opened, it has production traffic on the affected staging branch, and skipping the Go change would 500 a working endpoint.
- Spec doc + neighbor doc updates + implementation-plan rewrite (this PR) — no code
- Content substrate — migrations for
programs,program_phases,program_sessions,program_assets,content_files,session_audio_items,session_assets,program_versions,session_versions; additions toexercises(dual-ownership) andsessions(kind + ownership_kind); RLS, RBAC seeding, audit entity_types, data-classification entries. No Go domain code — repos for these tables land with PR 7 (clinic builder UI) and PR 8 (patient surface) per the rule above. - Exercise renders ↔ content_files refactor (depends on 2) — add
exercise_renders.content_file_id; backfill from existing renders; media service updated to create both rows atomically going forward. In-PR consumer: media service — the refactored media service is the immediate caller of the new column, so the Go-side change is paired with the migration. - Assignment + cadence substrate (depends on 2) —
protocolsrename + new columns,protocol_pauses,session_runs.assignment_id, cadence engine, RBAC, audit. In-PR consumer: cadence engine — the pure-Goservices/api/internal/core/domain/adherence/package consumesprotocols+protocol_pausesrepos directly, so those repos land here; service + handler + routes wait for PR 7 / PR 8 / PR 9 UI consumers.
PRs 2 + 3 sequence (3 depends on 2). PR 4 can run parallel to 3.
Phase 2 final scope (locked 2026-05-22)
Rescoped after honest audit
The earlier PR 5–9 slicing was re-cut on 2026-05-22 after a codebase audit showed (a) the substrate + clinic builder + clinic assignments tab + clinic stats endpoints + telemetry proxy had already shipped, (b) the patient portal was ~25% of what June 10 needs, (c) one critical foundation bug (runs not linking to assignments) silently broke adherence even for the surfaces that "worked." Work is now organized as 4 foundation units + 4 parallel UI lanes. No PR/Wave labels — each work unit is a coherent shippable slice. See project_f9_phase2_final_scope memory for live status + handoff prompts.
"Today's session" resolver DEFERRED to post-launch
At launch, patients with a program assignment see the program as browsable structure: program → phases → sessions → tap to play. No server-side "today's prescribed session" picker. Cadence drives the adherence denominator (computed); patient picks which session to play next. The "what should I do today" overlay (recommended-next, cadence-driven nudges) is a follow-up feature, not Phase 2 scope.
Foundation work units (sequenced before UI lanes; parallel with each other; orchestrator chat owns these):
- F1 — Adherence linkage. Portal
createRunActionpassesassignment_idwhen starting a run; backendCreateRunRequestaccepts explicitassignment_id(overrides auto-resolve when provided). Server-side resolver (FindActiveAssignmentIDForSession) was rewritten in F0 to JOIN throughprogram_sessions(programs-only model) — this completes the loop on the portal side so program-detail-view-launched runs link cleanly even when multiple active programs contain the same session. Without this, runs launched from the portal landassignment_id=NULLand adherence stays 0/N. - F2 — Patient-self stats backend. New endpoint group
/v1/me/patient-stats/{overview,history}. NO new permission — follows the established patient-self pattern (sessions.MountPatientshape): noRequirePermission; RLS does the gate viacurrent_human_patient_profile_ids(). The handler resolves the calling principal's patient row at the current org and 404s when missing. Query shapes mirror the org-scoped stats endpoints. Playback (operational health) is intentionally NOT exposed on the patient side — clinic/ops territory. - F3 —
media_idpivot verify. Sweep portal client telemetry-event construction to confirmmedia_idiscontent_files.id(UUID), not Bunny GUID. Per spec line ~532. Fix if drift; verify if already done. - F4 —
/v1/me/assignmentslisting. Patient-self read of own assignments — bothprescription(clinic-driven, has cadence) andenrollment(self-initiated, course-progress). All target programs (no session-direct assignments under the F0 model). Mounts WITHOUT aRequirePermissionmiddleware (patients have no role grants; RLS onprotocolsgates viacurrent_human_patient_profile_ids()). Same filter/pagination/sort grammar as the staff-side/v1/patients/{patientId}/assignments. Used by L3 (programs UI) and L4 (stats UI). Self-enrollment POST is out of scope for F4 — lands with L3 when the portal program catalog ships.
UI lanes (parallel after foundation lands; one chat each; orchestrator-commits-for-parallel-chats):
- L1 — Clinic completion. AssignProgramDialog +
prescribeProgramAction+ CTAs on/library/programslist + detail. (F0 already deleted the old AssignSessionDialog + prescribeSessionAction.) Plus the clinic stats tab UI (/patients/[id]/stats) — recharts (new SOUP entry) consuming the 4 already-shipped org-scoped stats endpoints. Sections per the Stats surface section above; library-curiosity tile renders empty-state pending telemetry library-views path. - L2 — Portal library browsing. Catalog of platform exercises browsable to patient (no assignment required). Bunny token consumption (
/v1/me/library-token). Library overlay player. TelemetryPOST /v1/library/viewsevent emission (NULL run_id path). Per [[telemetry-audio-library-handoff]]. - L3 — Portal programs (assigned + self-enroll). "My assignments" surface that renders both session AND program assignments (consumes F4). Program detail view: phases → sessions → tap to play. Catalog of published platform programs (
/v1/me/programs— new endpoint, parallel-with-lane scope) → self-enroll CTA (POST /v1/me/assignmentswithkind=enrollment+program_id). No "today's session" picker — patient picks from the program structure. - L4 — Portal patient stats. Own-stats page consuming F2 endpoints. Three progress lanes per the Three progress lanes section: adherence card per active prescription, course-progress card per active enrollment, activity log.
Dependency graph:
F1 → F4 ────────────┐
F2 ─────────────────┤
F3 ─────────────────┤
├─→ L1 (clinic prescribe + stats tab)
├─→ L2 (portal library)
├─→ L3 (portal programs + self-enroll) ← needs F4
└─→ L4 (portal patient stats) ← needs F2Already shipped (pre-2026-05-22; do not re-do):
- All Phase 1 substrate (migrations, RLS, RBAC, audit registration, classification entries, adherence engine).
- Clinic
/library/sessionsend-to-end (CRUD + builder + publish). Prescribe-session surface deleted in F0 — sessions are not directly assignable in the model. - Clinic
/library/programseverything except prescribe (CRUD + builder + phases + sessions attach + publish). - Clinic
/patients/[id]shell + tabs + assignments tab (renders both session AND program assignments; lifecycle controls all live). - Org-scoped stats endpoints (
/v1/organizations/{id}/patients/{pid}/stats/{overview,exercises,activity,playback}) — already program-aware. - Telemetry proxy (API → telemetry
/internal/v1/patients/.../media-summary). media_session_metrics,media_library_viewstelemetry tables ready (consumer-pending).- Media service stateless and ready (Phase 2 publishes bundle existing exercises; no new compose triggers needed).
Known acceptable gaps at launch (post-launch follow-ups):
contentfilesGo domain (CRUD endpoints) — platform-tier launch uses composer-driven inserts via API service layer; org-uploaded content is Phase 3, blocked until clinic asset upload UI ships.supervision_mode='supervised'adherence path (uses appointments table for denominator) — stats service swallows the unimplemented error gracefully; OK if no such protocols in the launch cohort. (Previouslyappointment_drivencadence_kind; dropped + folded into supervised 2026-05-28.)session_audio_itemsGo consumer — Phase 3 (audio therapy).- Library-curiosity tile in stats — depends on telemetry
POST /v1/library/viewsconsumer landing (L2's lane).
Gaps that dissolve when the three-tier rework lands:
- Org-tier program patient-detail visibility — under the rework, the patient's prescribed program is
ownership_kind=patient_specific, which the existingprograms_selectRLS branch permits natively. The current admin-pool bridge (inprograms/handler.goHandleMyGetProgram+ the*Adminrepo loaders) gets deleted entirely. - Clinic library showing many similar org variants flat — the rework adds
programs.derived_from_program_id; clinic library UI can group by root (variants surface under their parent). Patient catalog already filters byownership_kind='platform'so org variants never appear there.
Phase 3 — Post-launch roadmap (not in this work)
- Audio content (catalog rows for
kind='audio'sessions andsession_audio_items) - Org-uploaded custom content: composer ownership routing + Bunny token-authenticated playback + clinic upload UI + virus scanning + asset management UI
- (Library single-exercise engagement tile moved to Phase 2 — telemetry confirmed feasible via separate
POST /v1/library/views+media_library_viewspath; see [[telemetry-audio-library-handoff]]) access_modeenum column oncontent_files(when first use case appears)- Cross-patient roster / alerts dashboard (operational lens at scale)
- Pose-derived metrics (ROM, rep count) — depends on telemetry pose aggregates + clinical validation
- Patient uploads, generated documents, system exports tables — each separate feature
Open / verify before build
- Telemetry NULL
run_idcontract — REJECTED today (verified 2026-05-21). Audio media support + library-overlay engagement both require telemetry changes (JWT claim optional, schema nullable, separatemedia_library_viewstable candidate). Handoff to telemetry chat; ships in parallel. Phase 2 stats launches without library-curiosity tile until telemetry side closes. - Migration of existing
patient_assigned_sessionsrows — pre-prod so we can wipe + re-seed, but the rename + reshape is non-trivial. Per [[feedback-migrations-pre-prod]], the rename + reshape lands by editing migration000023_sessionsin place; new tables (programs,content_files,protocol_pauses, etc.) go in new migrations (000025+).
Implementation plan impact
apps/docs/implementation-plan/features.md F9 entries get rewritten around this model. F9.1 Phase 2 (exercises dual-ownership) folds into the Phase 1 Content substrate PR. F9.2 / F9.3 (treatment plans + patient enrollment) are superseded by this single spec. Phase 2 work units map to the F9 launch slice in [[june10-beta-launch]] (rescoped 2026-05-22 — see Phase 2 final scope above for the F1–F4 + L1–L4 structure that replaced the original PR 5–9 slicing).