Sessions
A session is the building block of the platform's rehab content: a group of exercises with materials, sequence, and dose configuration. Sessions are source-agnostic — the same template plays whether it came from a treatment plan, a guided program, or a clinic's standalone library. The patient session player consumes one. This doc covers the entity itself plus the first MVP feature: clinics author and serve standalone sessions.
Layer 10 — Telerehabilitation
Sessions are the first consumer of the source-agnostic session model. Treatment plans, guided programs, and future source kinds reuse the same template + the same session_runs ingest contract.
Reshaped by programs-and-assignments (Phase 1, locked 2026-05-21)
This MVP spec is extended by programs-and-assignments:
sessionsgainskindenum (exercise|audio) and the catalog-ownership tier columns (ownership_kind, nullableorganization_id, nullablepatient_id) — per P49sessionsgainsprogram_id+phase_id+order_in_phase(replacing the retiredprogram_sessionsjunction); sessions inside a program belong to it directly under three-tier copy-on-derivesession_runs.session_idandsession_runs.patient_idbecome nullable +ON DELETE SET NULLfor the patient-erasure cascade (anonymized survival)- The previously-planned
session_runs.assignment_idFK was dropped 2026-05-23 — the protocol a run satisfies is derivable viasession_runs.session_id → sessions.program_id → protocols.program_id - New owned tables:
session_audio_items(audio playlist forkind='audio'sessions),session_assets(downloadables). Thesession_versionssnapshot table was retired by the 2026-05-22 rework — sessions are not versioned; per-prescription isolation comes from deep copy at prescribe time. - The old
treatment_plan_sessionslink table is dropped — protocols are unified, programs-only (no session-direct prescription)
What's still authoritative here: the standalone-session MVP, the session_exercises per-exercise dose shape, the session_runs ingest contract, the source-agnostic structure. Schema additions in the linked spec land in Phase 1.
What this enables
End-to-end testing of the platform's rehab content pipeline: clinic creates a session → patient sees it → patient plays it → events flow to backend. This is what unblocks the in-flight patient-session-player work from running on mock-data.ts.
It also locks the source-agnostic ingest contract — POST /v1/session-runs/* works for clinic-authored standalone sessions today and is reused unchanged when treatment plans / guided programs ship.
MVP scope
- Standalone clinic-scoped sessions only. A "standalone session" is a
sessionsrow with no source-link entry — playable on its own, no patient enrollment, no plan, no schedule. No Console-side global sessions yet (added later via the cloning model from exercise-library). repsmode exercises only.holdandvideo_onlyare designed for but unimplemented by the composer; the session builder won't surface them until the composer supports them.- Single language (
ro). Other languages follow once translation primitives land. - Manual test-patient binding for the end-to-end test path — clinic admin assigns a session to a specific patient via a button; no patient self-subscription yet.
- Compose-on-save — the clinic picks any valid dose; if no rendered manifest exists in
exercise_renders, the media service is requested, session stays in "preparing" state until ready.
Out of MVP: cloning, draft-vs-published lifecycle (everything is implicitly playable; soft-archive replaces hard-delete), session-level tags/taxonomy, search beyond name, multi-language switching, treatment-plan integration.
Source-agnostic structure
┌───────────────────────────┐
│ sessions │ ← the template (this doc)
│ (name, objective, │
│ organization_id, │
│ status, ...) │
└───────────┬───────────────┘
│ 1
│ n
┌───────────▼───────────────┐
│ session_exercises │
│ (exercise_id, sequence, │
│ dose config, ...) │
└───────────────────────────┘
Optional source links (added per feature):
treatment_plan_sessions guided_program_sessions
(plan_id, session_id, (program_id, session_id,
session_number) sequence_order)
│ │
▼ ▼
F4 treatment plans future guided programs
A session with NO source link = standalone (MVP).
A session WITH a treatment_plan_sessions link = plan session.
A session WITH a guided_program_sessions link = program session.
The patient client sees these distinctions only through SessionContext,
derived at read time by left-joining the link tables.Domain model
sessions — the template
Org-scoped. The thing a clinic designs in the session builder UI; the thing the player consumes as SessionPayload.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID FK → organizations(id) | RLS scope. |
name | TEXT NOT NULL | Display name. |
subtitle | TEXT NULL | Optional secondary label, e.g. "Săptămâna 2 · Ziua 1". |
objective | TEXT[] NOT NULL DEFAULT '{}' | Clinician-authored clinical objectives, one per item (e.g. "Asuplizare musculo-ligamentară generală"). Free text — no structured derivation source. |
estimated_duration_s | INT NOT NULL DEFAULT 0 | Computed from sum of exercise prescribed_total_s; refreshed on save. |
status | ENUM session_status (active | archived) NOT NULL DEFAULT active | Implicit "draft" doesn't exist — saved sessions are immediately playable. Soft-archive replaces hard-delete. |
created_by_principal_id | UUID FK → principals(id) NOT NULL | Audit trail. |
created_at, updated_at | TIMESTAMPTZ | |
deleted_at | TIMESTAMPTZ NULL | Soft-delete. |
Materials derivation (not a column)
The patient-facing "have these ready" list is not stored on sessions. It's derived at read time from the union of exercise_equipment tags across this session's session_exercises, rendered via the equipment row's translations (RO at launch, name fallback), sorted by sort_order. The session response exposes it as materials_needed: string[] (wire field kept stable; the patient kiosk reads it unchanged from the previous shape).
Why derived, not stored: legacy programs hand-wrote "Echipamente necesare" per day because there was no exercise-level equipment taxonomy. Now every exercise carries a structured exercise_equipment tag set, so a free-text duplicate would just be stale data waiting to happen. The clinician changes the patient-visible materials by retagging the underlying exercises, not by editing a per-session field.
The session builder shows the derived list read-only ("Pacientul va vedea: …") next to the objective editor so the clinician can sanity-check what the patient will see at session start.
session_exercises — per-exercise dose
Shape mirrors patient-session-player's ExerciseInSession minus the runtime fields.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
session_id | UUID FK → sessions(id) ON DELETE CASCADE | |
organization_id | UUID FK | Denormalized for RLS. |
exercise_id | UUID FK → exercises(id) ON DELETE RESTRICT | Referenced exercises can't be hard-deleted. |
sequence_order | INT NOT NULL | 1-based. |
mode | ENUM exercise_mode (reps | hold | video_only) NOT NULL | Constrained to source exercise's mode at write time. |
sets | INT NOT NULL | |
reps_per_set | INT NULL | mode = reps. |
hold_seconds | INT NULL | mode = hold (deferred). |
side | ENUM exercise_side NOT NULL | Same values as patient-session-player. |
rest_between_sets_s | INT NOT NULL DEFAULT 10 | |
rest_after_exercise_s | INT NOT NULL DEFAULT 15 | |
intro_variant | ENUM (with_instructions | without_instructions) NOT NULL DEFAULT without_instructions | Composer input. |
language | TEXT NOT NULL DEFAULT 'ro' | Composer input. |
seed | INT NOT NULL DEFAULT 0 | Composer variant picker seed. |
created_at, updated_at | TIMESTAMPTZ |
Unique: (session_id, sequence_order).
session_runs — per-playthrough instance
Unified across all source kinds. The SessionRun.run_id the player consumes. State-shaped — flat table.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID FK | RLS scope. |
patient_id | UUID FK → patients(id) NOT NULL | |
session_id | UUID FK → sessions(id) NOT NULL | Template reference. Source kind is derived at read time via link-table joins. |
status | ENUM session_run_status NOT NULL DEFAULT in_progress | Values: 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. |
started_at | TIMESTAMPTZ NOT NULL DEFAULT now() | |
completed_at | TIMESTAMPTZ NULL | NULL while in_progress; stamped at every terminal transition. |
completed | BOOLEAN NOT NULL DEFAULT FALSE | TRUE iff every session_exercise received a terminal event (completed OR skipped) before the run terminated. Server-derived at terminal-write time. Orthogonal to status — see decisions.md. |
exercises_completed | INT NOT NULL DEFAULT 0 | Count of completed-kind session_exercise_events. Server-derived. |
pose_tracking_choice | ENUM (opted_in | declined) NULL | Captured at pose-opt-in step. |
feedback_pain_level_now | SMALLINT NULL | VAS 0–10. |
feedback_perceived_effort | SMALLINT NULL | RPE 1–5. |
feedback_notes | TEXT NULL | |
idempotency_key | TEXT UNIQUE NULL | Header Idempotency-Key; deduplicates create-run retries. |
created_at, updated_at | TIMESTAMPTZ |
RLS: org-scoped, dual-policy. Staff reads gated on session_runs.view. Patient self-CRUD gated on current_human_patient_profile_ids() (mirrors the consents / patient_subscriptions pattern from 000006) — patients are not a role on this platform, so no permission grant. No DELETE policy on session_runs (archive-only).
session_pain_events — events, partitioned
Event-shaped per P41 — Event table range partitioning. Range-partitioned monthly from day one. Append-only.
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
run_id | UUID NOT NULL | No FK constraint — run row may live across partitions; app-layer + RLS enforce existence. |
organization_id | UUID NOT NULL | Denormalized for RLS without cross-partition joins. |
exercise_id | UUID FK → exercises(id) | |
set_idx | SMALLINT NOT NULL | 0-based. |
side | TEXT | 'left' or 'right', or NULL when the exercise has no side concept (midline) or the source path can't resolve it. Self-describing — the client knows the side at capture, so no dose-config join is needed to interpret it. |
seconds_into_set | REAL NOT NULL | |
severity | ENUM (mild | moderate | severe) NOT NULL | |
action | ENUM (continue | skip_exercise | end_session) NOT NULL | |
reported_at | TIMESTAMPTZ NOT NULL DEFAULT now() | Partition key. |
RLS: org-scoped. No UPDATE / DELETE policies — append-only.
session_exercise_events — events, partitioned
Event-shaped per P41. Range-partitioned monthly. Append-only. Captures patient progress through the exercises in a run — the clinically-meaningful counterpart to the analytics-grade heartbeat that goes to telemetry. Drop point (patient closes browser mid-session) is inferred by the auto-close cron from "last started with no matching completed"; the client doesn't fire a dropped kind because it can't know it's about to drop.
| Column | Type | Notes |
|---|---|---|
id | UUID | Part of composite PK. |
run_id | UUID NOT NULL | session_runs.id. No FK constraint (cross-partition). |
organization_id | UUID NOT NULL | Denormalized for RLS. |
session_exercise_id | UUID NOT NULL | session_exercises.id within the run. |
kind | TEXT CHECK IN (started, completed, skipped, paused_for_pain, resumed) | Milestone semantics; drop is inferred server-side. |
video_time_s | NUMERIC NULL | Populated when meaningful (completed, paused_for_pain). |
set_count_completed | INTEGER NULL | Populated on completed. |
occurred_at | TIMESTAMPTZ NOT NULL DEFAULT now() | Partition key. |
created_at | TIMESTAMPTZ NOT NULL DEFAULT now() |
Composite PK (id, occurred_at). RLS dual-policy mirrors session_pain_events: staff reads gated on session_runs.view; patient self-CRUD via current_human_patient_profile_ids(). Partition seeding: current month, wired into cmd/api-partition-roll next to its sibling. Volume budget: ~5 events × ~3 exercises per session × 5k patients/day ≈ 75k events/day. Trivial at this scale.
Why this is clinical-record (not telemetry): per-exercise progress determines whether a session was clinically completed — same category as pain events. The decision rule lives in decisions.md → Why exercise milestones are clinical record, not telemetry. Telemetry retains the high-volume video-QoS heartbeat (/v1/media/events) and the pose biometric stream (/v1/pose/frames); those are observational, not clinical.
Source-link tables (future, deferred to feature build)
When treatment plans ship:
treatment_plan_sessions (
treatment_plan_id UUID NOT NULL REFERENCES treatment_plans(id),
session_id UUID NOT NULL REFERENCES sessions(id),
session_number INT NOT NULL,
PRIMARY KEY (treatment_plan_id, session_id),
UNIQUE (treatment_plan_id, session_number)
);When guided programs ship:
guided_program_sessions (
guided_program_id UUID NOT NULL REFERENCES guided_programs(id),
session_id UUID NOT NULL REFERENCES sessions(id),
sequence_order INT NOT NULL,
PRIMARY KEY (guided_program_id, session_id),
UNIQUE (guided_program_id, sequence_order)
);Not shipped in MVP — documented here for forward consistency. F4 / guided-programs features execute these migrations when they ship.
Note on legacy data-model docs
The existing data-model.md describes treatment_plan_sessions as a session-template table with name, description, estimated_duration_minutes, etc. (per-plan templates living inside the plan table). That shape is superseded by this doc. When F4 actually ships, those columns move onto sessions; treatment_plan_sessions becomes the link table above. Similarly, the legacy patient_session_completions table is replaced by session_runs.
Pre-implementation refactor — treatment plans haven't been built yet; no migration drama.
API surface
All endpoints are org-scoped via cookies + Clerk JWT. Per-tenant authz (is_patient_at_current_org) enforced via RLS.
Catalog browsing (clinic-side, extends existing exercise-library API)
GET /v1/exercises
query: q, body_region_id, category_id, equipment_id, mode, status, sort, page, limit
returns: paginated, server-side filtered
perm: exercises.read
Already specced in features/exercise-library/api.md — no changes.
GET /v1/exercises/{id}
returns: full exercise + instructions + contraindications + available_renders
(list of pre-rendered recipes already in exercise_renders)
perm: exercises.readSession CRUD (clinic-side)
GET /v1/sessions
query: q, status, sort=-updated_at, page, limit
returns: paginated list, org-scoped
perm: sessions.read
POST /v1/sessions
body: { name, subtitle?, objective, exercises: [...], idempotency_key? }
returns: { id, render_state: { pending: [exercise_id, ...], ready: [...], failed: [...] } }
Side effect: for each exercise that lacks a matching exercise_renders row,
enqueue a compose job via the media service.
perm: sessions.manage
GET /v1/sessions/{id}
returns: full session + exercises + render_state
perm: sessions.read
PATCH /v1/sessions/{id}
body: partial sessions fields (NOT exercises — those are nested-CRUD)
perm: sessions.manage
DELETE /v1/sessions/{id}
Soft-delete. RESTRICT-blocked if session_runs reference it within
retention window — returns 409. Archive (PATCH status = archived)
is the alternative.
perm: sessions.manage
POST /v1/sessions/{id}/exercises
body: { exercise_id, sequence_order, mode, sets, ... }
May trigger a compose job.
perm: sessions.manage
PATCH /v1/sessions/{id}/exercises/{ex_id}
body: partial fields
May trigger a new compose job if dose / variant changed.
perm: sessions.manage
DELETE /v1/sessions/{id}/exercises/{ex_id}
Hard delete (session_exercises are owned by the session).
perm: sessions.manage
PUT /v1/sessions/{id}/exercises/reorder
body: [ex_id_1, ex_id_2, ...] // new sequence_order
Atomic resequence.
perm: sessions.manage
GET /v1/sessions/{id}/render-state
returns: { pending: [...], ready: [...], failed: [...] }
Lightweight; clinic UI polls this every ~3s while pending non-empty.
perm: sessions.readTest patient binding (MVP-only)
POST /v1/sessions/{id}/assign
body: { patient_id }
Sets the assigned session for that patient (current-day queue).
MVP scaffolding for end-to-end testing. "Patients subscribe to library
items" is its own future product feature.
perm: sessions.manage (and patient must belong to same org)Patient-side fetch
Multi-assignment is first-class — patient_assigned_sessions has composite PK (patient_id, session_id), so a patient can hold N assignments and pick any.
GET /v1/me/sessions/assigned (self-resolving alias for the kiosk)
GET /v1/patients/{id}/sessions/assigned (staff variant)
returns: { assigned: AssignedSession[], active_runs: SessionRun[], video_library_id }
perm: implicit (patient reading own data; staff via RLS)
Source-agnostic. Each AssignedSession carries assigned_at + a `preparing`
flag (TRUE when any exercise render is still pending/failed — picker
shows a disabled "Se pregătește" badge until ready).
`active_runs` covers the resume case across all assigned sessions.
GET /v1/session-runs/{runId}/payload
returns: { run, session, video_library_id, telemetry_token }
perm: implicit (patient reading own run via RLS)
Bootstrap for the kiosk + TV companion. Resolves the run's
source template + re-mints a fresh telemetry JWT from the run's
stored consents. Replaces the previous /today re-fetch pattern.Ingest (runtime — matches patient-session-player spec)
POST /v1/session-runs
body: { source_kind, source_id, idempotency_key }
returns: { run_id, organization_id, patient_id, source_kind, source_id, started_at }
Creates a session_runs row.
POST /v1/session-runs/{run_id}/pain
body: PainEvent { exercise_id, set_idx, side, seconds_into_set, severity, action }
Appended to session_pain_events.
POST /v1/session-runs/{run_id}/exercise-event
body: { session_exercise_id, kind, reason?, video_time_s?,
set_count_completed?, idempotency_key? }
Appended to session_exercise_events. Emitted by the patient client on
each milestone (started / completed / skipped / paused_for_pain /
paused / resumed). The auto-close cron infers drop point from these;
client never fires a `dropped` kind.
Server-side: when this is the LAST pending exercise's terminal event
(completed or skipped), the service transitions the run to
`ended_naturally` inside the same tx — no separate /complete call.
POST /v1/session-runs/{run_id}/end-early
body: {} (none)
Explicit-end terminal write. Fires when the patient hits an End
Session button (pain-sheet "Oprește sesiunea", error-screen "Termină",
companion phone "Termină sesiunea"). Server transitions status to
`ended_explicit` and derives `completed` + `exercises_completed`
from session_exercise_events. Idempotent — racing taps return the
existing terminal row without re-auditing.
POST /v1/session-runs/{run_id}/feedback
body: { feedback_pain_level_now?, feedback_perceived_effort?,
feedback_notes? }
Post-terminal VAS/RPE/notes write. Server returns 409 if status is
still `in_progress` — the kiosk router prevents this in practice.
Idempotent — overwrites prior feedback for the same run.
GET /v1/session-runs/{run_id}
Reads the run row. Used by player on resume.URL prefix is /v1/session-runs/* (not /v1/patient-sessions/runs/*). The companion-mode chat's pair/channel/liveness endpoints fold under the same prefix.
Compose-on-save flow
Cache hit (already rendered)
[Clinic UI: Save session]
│
▼
POST /v1/sessions
│
▼ for each exercise:
recipe_hash = hash(exercise_id, sets, reps_per_set, side,
intro_variant, language, seed, asset_version)
SELECT * FROM exercise_renders WHERE recipe_hash = ? AND status = 'ready'
│
▼ (hit)
exercise marked render_state = "ready"
│
▼
Response: 201 + render_state = { ready: [all], pending: [], failed: [] }
[Clinic UI: "Session ready"]Cache miss (compose-on-save)
[Clinic UI: Save session]
│
▼
POST /v1/sessions → (miss for one or more exercises)
│
▼ for each missing recipe:
INSERT INTO exercise_renders (recipe_hash, status='pending', ...)
Publish job to composer queue: { exercise_id, recipe_hash, recipe_params }
│
▼
Response: 202 + render_state = { ready: [...], pending: [exercise_ids], failed: [] }
[Clinic UI: "Preparing session... (3 of 5 ready)"]
│
[media service consumes job]
pulls primitives from S3 → ffmpeg → upload to Bunny → upload manifest to Storage Zone
UPDATE exercise_renders SET status='ready', video_id=..., manifest_url=...
│
[Clinic UI polls GET .../render-state every 3s]
Eventually render_state = { ready: [all], pending: [], failed: [] }
[Clinic UI: "Session ready, assign to a patient"]Failure path
[Composer job fails (bad asset, ffmpeg error, ...)]
UPDATE exercise_renders SET status='failed', error_message=...
│
[Clinic UI polls]
render_state = { ready: [...], pending: [...], failed: [exercise_ids] }
[Clinic UI: "Couldn't prepare exercise X. Contact support."]
Clinic admin can re-trigger: POST /v1/sessions/{id}/exercises/{ex_id}/retry-renderPlayability gate
GET /v1/me/sessions/assigned includes preparing sessions in the response but flags them with preparing: true. The picker renders a disabled "Se pregătește" badge instead of an actionable "Începe" button — the patient sees that the clinic queued a session and that it's still being prepared, rather than the assignment vanishing until ready.
UI screens
Inline wireframes; real Figma comes during implementation.
Navigation (clinic app — new top-level group)
Sidebar (after this feature):
Patients
Appointments
...
Library ← new group
Exercises ← reads existing catalog
Sessions ← NEW (this feature)
.../library/sessions — list
╔══════════════════════════════════════════════════════════╗
║ Library / Sessions ║
║ ──────────────────────────────────────────────────────── ║
║ [+ Create session] [Search: ____________] [Active ▼] ║
║ ║
║ ┌──────────────────────────────────────────────────────┐ ║
║ │ Mobilizare & detensionare ⋯ │ ║
║ │ Săptămâna 2 · Ziua 1 │ ║
║ │ 2 exerciții · ~25 min · Ready │ ║
║ │ Last updated 2 days ago by Dr. Popescu │ ║
║ └──────────────────────────────────────────────────────┘ ║
║ ┌──────────────────────────────────────────────────────┐ ║
║ │ Detensionare lombară zilnică ⋯ │ ║
║ │ 3 exerciții · ~30 min · 2 of 3 ready (preparing…) │ ║
║ │ Created today by you │ ║
║ └──────────────────────────────────────────────────────┘ ║
║ ║
║ ⟨ 1 of 3 ⟩ ║
╚══════════════════════════════════════════════════════════╝Server-paginated. Per-row ⋯: View / Edit / Assign / Archive / Duplicate (deferred).
/library/sessions/new and /library/sessions/{id} — builder
╔══════════════════════════════════════════════════════════╗
║ Library / Sessions / New session ║
║ ──────────────────────────────────────────────────────── ║
║ Name: [Mobilizare & detensionare_______________] ║
║ Subtitle: [Săptămâna 2 · Ziua 1____________________] ║
║ ║
║ Materials needed: ║
║ [Saltea / pat] [Pernă] [Scaun] [+ Add] ║
║ ║
║ ──── Exercises ───────────────────────────────────────── ║
║ 1. Detensionare lombară [⋮] ║
║ Reps · 2 sets × 5 reps · ambele părți · 10s rest ║
║ Status: Ready ║
║ ║
║ 2. Glute stretch [⋮] ║
║ Reps · 2 sets × 5 reps · ambele părți · 10s rest ║
║ Status: Preparing (~1 min remaining) ║
║ ║
║ [+ Add exercise] ║
║ ║
║ Estimated duration: ~25 min (auto-computed) ║
║ ──────────────────────────────────────────────────────── ║
║ [ Cancel ] [ Save ] [ Save & Assign]║
╚══════════════════════════════════════════════════════════╝Per-row [⋮]: Edit dose / Move up / Move down / Remove. Drag-to-reorder also supported (sortable variant of AsyncMultiSelectFilter from packages/ui).
Exercise picker modal (on + Add exercise)
╔══════════════════════════════════════════════════════════╗
║ Add exercise ✕ ║
║ ──────────────────────────────────────────────────────── ║
║ [Search: ____________] [Body region ▼] [Mode ▼] ║
║ ║
║ ┌─── Detensionare lombară ─────────────────────────────┐ ║
║ │ Coloană lombară · Reps │ ║
║ │ Available recipes: 2×5 alternating, 1×10 left, ... │ ║
║ │ [ Add → ] │ ║
║ └──────────────────────────────────────────────────────┘ ║
║ ║
║ ⟨ async typeahead — server-side filter, 50/page ⟩ ║
╚══════════════════════════════════════════════════════════╝Server-side filter + paginated typeahead (CLAUDE.md production-scale rule — no client-side full-list filter). "Available recipes" hint surfaces what's already rendered, so the clinic can pick a fast path; non-rendered combos work but enter "preparing" state.
Dose configuration
╔══════════════════════════════════════════════════════════╗
║ Detensionare lombară — dose ║
║ ──────────────────────────────────────────────────────── ║
║ Mode: Reps (locked — comes from exercise) ║
║ Sets: [- 2 +] ║
║ Reps per set: [- 5 +] ║
║ Side: [Alternating L→R ▼] ║
║ Rest between sets: [- 10 +] seconds ║
║ Rest after exercise: [- 15 +] seconds ║
║ ║
║ Intro variant: ( ) Short (•) With instructions ║
║ Language: [Romanian ▼] ║
║ ║
║ ⓘ This recipe is not yet rendered. Saving will start a ║
║ compose job (~2 min). Session will be playable when ║
║ ready. ║
║ ║
║ [ Cancel ] [ Save dose ] ║
╚══════════════════════════════════════════════════════════╝ⓘ note dynamically toggles: "✓ Already rendered, instant" vs "⏳ Will render on save (~2 min)" based on exercise_renders lookup at field-change time.
Assign drawer
╔══════════════════════════════════════════════════════════╗
║ Assign session to patient ✕ ║
║ ──────────────────────────────────────────────────────── ║
║ Session: Mobilizare & detensionare ║
║ Patient: [Search by name or email__________] ║
║ (async typeahead, org-scoped) ║
║ ║
║ [ Cancel ] [ Assign ] ║
╚══════════════════════════════════════════════════════════╝MVP only; future surfaces include scheduling, expiry, multi-patient batch, …
Patient flow (consumer side)
Already specced in patient-session-player. Nothing changes on the player side beyond:
- Picker fetch:
GET /v1/me/sessions/assignedreturns{ assigned, active_runs, video_library_id }. Emptyassigned+ emptyactive_runs⇒ empty-state card; any combination otherwise renders the picker. SessionContext.kind === "guided_session"for standalone sessions created here. Other source kinds populate other discriminator values when they ship.- The picker + completion surfaces render the right chrome per source kind via the discriminated context; the player itself never reads source.
TV / companion mode handoff
The patient may play standalone on their phone OR pair with a TV via companion mode (phone-tv-companion). No schema changes here — the session payload + run lifecycle are identical regardless of presentation surface.
The companion-mode chat owns:
session_pairings(state, swept on TTL) — pre-run handle for the TV↔phone bind.session_tv_liveness(event, partitioned monthly per P41).- Pre-run endpoints at
/v1/session-pairings/*(top-level — run doesn't exist until claim). - Run-scoped channel + tv-liveness at
/v1/session-runs/{run_id}/*. - Background auto-close job (reads
session_runsvia this domain's repo method).
Permissions
New permission codes seeded by the migration:
sessions.read— list + view. Granted toadmin,specialist,customer_support.sessions.manage— create / edit / archive / assign. Granted toadmin,specialist.session_runs.view— view patient run history + pain events. Granted toadmin,specialist,customer_support.
There is no session_runs.run permission. The earlier spec proposed it as a "patient role" grant, but patients are not a role on this platform (decisions.md — Why principals as the root identity). Patient writes to session_runs + session_pain_events are RLS-gated on current_human_patient_profile_ids() (000006), the same gate used by consents + patient_subscriptions.
Per the RBAC pattern (staff side): seed the permission, grant to system role templates, gate routes with RequirePermission, add RLS policies calling current_app_has_permission(resource, action).
What's NOT in MVP
- Treatment plans + guided programs. Reuse the session-builder UI primitive but add their own source-link tables + scaffolding. Owned by their respective feature chats.
- Console-side global sessions (platform-curated, available across orgs). Add later via cloning model from exercise-library.
hold-mode andvideo_only-mode exercises in the builder. Surface only once the composer supports them.- Cloning sessions. Wait until the catalog matters.
- Draft state. Everything is implicitly active.
- Patient self-subscription to a session. The
assignadmin action is MVP-only test scaffolding. - Translation between languages. Composer needs the per-language path operational first.
- Bulk session creation, templates, presets. Not needed for v0.
- Session-level taxonomy / tags / search by category. Name search is enough at clinic scale.
- Multi-clinic session sharing (clinic A's session visible to clinic B). Org-scoped only.
Open questions (decide before implementation)
Idempotency on
POST /v1/sessions. Should the create endpoint accept anIdempotency-Keyheader? Useful for retries on flaky clinic-app networks. Recommend: yes, cheap to add.exercise_rendersfailed-state retry semantics. When a clinic clicks "Retry" on a failed render, do we soft-bump the row topendingand re-enqueue, or insert a new row? Recommend: soft-bump.Session deletion vs archival. Allow hard-delete only when no
session_runsreference the session ANDdeleted_atIS NULL — otherwise force archive. Or only ever archive? Recommend: only archive — simpler model, UUID storage cost negligible.patient_sessions/todayshape when 0 or 2+ sessions are assigned. MVP wiring assigns exactly one session per patient per day. What if assign-twice happens? Recommend: overwriting allowed; latest assignment wins. Multi-session queues are a feature, not a hack-fix.Render-state UX during "preparing". Block the "Assign" CTA until render_state.pending is empty, or allow assigning a preparing session that becomes playable when ready? Recommend: block — patients shouldn't see "session being prepared" cards on today-landing.
Implementation order (for the build chat)
- Migration:
sessions+session_exercises+session_runs+session_pain_events(partitioned). Seed permission codes. Add to data-classification registry per data-classification.md. - Go domain:
internal/core/domain/sessions/. Repo + service + handler per/new-domainskeleton. Wire compose-on-save against the media service. - API endpoints. Above shapes. Server-side filter/sort/pagination via
apiquery. RLS policies. Audit-log every state-changing mutation. - Clinic app routes + screens.
/library/exercises(consumes existing exercise-library API) +/library/sessions/*. UseAsyncMultiSelectFilterfor typeahead pickers. - Patient-side endpoints:
GET /v1/me/sessions/assigned(picker) +GET /v1/session-runs/{runId}/payload(kiosk/companion bootstrap). The oldmock-data.tsfixture is retired;apps/portal/lib/session/fetch.tsexposesfetchAssignedSessions+fetchRunPayload. - Ingest endpoints:
POST /v1/session-runs/*. WireSessionStateProvider's in-memory state to these on submit. - End-to-end smoke test on staging. Clinic creates session, assigns to test-patient, patient signs in on phone, plays, events flow.
Steps 1-3 unblock 4 (clinic UI consumes real backend). Steps 5-6 unblock 7 (patient plays real session).
Related
- Patient Session Player — what consumes a session (downstream).
- Phone-TV companion — TV surface that wraps a run.
- Exercise Library — composition — composer +
exercise_renders(compose-on-save dependency). - Exercise Library — cue manifest — what the composer emits.