Skip to content

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:

  • sessions gains kind enum (exercise | audio) and the catalog-ownership tier columns (ownership_kind, nullable organization_id, nullable patient_id) — per P49
  • sessions gains program_id + phase_id + order_in_phase (replacing the retired program_sessions junction); sessions inside a program belong to it directly under three-tier copy-on-derive
  • session_runs.session_id and session_runs.patient_id become nullable + ON DELETE SET NULL for the patient-erasure cascade (anonymized survival)
  • The previously-planned session_runs.assignment_id FK was dropped 2026-05-23 — the protocol a run satisfies is derivable via session_runs.session_id → sessions.program_id → protocols.program_id
  • New owned tables: session_audio_items (audio playlist for kind='audio' sessions), session_assets (downloadables). The session_versions snapshot 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_sessions link 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 sessions row 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).
  • reps mode exercises only. hold and video_only are 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.

ColumnTypeNotes
idUUID PK
organization_idUUID FK → organizations(id)RLS scope.
nameTEXT NOT NULLDisplay name.
subtitleTEXT NULLOptional secondary label, e.g. "Săptămâna 2 · Ziua 1".
objectiveTEXT[] NOT NULL DEFAULT '{}'Clinician-authored clinical objectives, one per item (e.g. "Asuplizare musculo-ligamentară generală"). Free text — no structured derivation source.
estimated_duration_sINT NOT NULL DEFAULT 0Computed from sum of exercise prescribed_total_s; refreshed on save.
statusENUM session_status (active | archived) NOT NULL DEFAULT activeImplicit "draft" doesn't exist — saved sessions are immediately playable. Soft-archive replaces hard-delete.
created_by_principal_idUUID FK → principals(id) NOT NULLAudit trail.
created_at, updated_atTIMESTAMPTZ
deleted_atTIMESTAMPTZ NULLSoft-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.

ColumnTypeNotes
idUUID PK
session_idUUID FK → sessions(id) ON DELETE CASCADE
organization_idUUID FKDenormalized for RLS.
exercise_idUUID FK → exercises(id) ON DELETE RESTRICTReferenced exercises can't be hard-deleted.
sequence_orderINT NOT NULL1-based.
modeENUM exercise_mode (reps | hold | video_only) NOT NULLConstrained to source exercise's mode at write time.
setsINT NOT NULL
reps_per_setINT NULLmode = reps.
hold_secondsINT NULLmode = hold (deferred).
sideENUM exercise_side NOT NULLSame values as patient-session-player.
rest_between_sets_sINT NOT NULL DEFAULT 10
rest_after_exercise_sINT NOT NULL DEFAULT 15
intro_variantENUM (with_instructions | without_instructions) NOT NULL DEFAULT without_instructionsComposer input.
languageTEXT NOT NULL DEFAULT 'ro'Composer input.
seedINT NOT NULL DEFAULT 0Composer variant picker seed.
created_at, updated_atTIMESTAMPTZ

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.

ColumnTypeNotes
idUUID PK
organization_idUUID FKRLS scope.
patient_idUUID FK → patients(id) NOT NULL
session_idUUID FK → sessions(id) NOT NULLTemplate reference. Source kind is derived at read time via link-table joins.
statusENUM session_run_status NOT NULL DEFAULT in_progressValues: 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_atTIMESTAMPTZ NOT NULL DEFAULT now()
completed_atTIMESTAMPTZ NULLNULL while in_progress; stamped at every terminal transition.
completedBOOLEAN NOT NULL DEFAULT FALSETRUE 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_completedINT NOT NULL DEFAULT 0Count of completed-kind session_exercise_events. Server-derived.
pose_tracking_choiceENUM (opted_in | declined) NULLCaptured at pose-opt-in step.
feedback_pain_level_nowSMALLINT NULLVAS 0–10.
feedback_perceived_effortSMALLINT NULLRPE 1–5.
feedback_notesTEXT NULL
idempotency_keyTEXT UNIQUE NULLHeader Idempotency-Key; deduplicates create-run retries.
created_at, updated_atTIMESTAMPTZ

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.

ColumnTypeNotes
idUUID PK
run_idUUID NOT NULLNo FK constraint — run row may live across partitions; app-layer + RLS enforce existence.
organization_idUUID NOT NULLDenormalized for RLS without cross-partition joins.
exercise_idUUID FK → exercises(id)
set_idxSMALLINT NOT NULL0-based.
sideTEXT'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_setREAL NOT NULL
severityENUM (mild | moderate | severe) NOT NULL
actionENUM (continue | skip_exercise | end_session) NOT NULL
reported_atTIMESTAMPTZ 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.

ColumnTypeNotes
idUUIDPart of composite PK.
run_idUUID NOT NULLsession_runs.id. No FK constraint (cross-partition).
organization_idUUID NOT NULLDenormalized for RLS.
session_exercise_idUUID NOT NULLsession_exercises.id within the run.
kindTEXT CHECK IN (started, completed, skipped, paused_for_pain, resumed)Milestone semantics; drop is inferred server-side.
video_time_sNUMERIC NULLPopulated when meaningful (completed, paused_for_pain).
set_count_completedINTEGER NULLPopulated on completed.
occurred_atTIMESTAMPTZ NOT NULL DEFAULT now()Partition key.
created_atTIMESTAMPTZ 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.

When treatment plans ship:

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

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

Session 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.read

Test 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-render

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

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/assigned returns { assigned, active_runs, video_library_id }. Empty assigned + empty active_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_runs via this domain's repo method).

Permissions

New permission codes seeded by the migration:

  • sessions.read — list + view. Granted to admin, specialist, customer_support.
  • sessions.manage — create / edit / archive / assign. Granted to admin, specialist.
  • session_runs.view — view patient run history + pain events. Granted to admin, 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 and video_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 assign admin 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)

  1. Idempotency on POST /v1/sessions. Should the create endpoint accept an Idempotency-Key header? Useful for retries on flaky clinic-app networks. Recommend: yes, cheap to add.

  2. exercise_renders failed-state retry semantics. When a clinic clicks "Retry" on a failed render, do we soft-bump the row to pending and re-enqueue, or insert a new row? Recommend: soft-bump.

  3. Session deletion vs archival. Allow hard-delete only when no session_runs reference the session AND deleted_at IS NULL — otherwise force archive. Or only ever archive? Recommend: only archive — simpler model, UUID storage cost negligible.

  4. patient_sessions/today shape 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.

  5. 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)

  1. Migration: sessions + session_exercises + session_runs + session_pain_events (partitioned). Seed permission codes. Add to data-classification registry per data-classification.md.
  2. Go domain: internal/core/domain/sessions/. Repo + service + handler per /new-domain skeleton. Wire compose-on-save against the media service.
  3. API endpoints. Above shapes. Server-side filter/sort/pagination via apiquery. RLS policies. Audit-log every state-changing mutation.
  4. Clinic app routes + screens. /library/exercises (consumes existing exercise-library API) + /library/sessions/*. Use AsyncMultiSelectFilter for typeahead pickers.
  5. Patient-side endpoints: GET /v1/me/sessions/assigned (picker) + GET /v1/session-runs/{runId}/payload (kiosk/companion bootstrap). The old mock-data.ts fixture is retired; apps/portal/lib/session/fetch.ts exposes fetchAssignedSessions + fetchRunPayload.
  6. Ingest endpoints: POST /v1/session-runs/*. Wire SessionStateProvider's in-memory state to these on submit.
  7. 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).