Cadence & Supervision
Authoritative design for protocol prescription — how a clinician shapes a patient's program rhythm, how each session is supervised and delivered, and how adherence is computed. Replaces the earlier 3-cadence-kind +
modalitybinary model. Designed 2026-05-28.
Implementation status
The design below is fully implemented for unsupervised + flexible/scheduled as of 2026-05-28. The substrate, lifecycle hooks, prescribe UX, stats lenses, and supervised-dispatch seam are all in place; the only consumer that's spec-locked-but-unbuilt is supervised dispatch (waits for the appointments table — see appointments-substrate.md).
Shipped (commit refs on staging):
| Layer | Commits | What |
|---|---|---|
| Spec | 11604d5 | This doc + 11 cross-doc updates (terminology consolidation, appointment_driven removal, etc.) |
| Backend substrate | b0fb339 | protocols.modality → supervision_mode; cadence_kind CHECK drops appointment_driven; add end_date_is_hard_cap + completed_at; session_exercises.deleted_at + partial active-row indexes + soft-delete-aware exercise_count trigger; cadence engine reshape (SupervisionMode field, ConfigFlexible gains RestPattern + SessionsPerActiveDay) |
| Frontend wire shape | 1bbe72f | api-client types, clinic + portal catch-up |
| Down migration | 6c063ee | patient_assignments → protocols rename in down file |
| Author-typed duration drop | f524396 / be4d12a / f639b04 | estimated_duration_weeks removed end-to-end |
| Appointments substrate spec | bc68d19 | appointments-substrate.md locked; no code yet |
| Prescribe dialog UX | 56041b1 | rest_pattern 7-position picker + sessions_per_active_day control in clinic dialog |
| Auto-complete | 85418cc | Sessions service hooks into protocols.MaybeAutoCompleteForSession after every terminal write where completed=true. Soft-cap default closes naturally when the last active session is played. |
| Hybrid stats (clinic) | 1792fbd | AdherenceEntry gains total_active_sessions / completed_distinct_sessions / behind_by_n; clinic protocol-detail header shows the triplet alongside the existing windowed adherence pct. |
| Hard-cap auto-expiry | 89baa41 | New cmd/expire-hard-cap-protocols cron + service method; active → ended flip on hard-cap protocols past end_date. |
| Derived catalog duration | 8913823 | programs.session_count (server-derived via correlated subquery) drives the portal catalog hint "N sesiuni · ~M săpt. la 3×/săpt." that replaced the dropped author-typed column. |
| Portal stats display | f258b40 | Patient progress page mirrors the clinic triplet with softer language ("cu 2 sesiuni în urmă"). |
| One-active-prescription + hard-cap create | program-authoring rework | uq_one_active_prescription_per_patient partial unique index (000023) + service-layer pre-check & unique-violation catch (ErrActivePrescriptionExists); end_date_is_hard_cap wired into the protocols create path (handler → CreateInput → row) + api-client CreateProtocolRequest. Closed the false-guard drift the 000023 comment claimed. |
| Program drag-drop substrate | program-authoring rework | Bulk PUT /v1/programs/{id}/phases/reorder + atomic PUT /v1/programs/{id}/sessions/arrange (full per-phase placement = within-phase reorder + cross-phase move in one high-water shuffle) + api-client methods. Backs the reworked clinic builder. |
Designed, not yet built (specs in this doc):
- Today surface + play-time gate — the patient-facing cadence manifestation + server-side enforcement that prescriptions can't be rushed. Full spec in Today surface & play-time gating. Decisions locked: daily ceiling via
sessions_per_active_dayon all cadences; carry-over shows today-only; "ahead" impossible for prescriptions. - Treatment-as-journey layer — a journey is ONE program whose phases are its ordered stages. Stateful phases + manual gating shipped; per-phase cadence, phase kinds (
exercise/reassessment) and the journey builder are designed and in build — full spec in Treatment-as-journey. Goals and the milestone log stay deferred (goals wait on F16 measure identity).
Deferred (substrate ready, build later):
- Mid-treatment exercise edit workflow —
session_exercises.deleted_atcolumn + soft-delete-aware indexes +check-softdelete:allowannotations on historical-event paths are all in place. The service-layer transactional pattern (soft-delete old + INSERT new for replace/re-dose; plain INSERT/UPDATE for add/reorder; soft-delete only for remove) and clinic UI are intentionally unbuilt — waits for the clinic program UI rework (step 1 of the roadmap). Notifying the patient on edits is a separate notifications-chat scope. min_rest_hours— reservedcadence_configfield; enforce as a soft nudge (never a hard block) if/when asessions_per_active_day ≥ 2protocol ships.
Out-of-stream (separate feature builds):
Patient↔specialist assignment /
care_team— does not exist today (protocol records prescriber viacreated_by_principal_idonly). Planned post-launch; prerequisite for the journey layer's reassessment + clinical-milestone pieces. Its own discussion.Appointments table + Go domain — spec-locked at appointments-substrate.md. Until that ships,
supervision_mode='supervised'protocols getErrSupervisedNotImplementedfrom the cadence engine; the stats service swallows it and the UI shows progress + counts without a ratio.Notifications system — separate chat scope. The auto-complete hook + the mid-treatment edit pattern both have hooks where notifications would slot in once the system exists.
What this is
A protocols row is the workflow wrapper around a patient-instance program. Three orthogonal axes describe how the prescription behaves at runtime:
| Axis | Lives on | Values | Answers |
|---|---|---|---|
| Cadence | protocols.cadence_kind + cadence_config | flexible | scheduled | What's the rhythm? |
| Supervision | protocols.supervision_mode | unsupervised | supervised | Does the specialist need to be present per session? |
| Channel | appointments.channel | in_person | online_live | When supervised, how is the specialist present? Picked per-appointment. |
The three axes are deliberately independent. A patient on a scheduled+supervised protocol can take Tuesday's appointment in_person and Thursday's online_live in the same week — the rural-patient case the platform is built for. Conflating any two of these axes into one column (the historical modality mistake) forces a refactor the moment the hybrid case appears, which for this platform is on day one.
Cadence kinds
flexible — patient-anchored
cadence_config shape:
{
"sessions_per_week": 3,
"rest_pattern": [2, 4, 6],
"sessions_per_active_day": 1
}sessions_per_week(required, int > 0) — the prescribed weekly target.rest_pattern(optional, int[]) — rest positions within a 7-day cycle, 1-indexed against the patient'sstart_date.nullor omitted = patient picks which days within the week. Set = fixed cycle ("day 1 active, day 2 rest, day 3 active…").sessions_per_active_day(optional, int ≥ 1, default 1) — number of sessions the patient does on each active day.2+covers the morning-and-evening case without time-of-day labels — the patient self-paces the gap.
The clinical question the specialist faces is binary: do I dictate which days, or let the patient pick? rest_pattern set or null answers it. This is one cadence_kind with one config knob, not two cadence_kinds.
Adherence walks the cycle from start_date to today, minus protocol_pauses intervals, counting expected session-slots.
scheduled — calendar-anchored
cadence_config shape:
{
"days_of_week": ["tue", "thu"]
}days_of_week(required, string[]) — ISO weekday abbreviations (mon…sun). The patient comes on these calendar weekdays. Not patient-anchored — Tuesday is Tuesday, regardless of when the patient started.
Used for in-clinic appointment rhythm (the main case) and the rare take-home program locked to specific weekdays.
Enrollment (no cadence)
protocols.kind = 'enrollment' (self-initiated guided programs) sets cadence_kind = NULL. No adherence denominator; only course progress (completed / total). Unchanged from current behavior.
Supervision mode
protocols.supervision_mode discriminates execution path:
unsupervised— patient does each session alone. No appointments. Session_runs are played at the patient's discretion within the cadence. The cadence engine walks the calendar to compute expected occurrences.supervised— each session requires the specialist present. Each session corresponds to anappointmentsrow. The appointments table is the materialization of the prescription.
The combination matrix:
| Cadence | Supervision | Behavior |
|---|---|---|
flexible + unsupervised | Patient does N/week at home, alone. Most common take-home case. | |
flexible + supervised | Patient books N/week with the specialist, any weekday. Niche but valid. | |
scheduled + unsupervised | Patient does on configured weekdays at home. Rare. | |
scheduled + supervised | Main in-clinic case. Patient comes/connects on Tue/Thu (per days_of_week), each appointment has its own channel. |
Appointments channel
When supervision_mode = 'supervised', each appointments row carries channel:
in_person— patient comes to the clinic, specialist in the room.online_live— patient stays home, specialist joins over live video (Daily.co).
Picked per-appointment by the patient (portal) or clinic (clinic app) at booking time. A clinic without physical premises filters in_person out of the offered options; a clinic without video setup filters online_live. Both are org-level capability gates, not protocol-level commitments.
Channel does not affect adherence math. An attended online_live and an attended in_person count the same — both are "patient showed up to the supervised session."
Adherence
Two derived numbers, both from existing data:
- Progress =
completed_session_count / total_active_sessions_in_program. Pure content math. "Where in the journey is the patient." Always meaningful; never penalises calendar drift. - Adherence =
completed_session_count / expected_by_today. Calendar-aware. "Is the patient keeping the prescribed rhythm." - Behind by N =
expected_by_today - completed_session_count. Negative = ahead.
expected_by_today dispatches on supervision_mode:
unsupervised→ cadence engine walks[start_date, today]minusprotocol_pausesintervals, counts cadence-expected session-slots.supervised→ count ofappointmentsrows for the protocol in the window withstatus IN ('done', 'noshow', 'cancelled_late', 'cancelled_by_patient')— the patient-attributable statuses. Clinic-side cancellations (cancelled_by_clinic, "no specialist available") do not enter the denominator, so a patient is never penalised for capacity they couldn't access.
The pattern is the same across both: expected is what the patient should have done by now, completed is what they actually did, and both numbers are honest about what's the patient's responsibility vs. the clinic's.
behind_by_ntoday-counting wrinkle (refinement for the Today build). The lifetime walk currently includes today as an expected day, so at the start of an active day a patient reads "behind by 1" until they play. For the ambient-awareness framing (below), the walk should count through yesterday and treat today as in-progress. Small refinement; land it with the Today surface.
Today surface & play-time gating
Status: designed 2026-05-28, not yet built. This is the patient-facing manifestation of the cadence and the enforcement that the cadence isn't just advisory. Supersedes the earlier "no server-side today picker, patient browses and picks freely" line in programs-and-assignments — that was the pre-cadence-redesign model.
The gate: a patient cannot run ahead of a prescription
Rehab is rate-limited by biology, not motivation. A keen patient who does a week's sessions in two days gets a worse outcome and higher re-injury risk — rushing doesn't heal. So for kind='prescription' protocols, the cadence gates session-run creation, it doesn't merely decorate the UI. Future sessions are visible (the patient can see what's coming) but not playable until their day.
This must be enforced server-side in the sessions domain's run-creation path, not just hidden in the UI — a determined patient hitting the start URL directly must be rejected. The gate is:
Playable now? = eligible day AND under today's ceiling AND an un-played session remains.
- Eligible day — depends on cadence:
scheduled→ today's weekday is indays_of_week.flexible+rest_pattern→ today's cycle position (anchored atstart_date) is not a rest position.flexible, norest_pattern→ every day is eligible (patient picks days), bounded only by the weekly target + the daily ceiling.
- Today's ceiling =
sessions_per_active_day(default 1). This applies to all three cadences, includingflexiblewith no rest_pattern — "flexible" means flexible about which days, never about cramming. Clinically: inter-session recovery, motor-learning consolidation, and within-day fatigue all make "3 sessions Saturday" worse (and potentially harmful) than spaced days, even though it hits the weekly count. - Un-played session = next session in the program's flat ordered list (across phases) the patient hasn't completed (
session_runs.completed=true) at least once. Missed days do not skip sessions — the sequence rolls at the patient's pace; behind-by-N signals the lag without removing work.
The three cadences thus unify to one gate that differs only in which days are eligible; every cadence has the same daily ceiling.
Carry-over and "ahead"
- Carry-over: if the patient missed yesterday, today still shows only today's allowance — never a piled-up backlog. They simply finish later; the protocol auto-completes when the last session is played regardless of calendar. Behind-by-N is ambient awareness, not a warning — no red flag, just "where you are."
- "Ahead" is impossible for prescriptions. The ceiling caps
completed ≤ expectedalways, sobehind_by_n ≥ 0for prescriptions. The "ahead / înainte" UI branch added to the clinic + portal stats in1792fbd/f258b40is dead code for prescriptions (enrollments don't compute behind_by_n) — remove it when building the gate.
Enrollments are free-pace
kind='enrollment' has no cadence and is not gated — the patient plays any session, any day, any number. The Today surface shows enrollments as a separate "self-paced programs" list, always fully browsable/playable. Only the single active prescription gets the gated "today" treatment.
GET /v1/me/today (sketch)
A new patient-scoped endpoint returns the prescription's today-card + the free-paced enrollments:
GET /v1/me/today → {
prescription: { // null if no active prescription
protocol_id, program_id, program_name,
day_state: "active" | "rest",
progress: { session: 3, of: 12 }, // "Session 3 of 12 in your program"
sessions: [ {session_id, name, kind, estimated_duration_s} ], // [] on rest days
behind_by_n // ambient; >= 0 for prescriptions
},
enrollments: [ {protocol_id, program_name, /* free-pace, browse */ } ]
}Server-side derivation (the cadence walk is the source of truth — never duplicate it in TS). Patient-facing copy uses "Session 3 of 12 in your program" framing, not clinical "Day 5 of 28."
Prescription lifecycle constraints
One active prescription per patient
A patient (per patients.id — the org-patient row, so the same person at two clinics has one each) may have at most one active prescription at a time, plus any number of active enrollments. This is clinically load-bearing, not a simplification: two independent prescriptions (knee 3×/week + shoulder 3×/week) means nobody coordinates total daily load. The responsible model is one coherent daily plan, deliberately dosed — the multi-concern case is served by composing one program covering both concerns (see Multi-program composition), where the specialist consciously balances the combined dose.
Enforced by a DB partial unique index (foundation discipline — load-bearing invariants live in the database, not the app layer; mirrors the existing uq_protocol_pauses_open_per_protocol):
CREATE UNIQUE INDEX uq_one_active_prescription_per_patient
ON protocols (patient_id)
WHERE kind = 'prescription' AND status = 'active';The service layer catches the unique violation and returns a typed conflict (ErrActivePrescriptionExists), with a cheap pre-check (HasActivePrescription) to short-circuit before the program deep-copy on the common case; the DB index is the real enforcement + race backstop. Implemented in the program-authoring rework — the index lives in migration 000023 and the 000023 comment that previously claimed a non-existent FOR-UPDATE guard was corrected to point at it.
Prescribe-while-active conflict
When a specialist prescribes while the patient has an active prescription, the clinic surfaces a choice — each maps to a real clinical intent, and three of four need zero new substrate (existing lifecycle actions):
| Choice | Clinical intent | Substrate |
|---|---|---|
| End current → prescribe new | Plan changed; old plan abandoned | end exists |
| Pause current → prescribe new | Temporary focus shift (acute flare); resume old later | pause/resume exist; paused ≠ active |
| Finish current first (don't prescribe) | Current nearly done | no-op |
| Dropped — see below |
Queue is deliberately not built. Rehab is adaptive — you choose the next program based on how the patient responded, which you don't know until the reassessment. Pre-committing program B weeks ahead is clinically premature; staged progression within an episode is what phases are for. The shape this section named as the safe one — a draft prescription the specialist activates at the next visit — is what shipped; see below. It is still not a queue: nothing auto-starts, a person hands it over.
Prepared prescriptions (status = 'draft')
Built 2026-08-27, folded into 000023 (the migration that creates protocols) rather than shipped forward — both environments are being rebuilt, so the chain is edited in place. A prescription is prepared first and handed over deliberately. The lifecycle is draft → active → (paused ⇄ active) → completed | ended; draft is an entry state and nothing returns to it.
It exists because composing and giving were one gesture. POST .../protocols/compose assembles a journey stage by stage from a builder the clinician may be halfway through — and it published the patient's program, activated the protocol and emailed them "your programme is ready" on submit. There was no state for built, not yet given, so a half-finished journey submitted by accident was indistinguishable from a considered prescription.
A draft is three things, and each is enforced where it cannot be forgotten rather than in a handler:
| Property | Where it lives |
|---|---|
| The patient cannot see it | protocols_select_self carries status <> 'draft'. The play gates already filtered status = 'active'. |
| The plan is still editable | Hand-over publishes the program, so a draft's program is still a draft — and the unpublish-to-edit rule leaves it structurally open. |
| It does not hold the one-active slot | uq_one_active_prescription_per_patient is partial on status = 'active'. |
A draft may sit beside a running prescription — preparing the next block while the current one runs is the real use — so the one-active conflict is a hand-over-time question, resolved through the End-current flow in the table above rather than at create.
POST /v1/patients/{patientId}/protocols/{id}/hand-over is the single transition, and it does everything creating a protocol used to do in the same breath as building it: publish the program, flip to active, anchor the journey's first stage to the start date, fire the program-start paperwork, tell the patient. Permission is protocols.prescribe — whoever may prescribe may hand over; clinics wanting a second pair of eyes have protocols.approve already. Audited as HAND_OVER.
Prescribing an existing published program (POST .../protocols) stays a one-gesture act: the plan was authored and reviewed in the library, so there is nothing left to review.
Multi-program composition
The legacy "this plan uses sessions from these 2 source programs" model collapses to a content-authoring convenience: the clinic-side program builder lets a specialist pull sessions from multiple source programs into a new program at creation time. Once saved, the result is a single program; lineage is not tracked at runtime. protocols.source_program_id stays a single optional FK that records the primary template (NULL when the specialist hand-composed from scratch).
This decouples authoring (compose from anywhere) from runtime (one protocol → one patient-instance program), which is the clean separation the legacy model was missing.
Mid-treatment edits
Specialists routinely need to remove/replace/re-dose exercises during an active program — a patient hits pain on one exercise, the dose needs to step up, an exercise is contraindicated. The rule: soft-delete-old + insert-new for any clinical content change.
| Edit | Operation |
|---|---|
| Remove exercise / session | UPDATE … SET deleted_at = NOW() |
Replace exercise (different exercise_id) | soft-delete old row + INSERT new row, same sequence_order |
| Re-dose (same exercise, different reps/sets/hold) | soft-delete old row + INSERT new row, same exercise_id + new params |
| Add | INSERT new row |
| Reorder | UPDATE sequence_order is fine — order is presentation, not clinical fact |
Why never mutate-in-place for clinical content: past session_exercise_events.session_exercise_id rows keep resolving by FK to the soft-deleted row, so "what was the patient prescribed when they did run R" is recoverable directly from row IDs. This gives two stats lenses for free, both intact across edits:
- Per-prescription-row ("how was the patient on this specific dose"): events resolve to the historical row.
- Per-exercise-catalog ("pain trend on the shoulder-press exercise"): aggregate over the stable
exercise_idFK that survives re-dosing.
Single edit pattern across remove/replace/re-dose/add also gives a cleaner Class I MDR story than "edit identity = insert, edit dose = update, edit order = update" — every clinical change is a discrete row event, not a mutation diff buried in audit_log.
Interaction with cadence + appointments
When a session is soft-deleted mid-treatment, the cadence engine walks the current (non-deleted) session list, and the denominator drops correspondingly. The patient doesn't lose credit for completed work — past session_runs.completed = true rows still count in the numerator, even if their session was later soft-deleted.
For supervised protocols, any appointments booked against the now-soft-deleted session need handling at the appointments layer: the specialist either reschedules them to the replacement session, or cancels them with reason clinical_change (out of denominator).
Patient notification
Mid-treatment edits notify the patient ("your specialist updated your plan: X was replaced with Y"). The notification system is a separate spec — for this design's purposes, the contract is: every clinical edit produces a patient-visible notice. Silent edits are not acceptable.
End-of-program lifecycle
protocols.end_dateis the projected end under perfect cadence, snapshotted at protocol creation and recomputed on any edit that changes session count or cadence config. It is always a projection, never a promise. Reports use it for "patients ending around" estimates.- Soft cap (default). The cadence walk continues past
end_dateif the patient is behind; the program isn't truncated. The patient keeps progressing through remaining sessions. - Hard cap (opt-in). A flag
end_date_is_hard_capon the protocol lets a specialist set a clinical deadline (e.g., pre-surgery prep) — whenend_datepasses, the protocol auto-flips tostatus = 'ended'regardless of remaining sessions. Default false. - Auto-complete. When the last active (non-soft-deleted) session in the program is played and marked completed, the service flips
statusfrom'active'to'completed'and writescompleted_at = NOW().end_datestays as the projection;completed_atis reality. Both columns coexist — reports that need projected end useend_date, reports that need actual completion usecompleted_at.
Schema implications (substrate changes vs. today)
These are the substrate changes required to align with this design. They are pre-prod (no production data) and can land in a single foundation PR.
protocols table
| Change | Rationale |
|---|---|
cadence_kind CHECK becomes IN ('flexible', 'scheduled') | Drop appointment_driven — its semantics live inside scheduled + supervised. |
Rename modality → supervision_mode; values 'unsupervised' | 'supervised' | Today's binary telerehab/in_clinic conflates supervision with channel; the rename makes the column describe what it actually controls. |
Add end_date_is_hard_cap BOOLEAN NOT NULL DEFAULT FALSE | Soft cap is the default; hard cap is opt-in per-protocol. |
Add completed_at TIMESTAMPTZ NULL | Distinct from projected end_date; set on auto-complete. |
cadence_config JSONB shape (for flexible)
Extended from {per_week} to:
{
"sessions_per_week": int,
"rest_pattern": int[] | null,
"sessions_per_active_day": int,
"min_rest_hours": int | null
}Field rename per_week → sessions_per_week for consistency. Validated by Go discriminated-union code; the test suite locks the shape.
sessions_per_active_dayis the daily ceiling, honored by every cadence (see Today surface gate) — default 1.min_rest_hoursis reserved, not enforced. It only matters forsessions_per_active_day ≥ 2(don't do morning + evening back-to-back). When built it should be a soft nudge, not a hard block — blocking a home patient at hour granularity reads as hostile and they have irregular days. The daily count ceiling covers cramming-prevention on its own; min_rest is a second-order within-day refinement. Keep as a JSONB field (zero migration to reserve), implement advisory-only if/when a 2/day protocol ships.
session_exercises table
| Change | Rationale |
|---|---|
Add deleted_at TIMESTAMPTZ | Enables soft-delete-+-insert mid-treatment edits. Today's table has no soft-delete column; only sessions and programs do. |
Update RLS + read queries to filter WHERE deleted_at IS NULL | Standard convention used elsewhere in the schema. |
appointments table
| Change | Rationale |
|---|---|
Add channel TEXT NOT NULL DEFAULT 'in_person' CHECK IN ('in_person', 'online_live') | Per-appointment channel; the load-bearing field for the hybrid use case. |
Add protocol_id UUID NULL REFERENCES protocols(id) | Required so the adherence engine's supervised branch can scope the denominator query to one protocol. NULL for stand-alone appointments unrelated to a protocol (consultations, etc.). |
Add session_id UUID NULL REFERENCES sessions(id) | Each booked appointment knows which session of the patient-instance program it materialises. NULL for non-protocol appointments. |
| Status enum extension | Must distinguish patient-attributable (cancelled_by_patient, noshow, cancelled_late) from clinic-attributable (cancelled_by_clinic) so adherence math is fair. |
Cadence engine (services/api/internal/core/domain/adherence/)
| Change | Rationale |
|---|---|
Drop CadenceAppointmentDriven enum variant + AppointmentCounter interface | Folded into supervised dispatch. |
ExpectedOccurrences input takes SupervisionMode | When supervised, dispatch consults appointments table (via injected counter) regardless of cadence_kind. |
ConfigFlexible adds RestPattern []int and SessionsPerActiveDay int; rename PerWeek → SessionsPerWeek | Mirror the JSONB shape change. Engine walks the cycle when RestPattern is non-empty. |
Cadence engine dispatch (after changes)
if supervision_mode == 'supervised':
return count_appointments(protocol_id, window,
status IN ('done','noshow','cancelled_late','cancelled_by_patient'))
switch cadence_kind:
case 'flexible':
if config.rest_pattern is null:
return floor(config.sessions_per_week * weeks_in_effective_window)
else:
return walk_cycle(config.rest_pattern, config.sessions_per_active_day,
effective_window, pauses)
case 'scheduled':
return count_weekday_slots(config.days_of_week, effective_window, pauses)Supervision is the outer dispatch; cadence kind is the inner. This keeps adherence math honest: supervised protocols always source their denominator from appointments (what actually happened), unsupervised protocols compute from cadence (what was prescribed).
What this design defers (without painting into a corner)
These items have substrate hooks in place but no implementation in this design. They can be built incrementally without changing the cadence_kind enum, the supervision_mode column, or the appointments FK shape.
- In-clinic appointment booking flow. Week-by-week, lazy booking against specialist availability. Substrate:
appointments.protocol_id+session_idalready in place; the booking flow is pure F-tier work. - Fill-rate / clinic-capacity reporting. "What fraction of prescribed slots could the clinic actually book?" — a clinic-side metric distinct from patient adherence. Substrate: status enum distinguishes
cancelled_by_clinic, so the data is already there. - Patient channel switching mid-protocol. "Move next Tuesday's appointment from in-person to online" — pure UX on the appointment row's
channelfield. - Specialist reschedule-with-reason. Substrate: appointment status enum + reason field.
- Pre-surgery hard-cap programs. Substrate:
end_date_is_hard_capflag + service-layer expiry logic. - AI-suggested cadence adjustments. "Patient is consistently behind by 30%; suggest reducing sessions_per_week from 4 to 3." Substrate: cadence_config is a single JSONB write; suggesting changes is a layer above this design.
Treatment-as-journey
Status: direction agreed 2026-05-28; designed 2026-08-24 (this section). Phase progression + manual stage-gating shipped earlier as roadmap step 3. Per-phase cadence, phase kinds and the journey builder are the current build.
The starting model was "program → phases → sessions, patient plays sessions, adherence %." The clinical reality is a staged journey with checkpoints: two weeks at 3×/week, a reassessment, then five weeks at 5×/week — or something else entirely, decided at that reassessment. Clinics ran this by hand: prescribe a block, meet the patient, prescribe another block.
A journey is ONE program, not a chain of prescriptions
This is the load-bearing decision, and the database already enforced half of it before the journey was designed.
uq_one_active_prescription_per_patient (000023) permits a patient exactly one active prescription, with the rationale written into the migration: two independent prescriptions mean nobody is accountable for total daily load. A journey assembled from concurrent protocols is therefore not representable, and a journey assembled from sequential protocols would scatter one treatment's adherence, pauses, approvals and history across rows that nothing joins.
So: a program IS the journey; its phases ARE the stages. Everything the journey layer needs hangs off program_phases, which was already the ordered spine and already carried the gate.
Phase kinds — one ordered spine
program_phases.kind discriminates what a stage is:
| kind | Sessions | Meaning |
|---|---|---|
exercise | yes | A block of sessions, dosed by that phase's cadence. |
reassessment | none | A clinical checkpoint. Carries requires_unlock, so the following stage stays shut until the specialist has done the reassessment and opened it. |
milestone (reserved) | none | Clinical / patient-reported event on the timeline. Not built. |
goal (reserved) | none | Measurable outcome target. Not built — see below. |
Milestones and goals join this enum on this table, not a parallel events table: one ordering, one query, and gating that already works. The journey API's JourneyEvent.kind discriminator was designed as exactly this seam, so adding a kind is additive on the wire.
A reassessment phase holding no sessions has one consequence worth stating: the journey read must enumerate phases and left-join progress, not derive its groups from session-progress rows, or a session-less stage is invisible.
Per-phase cadence and supervision
program_phases carries nullable cadence_kind + cadence_config + supervision_mode, in the same JSONB shapes as protocols — one parser, one validator.
NULL means inherit the protocol's. That single rule buys three behaviours:
- Undosed templates. A template phase with NULL cadence is content only; the prescribe dialog asks. The same "Coloană cervicală" is 3×/week for one patient and 5×/week for the next.
- Dosed templates. A clinic that always doses a program the same way sets the cadence on the template phase; prescribe prefills it. The clinician can still override — and the override is written to the patient's instance copy, never back to the template.
- No migration, no behaviour change. Every existing protocol and every single-phase program keeps working exactly as before, because every phase inherits.
The journey composer never leaves it NULL (2026-08-27). Inheritance is a property of the schema, kept for dosed and undosed templates; it is not something the clinician building a journey opts out of. Every exercise stage is dosed in the composer, beside the sessions it doses, and protocols.cadence_kind — which chk_protocols_cadence_kind_pair requires — is DERIVED from the first dosed stage rather than authored. The composer used to work the other way: a per-stage "this stage has its own frequency" toggle defaulting to off, and a prescription-level cadence control sitting outside every stage, silently dosing whichever subset had not opted out. Two frequencies on one screen, one of them live for an unnamed set of stages, is not a default — it is a guess the clinician has to reverse-engineer.
supervision_mode rides along for the same reason cadence does — "first two weeks supervised in clinic, then at home" is the same segmentation, and bolting it on later would re-open the adherence denominator a second time. A reassessment phase is supervised by nature; its cadence is meaningless and a CHECK forbids it.
entered_at — why a stored timestamp, when progression is derived
Cadence is calendar-anchored; phases are completion-anchored. Phase 2 begins whenever the patient finishes phase 1, or whenever the specialist unlocks it. To dose phase 2 at 5×/week the engine has to know the calendar day phase 2 actually opened — a runtime fact no amount of structure records.
program_phases.entered_at is that fact: the moment the phase became the patient's current, playable stage. Per-instance, the sibling of unlocked_at, never deep-copied.
Written at exactly three points:
- Prescribe — the first phase,
entered_at = start_date. - Prior-phase completion — the run that completes the last session of phase N stamps phase N+1, unless N+1 is gated.
- Unlock — a gated phase is entered when the specialist opens it.
It is not a progression pointer. done / current / locked stays derived from session completion, exactly as the 000025 comment insists; entered_at only anchors cadence windows. Deriving it instead (from the last completed run of the previous phase) was rejected: a deleted or corrected run would retroactively move a phase boundary and silently rewrite historical adherence.
Segmented adherence
The engine needs no rewrite. ExpectedOccurrences is already pure, already takes StartDate + Window + Pauses, and rest_pattern is already 1-indexed against StartDate. Per-phase cadence is a new outer function:
- Build the ordered phase segments from
entered_at— segment N spans[entered_at(N), entered_at(N+1)), the last one open-ended. - Clip each to the requested window; drop the empties.
- Call the existing walk once per segment with that phase's cadence,
StartDate = entered_at(N), that phase's supervision mode. - Sum.
Pauses are already interval arithmetic and compose unchanged. A protocol whose phases all inherit collapses to a single segment — i.e. today's behaviour, by construction.
Play gate
The gate already resolves the current phase (nextUnplayedInCurrentPhase). It reads that phase's cadence instead of the protocol's, and anchors the weekly block at entered_at instead of start_date.
The transition week resets at phase entry. A patient who finishes phase 1 on a Wednesday and steps up to 5×/week starts a fresh week that Wednesday, rather than carrying the old week's count forward at the new target. That is what a specialist means by "from now on, five times a week", and the alternative penalises the patient for the calendar position of a clinical decision.
Journeys are planned forward AND appended to
Both, and the append is the common case: a clinician who says "two weeks, then we reassess and decide" genuinely does not know stage 3 yet.
- Planned forward — the composer builds
faza 1 → reevaluare → faza 2in one submit. - Appended — after the reassessment, the next stage is added to the running protocol.
The append path exists structurally: assertProgramStructureEditable requires a patient-specific program to be draft, so adding a phase to a live protocol is unpublish → add → republish, which opens and closes a content_edit pause (protocol_pauses.kind, 000026). What was missing is the affordance — an "add next phase" entry point on the protocol page that performs that cycle rather than making the clinician walk it.
No placeholder "TBD" stage is needed. A journey that ends on a reassessment phase already tells the patient the truth: this block, then we reassess and plan what follows. Inventing an empty stage to represent an undecided one would state more than the clinician knows.
Prescribing a multi-phase journey
The prescribe dialog shows one cadence block per phase, prefilled from the template, with "same for all phases" enabled by default — so the ordinary program stays a single input, and a stepped journey is one toggle away. Phases appended later are dosed in the same sheet at append time. start_date, end_date / hard cap and approval stay journey-level: they describe the prescription, not a stage.
Deferred, deliberately
- Reassessment ↔ appointment link + auto-unlock. A
reassessmentphase today is a named gate a specialist opens by hand. Attaching the actualappointmentsrow (so the journey shows its date, and completing it opens the next stage automatically) is the next increment. It is no longer blocked on the appointments substrate — that shipped with F5 — but auto-unlock wants thecare_teamdiscussion to answer whose reassessment counts. - Milestones — an append-only clinical/patient event on the same spine. Buildable; not designed until committed.
- Goals — a goal is a target on an F16 measure series, so it must key on
custom_fields.key({measure_key, op, target, baseline}). Designing a private metric enum here would fork measure identity the day F16 lands. Waits on F16.1. min_rest_hours— unchanged; still a reservedcadence_configfield.
Roadmap status
| # | Step | Status |
|---|---|---|
| 1 | Clinic program-authoring UX rework | shipped (builder, drag-drop substrate, composer) |
| 2 | Today surface + play-time gate | shipped |
| 3 | Stateful phases (progression + manual gate) | shipped |
| 4 | Schedule-grid rendering | shipped as the composer's day-by-day preview |
| 5 | Per-phase cadence + phase kinds + journey builder | designed here; in build |
| 6 | Outcome goals | deferred to F16.1 |
| 7 | Milestone event log | deferred |
Related docs
- Appointments substrate — authoritative schema, status enum,
AppointmentCounterSQL contract, and capability gating for thesuperviseddispatch consumer. Substrate is spec-locked but not yet built. - Programs & Assignments — full feature spec, content model, RLS, API surface.
- Appointments — appointment lifecycle, channel, status enum (feature-level behaviour on top of the substrate).
- Data classification —
protocols+appointmentscolumn classifications. - Decisions: Why cadence + supervision + channel as three axes — design rationale.