Template updates & the prescription builder
Design locked 2026-08-23, not built. Covers three workstreams decided together: (A) forms-style pull updates from templates to patient copies, (B) custom (hand-composed) prescriptions, (C) the builder-first authoring flow. Also folds in two defects found during the analysis that sit on the same seam. Read with P49 — Catalog Ownership Tiers, cadence-and-supervision.md (mid-treatment edits section), and index.md for the base model.
Decisions this doc binds (2026-08-23)
- Copy-on-derive stays. No shared references, no snapshot tables. The instance rows are the old version.
- The session is the atom of content updates. Lineage and versioning live per session, not (only) per program — a clinician customizing one session of Maria's copy severs that session, and her other sessions keep receiving updates.
- Updates are pulled, forms-style. Template staff make an explicit Publish update gesture (mirrors
form_templates.version); outdated copies show an update affordance. Nothing propagates silently. Bulk "update all" is deferred — v1 is publish + per-instance apply. - Program-structure changes are in scope (template adds/removes/reorders sessions or phases), carried by a program-level structure version on top of the same lineage.
- Clinician customizations are out of propagation scope by definition — customizing severs lineage; a severed row never shows an update offer.
- Progress and stats survive updates. An exercise already performed keeps its history even when the update removes it — the soft-delete+insert dose pattern is the mechanism.
- Migration shape: the new columns are folded into the creating migrations (
000023/000025), not shipped as a forward migration. The owner stated (2026-08-23) that staging and production will be wiped and rebuilt for this early release (legacy data migrated later if needed), which is the precondition the fold rule requires. Wiping remains the owner's action exclusively.
A. Template updates (pull versioning)
Concept
Every copy edge in the P49 spine stamps lineage; every template carries a version counter that only the explicit Publish update gesture bumps. An instance row whose source has a newer version is outdated and shows an update button. Applying an update is a one-shot atomic patch — it needs none of the unpublish/pause machinery, because there is no half-edited window: the only guard is "no run in progress on the affected content".
Two independent layers:
| Layer | Tracks | Lineage | Version pair | Severed by |
|---|---|---|---|---|
| Session content | dose rows (exercises, sets/reps/holds, rests, audio items) | sessions.source_session_id | template sessions.content_version vs instance sessions.source_content_version | any dose/content edit on the instance session |
| Program structure | which sessions/phases exist, their order, phase names, requires_unlock | protocols.source_program_id (identity) + program_phases.source_phase_id + session lineage (matching) | template programs.structure_version vs instance programs.source_structure_version | any structure edit on the instance program |
The layers compose: a structure-severed instance (clinician added a session) still receives content updates on its un-customized sessions; a content-severed session still participates in structure sync (reorder, re-home).
Instance metadata is never touched by updates (name, subtitle, description, tags, cover) and metadata edits never sever. Updates move clinical content only.
Schema deltas
All folded into the creating migrations; every column gets a data-classification registry entry in the same PR.
sessions (fold into 000023):
content_version INT NOT NULL DEFAULT 1— meaningful on template rows (platform/org, in-program or standalone). Bumped only by Publish update.content_updated_at TIMESTAMPTZ NULL— service-maintained dirty marker: set by every content mutation on a template session, cleared by Publish update. Dirty =IS NOT NULL. (Deliberately not the trigger-pollutedupdated_at.)source_session_id UUID NULL REFERENCES sessions(id) ON DELETE SET NULL— stamped by every copy edge:CopySessionIntoProgram(attach) andDeepCopyProgram(prescribe/enroll; save-as-template stamps nothing). Survives severing — it is also the matching identity the structure sync places rows by.source_content_version INT NULL— the source'scontent_versionat copy/apply time.content_severed_at TIMESTAMPTZ NULL— the sever stamp, set once by the first dose/content edit on a patient copy. (Refined during implementation 2026-08-23: severing was first designed as NULLing the lineage, which silently erased the identity the structure sync matches by — a customized session would have had its template twin re-copied in beside it. A stamp closes the content-update door while the lineage keeps doing its second job.)
programs (fold into 000025):
structure_version INT NOT NULL DEFAULT 1+structure_updated_at TIMESTAMPTZ NULL— same stamp/dirty mechanic, set by structure mutations (phase CRUD/reorder, attach/detach/move/arrange).source_structure_version INT NULL— instance rows only; NULL = severed (or hand-composed, which never had it). Template identity stays onprotocols.source_program_id— no new FK onprograms, preserving the "instances stay out of the variant tree" rule.
program_phases (fold into 000025):
source_phase_id UUID NULL REFERENCES program_phases(id) ON DELETE SET NULL— stamped at deep copy; lets structure sync match phases so per-instance runtime state (unlocked_at,unlocked_by_principal_id) survives an update.
No new tables. No program_versions/session_versions — reconstruction of old versions is never needed because the un-updated instances are the old versions (this is where the analogy to F3 deliberately stops: forms need form_template_versions because a pending form has no materialized copy; our copies materialize at prescribe time).
Publish update (the template gesture)
- Where: a program template's page (one button covering the program + its sessions) and a standalone library session's editor. Permission:
content.publish. Audited normally. - What: in one transaction — every owned session with
content_updated_at IS NOT NULLgetscontent_version + 1and the marker cleared; ifstructure_updated_at IS NOT NULL,structure_version + 1and cleared. Idempotent when nothing is dirty (button disabled). - Templates remain editable in place at any status (the 2026-08-22 rule is untouched). Publish update is the moment staff say "this is now the version patients should have" — it is what keeps a 5-step editing session from generating 5 update offers, and it is the audit point.
Outdated + the apply endpoints
Outdated is computed at read time, no stored flags:
- session:
source_session_id IS NOT NULL AND source.content_version > source_content_version - structure:
protocols.source_program_id IS NOT NULL AND source_structure_version IS NOT NULL AND template.structure_version > source_structure_version
Apply session-content update — staff POST /v1/protocols/{id}/sessions/{sessionId}/apply-update, patient POST /v1/me/programs/{id}/sessions/{sessionId}/apply-update (enrollments only). Guards: instance program status='published' (never inside an unpublish-edit window), no in-progress run on this session (a run on session 2 never reads session 5 — the guard is per-session), source still exists and is newer. One transaction, diff-based per the mid-treatment-edit pattern:
- unchanged dose rows (matched by
exercise_id+ ordinal among equal exercises) keep theirsession_exercises.id— per-row stats continuity; - changed doses and removals: soft-delete old + insert new — historical
session_exercise_events, pain points and per-exercise stats keep resolving (the read paths are already soft-delete-aware; the implementation PR re-verifies each stats path reads history throughdeleted_at); - additions: plain insert; audio items replaced wholesale (no run history hangs off them);
source_content_versionset to the source's current value; render readiness re-checked (CreateRunalready refuses withsession_preparinguntil renders exist, so an updated dose that needs a new bake degrades safely).
Apply structure update — staff POST /v1/protocols/{id}/apply-structure-update, patient equivalent for enrollments. Guards: program published, no in-progress run on the whole program, no open pause of kind content_edit. Diff via lineage:
- phases matched by
source_phase_id: sync name, order,requires_unlock; keepunlocked_at/unlocked_by_principal_id; - template-added phases/sessions: deep-copied in (with lineage,
status='published'); - template-removed sessions: soft-deleted on the instance (runs and stats survive; "session N of M" re-derives; the gate walk and
behind_by_nare derive-only and adjust on next read); - template-removed phases: their surviving sessions soft-delete first, then the phase row deletes;
- content-severed sessions participate in reorder/re-home but keep their custom content;
- afterwards
source_structure_versionsyncs and the auto-complete check runs once (an update that removes the only unplayed session can legitimately complete the protocol — same rule as a terminal run).
Severing
- Any dose/content edit on a
patient_specificsession ⇒content_severed_atstamped (once; one-way, in the same service calls the dose gate already guards). The lineage columns survive: a severed session never gets a content offer again, but the structure sync still recognizes it as "the patient's version of template session X" and places it rather than re-copying X in beside it. A severed session whose source the template later removes is kept (customized means theirs) and appended after the synced rows. - Any structure edit on a
patient_specificprogram ⇒source_structure_version = NULL(the pin has no second job, so NULLing is fine at the program layer). - Metadata edits sever nothing. Severed rows never show update offers — "customized for Maria means permanently hers".
Surfaces, notification, permissions (v1)
- Clinic, protocol Content tab: per-session "Update available" badge + apply; program-level banner for structure updates. Applying requires
content.write(RLS scopes the instance to the org; the endpoint additionally verifies protocol ownership). - Portal, program detail (enrollments only): "Program updated — get the latest" button on the patient's own program. Prescriptions show no patient button — the clinician decides; the patient learns via the notice below.
- Every STAFF-applied update must produce an in-app notice to the patient ("no silent clinical edits" — cadence doc; patients receive no email, ever). A patient's own pull needs none — they performed the gesture. BLOCKED on unbuilt infrastructure (found 2026-08-23): the notify primitive's
in_appchannel writes rows, but the inbox surface does not exist — noGET /v1/me/notifications, no portal bell — so a notice row today would be invisible. The same gap already applies to the pre-existing mid-treatment edit flow (unpublish→edit→republish reaches the portal only as a generic "paused" badge). The notice workstream = notify category + templates + Send at the staff-apply call sites + the inbox endpoint + portal bell, and the last two are platform-wide surfaces deserving their own pass. Until it lands, staff-applied updates on prescriptions are silent — treat that as a release gate for patient-facing use, not a reason to half-build the bell here. - Deferred, in this order when wanted: bulk "Apply to all current copies" from the template page (+ the "97 current / 3 customized" counters that come with it); staff-side update offers on the library-session → program-template edge (lineage is already stamped, only the surface is missing); a patient-facing "what changed" diff.
B. Custom prescriptions (hand-composed)
The substrate half-exists: POST /v1/programs with patient_id already creates a buildable patient_specific draft — but no endpoint can hang a protocol on it (AuthorizeDerivableSource refuses the tier), so the flow dead-ends. This workstream completes it:
- Activation.
POST /v1/patients/{patientId}/protocolsaccepts, besides a template id, an existingpatient_specificdraft program of that same patient that no protocol owns yet. It publishes the program + sessions and creates the protocol withsource_program_id = NULL(already the documented hand-composed shape in cadence-and-supervision.md) andsource_structure_version = NULL. Every existing prescription rule applies unchanged: one-active-prescription, cadence required, the End/Pause/finish-first conflict flow,required_entitlement = content.prescription_play. - Minimal metadata. A custom prescription collects title + short plain description (subtitle) only — no cover, no tags, no rich description. Schema already allows it (all nullable); the card cover falls back to the text tile. The columns stay empty unless the program is later saved as a template.
- Mixed sources. The add-existing picker gains a "from programs" source: browse platform/org program templates and copy individual sessions out of them, alongside standalone library sessions and inline creation. Server side,
AttachSession's source check gets the tier guard it accidentally lacks today: source must be platform/org tier — copying out of another patient's instance is explicitly refused. Every copy stamps session lineage, so an untouched session inside a custom prescription still receives content updates from its source session — the bad-exercise fix reaches hand-composed programs too, which is exactly the session-as-atom payoff. - Save as template.
POST /v1/programs/{id}/save-as-template— the already-implemented-but-uncalledDeepCopyProgram(TargetOwnership='org'), producing a new, unlinked org draft (derived_from_program_id = NULL— decided: no link back to the originating prescription). Callable from the patient-scoped builder and from a protocol's Content tab ("save Maria's tailored program for reuse"). The copy then goes through the normal metadata step, publish, and (optionally) catalog placement on the existing/library/catalogsurface.
C. Builder-first flow (clinic UI)
BUILT 2026-08-23, then reshaped on owner feedback the same day: creation asks NOTHING — "New program" and "Compose a custom program" each create a numbered blank draft ("Program nou N" / "Plan personalizat N", slug auto-derived; the name is a handle to rename on Details) and land straight in the Content tab; the /new form and the custom-title dialog are gone. And DELETE /v1/programs/{id} now PURGES a draft outright — sessions, dose rows, phases, attachments cascading with it — when the database can prove nothing ever attached to it (no protocol targets or descends from it, no runs on its sessions, no derived copies, no appointments; the guards, not a was-ever-published flag, are the test), soft-deleting as before otherwise. The purge audits under its own PURGE verb with the before-image, runs on the admin pool inside the guards (programs/sessions deliberately have no RLS DELETE policies), and backs both "Discard draft" in the builder and "Delete draft" on library template drafts. Also with three review-driven hardenings beyond the plan: adoption REFUSES an empty draft (empty_program 409 — it publishes silently, and via the End-current conflict flow could replace a real treatment with nothing); "does a protocol own this copy?" and "which drafts are unowned?" are answered in SQL (program_id= on the protocols list, without_protocol=true on the programs list) rather than by client-side scans of paginated lists; and the extracted PrescribeConfigForm was translated (EN+RO) instead of carrying the old dialog's hardcoded English onto three surfaces. A custom draft's Details tab renders the minimal-metadata variant (name + subtitle only), drafts-in-progress surface on the patient's Protocols tab, and "Discard draft" is the way out. B.3's "from programs" picker source shipped 2026-08-23 too: the add-existing picker has two sources — the library, and published program templates browsed one level deep so individual sessions copy out ("two sessions out of the cervical programme"). Same AttachSession underneath, so lineage stamps and the tier guard applies; the composing program is kept out of its own list, archived sessions never offer themselves. With that, sections B and C are complete; what remains for the whole design is the in-app "program updated" notice (blocked on the platform notifications inbox).
The composer ("Generează din programe", 2026-08-24) — the old leo plan generator mapped onto the new model. What a medic writes ("Lombar faza 1 sapt 2 3 3 4, Sold faza 1 sapt 2 2 3 3, zilnic alternand programele") becomes: add source templates, pick PHASES with a × repeat count (a phase is what clinics author as the old "week"), merge sequentially or alternating with per-program weights ("3 genunchi 1 umar"), review the summary, Generate. The merged list lands through POST /v1/programs/{id}/sessions/batch — atomic, ordered, every copy lineage-stamped, so a generated plan keeps receiving its sources' published updates (the thing leo's generator could never do). Two legibility rules added after first use (2026-08-24): every run creates its own new phase (named after the sources, editable — generating twice yields two readable groups, and the refused batch rolls its phase back too), and when two or more programs interleave, each copy is renamed with its source tag ("Ziua 1 · Cervical") — three programs' worth of "Ziua 1/2/3" was unreadable; the override also strips the translated names off the copy's envelope so a localized source name can't shadow the tag, and content updates never touch names, so the tag survives pulls. The surface is a wide sheet (session-editor width) and the preview is a per-source summary + cycle line, not a session-by-session chain.
Compose-and-prescribe (2026-08-24) — the patient-first flow stopped being create-draft → builder → generate → prescribe. From the patient header, "Custom program" opens the SAME composer sheet in patient mode: step 2 embeds the shared PrescribeConfigForm (supervision, cadence, rest cycle, dates), and the preview lays the merged plan out day-by-day with explicit rest tiles (layoutByCadence in compose-plan.ts — the medic's "3 sedinte, 1 pauza" rendered before anything exists). The whole builder is client state until submit: POST /v1/patients/{patientId}/protocols/compose creates the patient program, batch-copies the sessions (flat — empty phase_name), and prescribes via the adopt branch, all in ONE transaction (protocols.Service.CreateComposed); a refusal anywhere — unpublished source, one-active conflict, bad cadence — leaves nothing behind, and the handler writes the same audit rows the manual walk would have (program CREATE, per-copy ATTACH, protocol CREATE). The escape hatch "Deschide în builder" materializes the plan as a numbered draft without prescribing, for hand-tweaking; createCustomProgramAction (blank-draft-from-header) was retired with its entry point. Template mode is untouched — the sheet without a prescribe prop still generates into an existing program. Deliberately absent: pauses and frequency — "zilnic" / "2 zile pauza" are cadence, set at prescribe, and the dialog says so. Merge + expansion logic is pure (apps/clinic/lib/programs/compose-plan.ts) with the medics' verbatim prescriptions as its unit tests.
Compose first, decide destination, metadata last:
/library/programs/newshrinks to title (+ optional subtitle) and drops straight into the Content tab. Slug auto-derives. Cover, tags, rich description move entirely to the Details tab, fillable whenever — typically at publish/catalog time. (Cover authoring itself shipped 2026-08-23 indd2bf569.)- Patient page "Prescribe" becomes a real fork: From a template → the existing prescribe dialog, inverted to patient-first (patient is fixed, pick the program — the known 750-line-dialog inversion); Custom program → creates the
patient_specificdraft and opens the builder scoped to that patient. - The patient-scoped builder's destination step: Prescribe (cadence dialog → activation endpoint) with an optional "also save as template" (runs B.4 and opens the metadata step on the new org copy). The library-scoped builder keeps its destinations (publish, prescribe CTA, catalog).
- Everything heavy reuses as-is: the SWR content panel, session sheet, pickers, cadence dialog. This workstream is flow re-sequencing plus the two new destination actions, not a rebuild.
Defects fixed in the same stream (FIXED 2026-08-23)
PATCH /v1/programs/{id} {status}bypassed the unpublish gates —UpdateProgram(andUpdateSession) validated the target status but never the current one, so published→draft skipped the in-progress-run refusal, the session flips, and thecontent_editpause, under the weakercontent.writepermission. Fixed by validating against the current row: a patient copy has no PATCHable lifecycle at all (ErrPatientCopyLifecycle/ErrPatientSessionLifecycle), published→draft routes to/unpublish(ErrUnpublishRequired; in-program sessions →ErrProgramSessionLifecycle), and the PATCH keeps only the guard-free transitions — draft↔archived and published→archived (template retire). A standalone session's published→draft PATCH is its legitimate unpublish door and now carries its own mid-run refusal (ErrSessionInPlay, viaHasInProgressRunForSession). Echoing the current status is a no-op.DetachSessionwas a silent no-op wearing a hard-delete — the code believedsession_runs.session_idisON DELETE RESTRICT(it isSET NULL, so the delete would have orphaned run history and cascaded the dose rows away) — butsessionsdeliberately has no RLS DELETE policy ("soft-delete via UPDATE only", 000023), so the DELETE affected zero rows on every request tx and the ignored boolean turned that into a lying 200. Fixed: detach always soft-deletes (honoring the schema's own rule; run history keeps resolving), and the service errors when the write touches no rows. Tests:rlstest/patch_status_and_detach_test.go.
Rollout
- Migrations: fold as listed above; verify with the established from-scratch rebuild + catalog diff. Local either rebuilds or hand-applies + diffs. Staging/production rebuild is the owner's action (owner-declared 2026-08-23 that both will be wiped for this release; nothing here authorizes an autonomous wipe).
- Same-PR obligations: classification registry rows for every new column; OpenAPI +
@workspace/api-client; glossary entries for Publish update and severed; P49 gains the versioning subsection; index.md's API inventory updated; i18n for every new surface (the recent wave already leftAssignProgramDialogand the catalog editors hardcoded-English — this stream must not widen that debt). - Suggested build order: defects → schema fold + lineage stamping in the copy paths → Publish update + outdated reads → session-content apply → structure apply → B (activation, tier guard, save-as-template) → C (flow rework) → portal enrollment button + notices.
Open details (small, flagged)
- Exact matching rule for dose-row diffing when one session holds the same exercise more than once (ordinal-among-equals is the proposal).
- Notification category naming/copy for "program updated".
- Whether enrollment updates should ever auto-apply (v1: never; the pull button + notice is the contract).