Skip to content

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:

  1. 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.
  2. 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.
  3. Three-tier copy-on-derive model (locked + landed 2026-05-22) — current content architecture
  4. Implementation status — line-by-line ledger of what shipped + what's deferred (authoritative for current schema + Go shape)
  5. 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_assignmentsprotocols, assignment_pausesprotocol_pauses, assignment_kindkind, 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), modalitysupervision_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 kind enum (exercise | audio), ownership_kind tiers, plus program_id + phase_id + order_in_phase on sessions to replace the retired program_sessions junction

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 flexible and calendar-anchored scheduled) along an orthogonal supervision_mode axis (unsupervised home work vs. supervised sessions, where each supervised session is an appointment with per-appointment in_person/online_live channel) — 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:

  1. Exercise catalog — browse + preview library content (single exercises). Optional play with engagement tracking; no enrollment semantics.
  2. 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.
  3. 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 NULL for the played session (engagement lane; no protocol row; the dropped session_runs.assignment_id column 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:

TierProgramsSessions
Platform-curated template (ownership_kind=platform, organization_id=NULL, patient_id=NULL)Library catalog — patient self-enrollable, clinic-prescribableStandalone 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 prescribingClinic'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 instance

Specific copy moments (every one is a server-side deep copy inside one transaction):

TriggerWhat gets copied
Clinic attaches library session L to org program Xnew 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_v1new programs row (derived_from_program_id=A) + all phases + all sessions + their exercises + all assets
Clinic copies org A_v1 → A_v2same as above with derived_from_program_id=A (the root, not A_v1 — see "Flat lineage" below)
Clinic prescribes program T to patientnew 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 Lsame 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)

sql
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't CASCADE — destroys clinic work; don't RESTRICT — 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:

python
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 = Asame 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:

sql
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_id is 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:

sql
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-root

This 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:

sql
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

CurrentWhy it goes
program_sessions junction tableReplaced by sessions.program_id (nullable; set when session is inside a program). One-to-many; junction is redundant.
session_versions snapshot tableTemplates don't need versioning (nothing depends on history at the live-data level); patient instances ARE the snapshot.
program_versions snapshot tableSame reason. Patient instance is the snapshot.
protocols.program_version_idNo version pin needed when each patient has their own program row.
protocols.session_version_idAlready gone in F0.

What new substrate the rework adds

NewPurpose
programs.derived_from_program_idLineage — org-tier variant family. NULL for platform / root org / patient_specific programs. Points at family root (flat, not tree).
protocols.source_program_idSource-tracking — which template the patient was prescribed/enrolled from. Separate concept from lineage.
sessions.program_idReplaces the junction table. Sessions have NO lineage column.
POST /v1/programs/{id}/promote-to-rootCurated lineage clearing (org variants only)
Server-side deep-copy logic on attach-session-to-program, prescribe, self-enroll, save-as-variantThe actual copy operations

Adherence resolver simplifies

sql
-- 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 HandleMyGetProgram admin-pool fallback (the assignment-proof second path + GetProgramByIDForAssignedPatient + the 3 *Admin nested loaders) — dissolved in this rework. Patient-instance programs are patient_specific tier and the new programs_select_self RLS policy (000026) permits the patient natively. HandleMyGetProgram is now a single RLS-scoped call.
  • Org-tier program patient-detail-view gap in the launch-gaps list — dissolved.
  • TV pair-claim explicit assignment_id gap — dissolved. session_runs.assignment_id was dropped in the 2026-05-23 rename pass; the protocol a run satisfies is derivable server-side via session_runs.session_id → sessions.program_id → protocols.program_id, deterministically since copy-on-derive means no two protocols share a program_id. The portal ?aid= plumbing and the assignment_id request-body field were removed together; see [[feedback-copy-on-derive-three-tier]].

Edits to a template never back-propagate or forward-propagate

  • Library Knee Rehab edits → don't touch existing org variants (each variant was a copy)
  • Org Knee Rehab — My Clinic Edition edits → 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:

sql
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_assignmentsprotocols (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_pausesprotocol_pauses; assignment_kindkind; 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 TABLE stacks per pre-prod discipline):
    • sessions.program_id + sessions.phase_id + sessions.order_in_phase columns 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_id added (000023); program_version_id removed
    • programs.derived_from_program_id added (000025) with ON DELETE SET NULL; CHECKs (derived_from_program_id IS NULL OR ownership_kind = 'org', self-reference guard); partial index for variant lookups
    • program_sessions + program_versions + session_versions + programs.published_version_id dropped (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 row
    • programs.Repository.CopySessionIntoProgram — single session subtree (header + exercises + audio + assets) for the silent-copy-on-attach UX
    • programs.Service.PrescribeFromTemplate — validates source status='published' + not archived, deep-copies into patient_specific; returns the new program_id
    • programs.Service.AttachSession — silent-copies the library session into the program; clinic UI surfaces "Editing a copy" banner
    • programs.Service.Publish — status flip + "no archived sessions inside the program" gate (no version snapshot)
    • programs.Service.UpdateProgram — relaxed draft-only gate for patient_specific rows (specialist edits patient-instance in place)
    • sessions.Service.PublishSession — render-ready state-check; returns the session, no version row
    • Three sessions repo queries (ListAssignedSessionsForPatient / FindActiveAssignmentIDForSession / ValidateAssignmentContainsSession) rewritten to walk sessions.program_id directly
    • assignments.Service.Create rewritten to call programsService.PrescribeFromTemplate → records program_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 *Admin loaders, GetProgramDetailForAssignedPatient)
  • Classification registry: dropped tables removed, dropped columns removed, derived_from_program_id + source_program_id + sessions.program_id/phase_id/order_in_phase added

Shipped (2026-05-23 protocols rename + assignment_id drop + cascade fix):

  • Schema:
    • protocolsprotocols; protocol_pausesprotocol_pauses (file 000026_protocol_pauses.up.sql)
    • kindkind column rename; permission codes assignments.*protocols.*
    • protocol_pauses.assignment_idprotocol_pauses.protocol_id (FK column rename)
    • session_runs.assignment_id column dropped + its partial index dropped
    • session_runs.session_id made nullable + changed to ON 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/
    • AssignmentProtocol, AssignmentKindKind, etc.
    • SessionRun.AssignmentID field dropped; CreateRunInput.AssignmentID dropped; AssignedSessionView.AssignmentIDProtocolID
    • FindActiveAssignmentIDForSession + ValidateAssignmentContainsSession repo methods dropped (no callers under copy-on-derive)
    • Stats domain: ActiveAssignmentActiveProtocol; ListActiveAssignmentsListActiveProtocols; etc. Activity log + adherence numerator queries rewritten to walk sr.session_id → s.program_id → proto.program_id (no sr.assignment_id)
    • CreateRun handler + CreateRunRequest schema: assignment_id request body field dropped (2026-05-23 portal/TV cleanup pass)
  • Frontend:
    • packages/api-client/src/assignments.tsprotocols.ts; types renamed (Protocol, ProtocolKind, etc.); URL paths swapped; method names renamed
    • apps/clinic/.../patients/[id]/assignments/protocols/; apps/clinic/components/assignments/components/protocols/; component file renamed
    • OpenAPI spec updated (AssignedSession.assignment_idprotocol_id; StatsAdherenceEntry + StatsActivityRow renamed); spec.gen.go + generated.ts regenerated
    • JSON wire keys: assignment_id / kind / assignment_labelprotocol_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-root endpoint + Console/Clinic UI for clearing derived_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-variant endpoint — 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_files table created as the single registry for consumable media (audio, video, image, document)
  • exercise_renders refactored to reference content_files (every render gets a content_files row; existing renders backfilled deterministically)
  • sessions.kind enum added (exercise | audio) — audio support latent, not used at launch
  • programs, program_phases, program_sessions, program_assets, session_audio_items, session_assets tables created (audio/asset tables latent at launch)
  • protocols (renamed from patient_assigned_sessions) + protocol_pauses + session_runs.assignment_id link
  • Versioning tables (program_versions, session_versions)
  • Cadence engine package (pure Go, no consumer yet at end of Phase 1)
  • RLS, RBAC seeding, audit entity_type registration 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 protocols with cadence)
  • Patient UI: library browsing (exercises), guided sessions (platform-curated), execute prescribed sessions
  • Tracking/metering: every session_run, session_exercise_event, session_pain_event flowing
  • /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_id support (see [[telemetry-audio-library-handoff]])
  • access_mode column on content_files if 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.

Tierorganization_idpatient_idVisible toWritable by
platformNULLNULLevery clinicsuperadmin only
orgsetNULLonly that clinicclinic staff with content:write
patient_specificsetsetthat patient's care teamclinic 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):

sql
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):

sql
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.

OwnershipPathPhase 1 / 2 accessPhase 3 access
platformplatform/... (enforced in code)Public Bunny URLs OK — catalog contentSame
orgorgs/{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-Baseline renders/). 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 typeGoes in content_files?Notes
Exercise source clips (raw uploads, pre-composer)NoLive in S3 private bucket with their own manifest as source of truth; composer reads from there
Org-uploaded exercise sources (Phase 3)NoSame 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 imagesYeskind='image'; consumed by catalog browsing UI
Patient-uploaded filesNoFuture patient_uploads table — different RLS + classification
Generated PDFs (signed prescriptions, reports)NoFuture 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:

ModeMeaning
anonymousInternet-public, no auth (e.g., marketing thumbnails on landing pages)
authenticatedAny authenticated platform user (any clinic, any patient) — what platform catalog content currently behaves like
org_scopedOnly 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.

ColumnTypeNotes
idUUID PK
organization_idUUID FK → organizations(id) NULLNULL = platform-owned
ownership_kindENUM content_ownership (platform | org) NOT NULLCHECK: matches organization_id nullability
kindENUM content_file_kind (audio | video | image | document) NOT NULL
storage_providerENUM (bunny_stream | bunny_storage | s3) NOT NULL
storage_refTEXT NOT NULLProvider-specific opaque ID (Bunny GUID, S3 key, …)
mime_typeTEXT NOT NULL
file_size_bytesBIGINT NULL
duration_secondsINT NULLAudio/video only
metadataJSONB NOT NULL DEFAULT '{}'Display title, alt text, captions URL, etc. — sparse descriptive bits
uploaded_by_principal_idUUID FK → principals(id) NOT NULLAudit trail
created_at, updated_atTIMESTAMPTZ

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:

AdditionNotes
organization_idNew nullable column — NULL = platform-curated (existing rows backfilled NULL)
ownership_kindNew ENUM column — defaults to platform for existing rows
RLS policyReplace current SELECT-all with visibility union (organization_id IS NULL OR organization_id = current_app_org_id())
Write permissionPlatform-tier writes restricted to superadmin (existing AdminPool guard preserved); org-tier writes gated by exercises:write per-org permission
Partial unique indexesslug unique per tier

exercise_renders — Phase 1 refactor to reference content_files

Existing table; gets one new column in Phase 1:

AdditionNotes
content_file_idNew 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:

ColumnTypeNotes
kindENUM session_kind (exercise | audio) NOT NULL DEFAULT exerciseDrives which items table populates this session
organization_id(existing) → NULL allowed for ownership_kind=platform
patient_idUUID FK → patients(id) NULLSet when ownership_kind=patient_specific
ownership_kindENUM session_ownership (platform | org | patient_specific) NOT NULLCHECK: 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.

ColumnTypeNotes
idUUID PK
session_idUUID FK → sessions(id) ON DELETE CASCADEMust have sessions.kind='audio'
organization_idUUID FKDenormalized for RLS; NULL for platform sessions
content_file_idUUID FK → content_files(id) NOT NULLMust have kind='audio'
order_in_sessionINT NOT NULL1-based
created_at, updated_atTIMESTAMPTZ

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.

ColumnTypeNotes
idUUID PK
session_idUUID FK → sessions(id) ON DELETE CASCADE
organization_idUUID FKDenormalized for RLS
content_file_idUUID FK → content_files(id) NOT NULLTypically kind='document' but not enforced
labelTEXT NOT NULLDisplay name (e.g., "Workbook PDF")
order_in_sessionINT NOT NULL DEFAULT 0For sorted display
created_at, updated_atTIMESTAMPTZ

programs — multi-session container

ColumnTypeNotes
idUUID PK
organization_idUUID FK NULLNULL = platform-owned
patient_idUUID FK NULLSet when ownership_kind=patient_specific
ownership_kindENUM program_ownership (platform | org | patient_specific) NOT NULLCHECK enforces tier shape
slugTEXT NOT NULLPer-tier unique
nameTEXT NOT NULL
descriptionTEXT NULL
tagsTEXT[] NOT NULL DEFAULT '{}'Catalog browsing
estimated_duration_weeksINT NULLFor UI; not load-bearing
statusENUM program_status (draft | published | archived) NOT NULL DEFAULT draft
published_version_idUUID FK → program_versions(id) NULLSet when status flips to published; see Versioning
created_by_principal_idUUID FK NOT NULL
created_at, updated_atTIMESTAMPTZ
deleted_atTIMESTAMPTZ NULLSoft-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.

ColumnTypeNotes
idUUID PK
program_idUUID FK → programs(id) ON DELETE CASCADE
organization_idUUID FKDenormalized for RLS
nameTEXT NOT NULLe.g., "Acute", "Strengthening", "Return to Sport"
descriptionTEXT NULL
order_in_programINT NOT NULL1-based
estimated_duration_weeksINT NULLUI hint
entry_criteriaJSONB NULLForward-compatible for advancement rules (future feature)
created_at, updated_atTIMESTAMPTZ

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.

ColumnTypeNotes
idUUID PK
program_idUUID FK → programs(id) ON DELETE CASCADE
session_idUUID FK → sessions(id) ON DELETE RESTRICTSessions referenced by published programs can't be hard-deleted
phase_idUUID FK → program_phases(id) ON DELETE SET NULLNULL = flat in program
organization_idUUID FKDenormalized for RLS
order_in_phaseINT NOT NULL1-based; if phase_id IS NULL, treated as order_in_program
created_atTIMESTAMPTZ

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 (was assignment_kind)
  • protocols.program_id references the patient-instance program (a deep copy), NOT a shared template; protocols.source_program_id records which template was prescribed from
  • program_version_id column is gone (no version snapshot in the new model)
  • session_runs.assignment_id column is gone (derivable via 2-hop chain through sessions.program_id)
  • session_runs.session_id and session_runs.patient_id are both nullable + ON DELETE SET NULL (cascade fix for patient erasure)
  • assignment_pauses table renamed to protocol_pauses; its FK column assignment_idprotocol_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.

ColumnTypeNotes
idUUID PK
organization_idUUID FK → organizations(id) NOT NULLAlways set — assignments are always within an org
patient_idUUID FK → patients(id) NOT NULL
program_idUUID FK → programs(id) NOT NULLThe assigned program — every assignment is a program relationship
program_version_idUUID FK → program_versions(id) NOT NULLThe version snapshot pinned at assignment creation
kindENUM kind (prescription | enrollment) NOT NULL
supervision_modeENUM (unsupervised | supervised) NOT NULLRenamed from modality on 2026-05-28. supervised = each session is an appointments row; unsupervised = patient does at home alone. Discriminator for adherence math.
cadence_kindENUM (flexible | scheduled) NULLappointment_driven dropped 2026-05-28 (folded into supervised). CHECK: set when kind=prescription, NULL when enrollment.
cadence_configJSONB NOT NULL DEFAULT '{}'Shape depends on cadence_kind; validated by Go discriminated-union code. See cadence & supervision.
end_date_is_hard_capBOOLEAN NOT NULL DEFAULT FALSEWhen 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_atTIMESTAMPTZ NULLSet on auto-complete (last active session played). Distinct from projected end_date.
statusENUM assignment_status (active | paused | completed | ended) NOT NULL DEFAULT activeAggregate status — pause history lives in protocol_pauses
start_dateDATE NOT NULL
end_dateDATE NULLNULL = open-ended
approval_statusENUM (auto_approved | pending_approval | approved | rejected) NOT NULL DEFAULT auto_approvedFor workflows requiring senior-specialist sign-off
approved_by_principal_idUUID FK → principals(id) NULL
created_by_principal_idUUID FK → principals(id) NOT NULL
created_at, updated_atTIMESTAMPTZ

CHECK constraints:

sql
-- 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_kindcadence_config shapeExample
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 NULLCourse 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.

ColumnTypeNotes
idUUID PK
assignment_idUUID FK → protocols(id) ON DELETE CASCADE
organization_idUUID FKDenormalized for RLS
paused_atTIMESTAMPTZ NOT NULL
resumed_atTIMESTAMPTZ NULLNULL = currently paused
reasonTEXT NULLFree-text or enum (TBD); clinical context
paused_by_principal_idUUID FK → principals(id) NOT NULLPatient self-pauses are a patient principal
resumed_by_principal_idUUID FK → principals(id) NULL
created_atTIMESTAMPTZ

Aggregate protocols.status='paused' ↔ at least one open protocol_pauses row.

session_runs — additions + lifecycle clarification

Existing table; this spec adds one new column:

AdditionNotes
assignment_idNew 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' → count appointments rows for the protocol in window with status 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: nullfloor(sessions_per_week × weeks_in_effective_window)
    • rest_pattern: [...] → walk the cycle from start_date, count active-day slots × sessions_per_active_day, minus pauses
  • supervision_mode='unsupervised', cadence_kind='scheduled' → count concrete weekday slots in window matching days_of_week, minus pauses
  • kind='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:

sql
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

When 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:

SignalSourceOwnerUsed for
session_runs.completed=trueclinical 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.statusclinical 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 clientPer-exercise breakdown only
media_session_metrics.watch_pct_95 IS NOT NULLtelemetrymedia playback reached 95% thresholdPlayback-health badge + engagement only

Adherence numerator query (canonical shape — completed, not status):

sql
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 <@ $window

Watch-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.

LaneFilterMetricSurfaced as
Adherencekind=prescriptioncompleted / expectedActive prescriptions card(s)
Course progresskind=enrollmentsession N of M, phase P of QActive enrollments card(s)
Engagementruns with assignment_id IS NULL + library exercise viewscounts onlyActivity 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 from session_pain_events), set-completion rate, retries (from telemetry's media_session_metrics.replay_count summed 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), completed boolean (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 as completed_at - started_at vs SUM(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% AND buffering_count > X over 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_views via 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 aggregated media_session_metrics + media_library_views rows
  • API calls those endpoints, joins with session_runs data, 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_renderscontent_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 NULL
  • session_exercise_events — already partitioned monthly per P41
  • session_pain_events — already partitioned monthly per P41
  • media_session_metrics (patient_id, started_at DESC) (telemetry side)
  • protocols (patient_id, status) WHERE status IN ('active', 'paused')

Escape hatches when scale demands them:

  1. P45 Redis cache-aside on the stats repo methods (per (patient_id, window))
  2. Rollup tables (patient_weekly_stats written on session_runs completion) — 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):

PermissionGranted to rolesPurpose
content:readspecialist, admin, customer_service, patient (own org)View catalog + own org content
content:writespecialist, adminCreate/edit org-owned exercises, sessions, programs, files
content:publishspecialist with publish role, adminFlip programs/sessions to published status
content:platform_writesuperadmin only (via Console + AdminPool)Write platform-tier rows (organization_id NULL)
assignments:readspecialist, admin, customer_service, patient (own)Read protocols
assignments:prescribespecialistCreate prescription-kind assignments
assignments:enrollpatient (own)Self-enroll in guided programs
assignments:pausespecialist, patient (own)Insert protocol_pauses row
assignments:approvesenior_specialist, adminFlip approval_status
stats:readspecialist, admin, customer_serviceView 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_version
  • session_audio_item, session_asset, session_version (existing session continues)
  • protocol, protocol_pauseunified 2026-08-23: handler writes used patient_assignment / assignment_pause while the service sweeps wrote protocol, splitting one entity's history across two strings. Writers now use protocol / protocol_pause exclusively; 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 why program_phases and program_assets became soft-delete (folded into 000025): a hard-deleted row made every audit line it ever wrote unreachable. DeletePhase also detaches the phase's sessions into the flat order namespace explicitly, above the high-water mark — the FK's ON DELETE SET NULL used 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 referenced

Assignments + 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 metrics

Service-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.

  1. Spec doc + neighbor doc updates + implementation-plan rewrite (this PR) — no code
  2. Content substrate — migrations for programs, program_phases, program_sessions, program_assets, content_files, session_audio_items, session_assets, program_versions, session_versions; additions to exercises (dual-ownership) and sessions (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.
  3. 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.
  4. Assignment + cadence substrate (depends on 2) — protocols rename + new columns, protocol_pauses, session_runs.assignment_id, cadence engine, RBAC, audit. In-PR consumer: cadence engine — the pure-Go services/api/internal/core/domain/adherence/ package consumes protocols + protocol_pauses repos 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 createRunAction passes assignment_id when starting a run; backend CreateRunRequest accepts explicit assignment_id (overrides auto-resolve when provided). Server-side resolver (FindActiveAssignmentIDForSession) was rewritten in F0 to JOIN through program_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 land assignment_id=NULL and 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.MountPatient shape): no RequirePermission; RLS does the gate via current_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_id pivot verify. Sweep portal client telemetry-event construction to confirm media_id is content_files.id (UUID), not Bunny GUID. Per spec line ~532. Fix if drift; verify if already done.
  • F4 — /v1/me/assignments listing. Patient-self read of own assignments — both prescription (clinic-driven, has cadence) and enrollment (self-initiated, course-progress). All target programs (no session-direct assignments under the F0 model). Mounts WITHOUT a RequirePermission middleware (patients have no role grants; RLS on protocols gates via current_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/programs list + 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. Telemetry POST /v1/library/views event 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/assignments with kind=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 F2

Already shipped (pre-2026-05-22; do not re-do):

  • All Phase 1 substrate (migrations, RLS, RBAC, audit registration, classification entries, adherence engine).
  • Clinic /library/sessions end-to-end (CRUD + builder + publish). Prescribe-session surface deleted in F0 — sessions are not directly assignable in the model.
  • Clinic /library/programs everything 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_views telemetry 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):

  • contentfiles Go 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. (Previously appointment_driven cadence_kind; dropped + folded into supervised 2026-05-28.)
  • session_audio_items Go consumer — Phase 3 (audio therapy).
  • Library-curiosity tile in stats — depends on telemetry POST /v1/library/views consumer 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 existing programs_select RLS branch permits natively. The current admin-pool bridge (in programs/handler.go HandleMyGetProgram + the *Admin repo 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 by ownership_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 and session_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_views path; see [[telemetry-audio-library-handoff]])
  • access_mode enum column on content_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_id contract — REJECTED today (verified 2026-05-21). Audio media support + library-overlay engagement both require telemetry changes (JWT claim optional, schema nullable, separate media_library_views table 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_sessions rows — 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 migration 000023_sessions in 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).