Skip to content

Features

Reconciliation banner (2026-08-02). The September-2026-launch framing that headed this file is retired. The active plan is platform-completion.md: no target date, migration last, scope F1 / F2.1 / F3 / F4 / F5 / F6 / F11. F1–F6 are ports of the live restartix-leo-* + restartix-intakes systems onto the platform's own data model — the UI and business rules come across, the schema does not. The survey behind that is leo-port-map.md; its §3 (per-feature port plan), §4 (preserved business rules), §5 (foundation conflicts C1–C16) and §7 (anti-pattern guardrails G1–G31) are folded into the F-sections below.

Out of scope (settled 2026-08-02): F7 Automations, F8 Segments, F12 billing engine, the F10 pose-frame ingest pipeline (client-side skeleton preview only — no MDR / IEC-62304 scope), F13 dedicated tenancy. Those sections stay in this file as design records, not as active scope.

Build state (updated 2026-08-03). Production has been live and serving real patients since 2026-06-05. Repo schema is at migration 000041; production is at 000040000041 ships with this branch and is not on prod until the user promotes it.

FeatureReality
F1 Specialists & SpecialtiesShipped 2026-08-03 (000040) — both Go domains, routes, OpenAPI contract, Clinic roster + detail + create, Locations page
F2.1 OfferingsShipped 2026-08-03 (000041) — offerings + offering_specialists, Go domain, routes, contract, Clinic catalog + detail + roster + cover. offering_forms waits for F3.4 (needs form_templates as an FK target). F2.2 / F2.3 stay deferred
F3 Forms, Custom Fields & ConsentsShipped 2026-08-04 (000042000044, local only — not on staging or production) — field library, templates + versioning, instances with snapshot-at-first-write, offering_forms, Tier B consents granted by a signed form, and the Cat E events that are the first producers for Cat C webhooks. Clinic builder + field library + offering Forms tab + patient Forms tab; Portal form wall + filler + signing screen. Two items still open inside F3 — org-creation template seed and the CNP block — see the status table at F3
F4 SchedulingNot built. No calendars, no specialist_weekly_hours, no btree_gist extension
F5 AppointmentsNot built. No appointments table exists — every reference in migrations is a forward-looking comment
F6 DocumentsNot built. No pdf_templates, no appointment_documents
F9 / F10 / F14Shipped, live (programs → sessions → protocols → runs, telemetry + media, commerce & access offers)

Permission rows now exist for specialties.*, specialists.* and offerings.* — seeded by 000040 and 000041 with their role-template grants. Still zero rows for forms.*, calendars.*, appointments.* and documents.*; those exist only as prose in rbac-permissions.md. Each feature seeds its own in its own migration, alongside role-template grants, RLS policies and data-classification.md entries (make check enforces the last one).

What builds on top of the foundation, in dependency order. Foundation (1A–1C + 1E.3) closed 2026-05-15; 1D admin surfaces are partially shipped / in flight (live status in foundation.md).

Architecture is authoritative for design — these checklists track scope and status, not entity definitions:

Discipline. Layer numbering inside features is Fn. Existing references like "Layer 3" / "Layer 4.5" still resolve via section anchors below. The naming change is editorial, not structural.


F1. Specialists & Specialties

Was old Layer 2.3. The first medical-domain feature: specialist profiles (specialty, signature, scheduling timezone) on top of the foundation's clinic-staff identity. Migration 000040 — the first migration of the port. Port sources: restartix-leo-dashboard/app/(dashboard)/echipa/* + app/_shared/specialist-availability/*.

F1.1 Specialties

  • [ ] specialties table — per-org: organization_id UUID NOT NULL. Settled 2026-08-02 (matches leo, data-model.md Area 2, and features/specialties/). The "per-org or global — decide before migration" question that stood here is closed.
  • [ ] Columns per the port plan: id, organization_id, title, slug, created_at, updated_at; UNIQUE (slug, organization_id); GIN (immutable_unaccent(title) gin_trgm_ops) (immutable_unaccent + pg_trgm ship in 000001).
  • [ ] Permission seeding: specialties.manage (admin); membership-read. Zero specialty permission rows exist today.
  • [ ] Specialty delete is a hard delete (configuration, not a medical record) but pre-checked → 409 with in-use counts, and audited (C8).

F1.2 Specialists

  • [ ] specialists table — human_id UUID UNIQUE NULL FK to humans(principal_id), name, title, description, slug, signature_url, avatar_url, minicrm_name, scheduling_timezone VARCHAR(64), scheduling_active BOOLEAN, deleted_at (P13). human_id NULL is the deliberate calendar-only specialist state — a specialist who is bookable but has no login. Partial index (organization_id) WHERE deleted_at IS NULL; GIN (immutable_unaccent(name) gin_trgm_ops); UNIQUE (slug, organization_id).
  • [ ] specialist_specialties junction with denormalised organization_id NOT NULL (C2 — RLS on junctions too).
  • [ ] specialist_locations — honour the 1B.14 contract: (specialist_id, location_id, created_at) composite PK. Its FK target (locations) has no clinic UI today; the Locations admin surface ships with this feature.
  • [ ] Signature image upload via 1A.8 (S3 surface signatures, already registered). Avatar upload needs a new SurfaceAvatars registration — verified absent from internal/integration/s3/surfaces.go (registry holds Signatures, Documents, FormsUpload, Logos, AppointmentFiles, ExerciseAssets). Do not overload SurfaceLogos, which is org branding.
  • [ ] Upload flow copies organization.UploadBrandingAsset verbatim: sniff → content-addressed key → upload → DB write → rollback-delete the new object on DB failure → orphan-delete the previous object only on success.
  • [ ] Patient-side SELECT policy expansion (carried from 1B.6): patients can read the clinic's specialist list — staff via organization_memberships OR patient via current_human_patient_profile_ids() joined to patients.organization_id. Without this, the portal's specialist picker returns zero rows for patient sessions. The same pattern recurs on offerings (F2.1) and calendars (F4) — flag it at each table.
  • [ ] Creation wrapped in middleware.EnforceLimit("max_specialists", 1) — the limit is already seeded (000004_tiers_subscriptions.up.sql: free 2 / pro 20 / dedicated unlimited).
  • [ ] internal/core/scheduling.ResolveSchedulingTimezone(ctx, locationID, specialistID, orgID) — P23's single fallback chain. Verified absent today; F4 and F5 both read it.
  • [ ] GET /v1/reference/timezones + IANA validation on write.
  • [ ] ?q= typeahead + ?ids= resolve from the first commit (Production Scale rule — never a full-directory picker).
  • [ ] Delete = softdelete.SoftDelete; no DELETE RLS policy on the table.
  • [ ] Permissions: specialists.view_org (membership-read), specialists.manage (admin). Zero specialist permission rows exist today.

Business rules carried from leo (port map §4.5):

  • T1minicrm_name overrides the display name in outbound CRM payloads: specialist_name = minicrm_name || name. Same pattern as offerings.minicrm_title.
  • T2 — presence (humans.last_activity) is throttled to 1 write / 5 min. It is a presence signal, not an access log — and is explicitly exempt from audit per CLAUDE.md.
  • C11 → bookability is derived, never a lazily-provisioned flag. leo gated bookability on an intakes_opening_id that was provisioned on demand, so specialists without one were silently undroppable from every roster. Here: scheduling_timezone IS NOT NULL AND scheduling_active AND EXISTS(weekly_hours). Expose a computed bookable flag with a machine-readable reason, surfaced in the roster UI.
  • C12 → deactivation is one column, one transaction. scheduling_active = false removes the specialist from availability computation by construction. Keep it strictly separate from humans.blocked — revoking at Clinic A must never lock the person out of Clinic B.

Do not port: leo's echipa/add/form-user.tsx, which sets a new member's initial password directly (confirmed: true, no invite loop). The platform's shipped invites domain + POST /v1/organizations/{id}/staff-invitations (magic link) replaces it and is strictly better.

New packages/ui primitives (both verified absent): TimeSelect (15-minute increments, searchable) and TimezoneSelect (IANA, sorted by live UTC offset).

Open, not invented here:

  • [ ] humans has no name column (port map §8.5). Verified at 000002_tenancy_rbac.up.sql — the table carries provider_subject_id, provider_org_id, email, confirmed, blocked, portal_credential_generation, last_activity, preferred_language, timezone and no display name. specialists.name covers specialists; admin and customer-support staff have nowhere to store one. Either add humans.name TEXT NULL in 000040 with a classification entry, or accept email-as-display-name on the roster. This is a foundation gap surfaced by the port, not a feature preference.
  • [ ] Route topology (§8.6) — /team/{principalId} or /team + /specialists/{id}? Calendar-only specialists (human_id NULL) have no principal to route on.
  • [ ] Does holding a specialty gate roster assignment? (§8.13) leo built the enforcing component and abandoned it unimported. Either enforce with a 422, or declare specialty membership taxonomic-only in the glossary.
  • [ ] Timezone-change policy when weekly hours already exist (§8.13) — block-with-migrate or allow-with-preview. leo silently shifts real availability, which is the one behaviour that is definitely wrong.

1D surfaces delivered with this feature: Clinic admin Specialists/Team roster + detail (apps/clinic has no team, staff or settings surface at all today — this is the first), and the Clinic admin Locations page (1D.2), because specialist_locations FKs a table with no UI.

Exit criteria: Clinic admin creates specialists with specialty + signature + avatar; the roster shows a machine-readable bookability reason; portal can list specialists for booking once F4/F5 ships.


F2. Offerings

Renamed and re-scoped 2026-08-02. This section used to be "Service Catalog" and specced services / service_specialists / service_plans / patient_service_plans / products. Those names are forbidden — the glossary's canonical term is Offering: a clinical service the clinic offers patients ("Initial Assessment", "Follow-up Consultation"). See glossary.md.

Offering ≠ access-offer. The shipped access_offers (F14 commerce — shop and campaign access grants) is a different concept that happens to share a word. Conflating the two already cost one wrong scope decision.

Settled 2026-08-02: F2.1 ships as a stand-in; F2.2 and F2.3 stay deferred. The Offering is not a nice-to-have catalog — it is the configuration spine of the clinical-ops stack, and three in-scope features have NOT NULL FKs into it (port map §2.1):

#DependencyFeatureSeverity
1calendars.offering_id NOT NULLF4.2Blocking — no FK target
2appointments.offering_id NOT NULLF5.1Blocking — no FK target
3offering_forms — decides which forms auto-attach at appointment creationF3.4Blocking — this is the form-generation mechanism
4offering_specialists with priority — the roster the assignment engine walksF4.3Blocking — assignment has nothing to iterate
5Public booking browse (bookable offerings)F5.4Blocking — nothing to list
6"Servicii efectuate" row on the generated report PDFF6.2Cosmetic — degrades to blank

Items 3 and 4 are the ones easiest to overlook: F3 Forms is not independently shippable in its useful form without a catalog entity, because "which forms does this appointment get" is answered by the offering, not by the appointment.

Naming it offerings from day one is load-bearing. The glossary defers the services → offerings rename "until that area is built"; building the stand-in meets that condition. Shipping it as services would mean renaming five tables and every FK later, on a table that by then holds production rows under the forward-only freeze.

F2.1 Offerings (in scope — migration 000041) ✅ SHIPPED 2026-08-03

  • [x] offeringsid, organization_id NOT NULL, title, slug, description, specialty_id FK specialties(id) ON DELETE RESTRICT, default_duration_minutes, cover_url, video_url, published, published_at, is_public, metadata, deleted_at, created_at, updated_at. UNIQUE (slug, organization_id); GIN (immutable_unaccent(title) gin_trgm_ops); partial indexes for the active catalog and the bookable subset.
  • [x] offering_specialists (offering_id, specialist_id, organization_id, priority INT NOT NULL DEFAULT 0), PK (offering_id, specialist_id). Capability + roster order — who can provide this offering and in what priority the assignment engine walks them. Distinct from calendar_specialists (F4), which is per-calendar assignment. Covering index on (offering_id, priority, specialist_id) for that walk.
  • [ ] offering_forms (offering_id, form_template_id, organization_id, slot, sort_order)lands with F3.4, since form_template_id needs form_templates to exist. slot ∈ disclaimer | survey | parameters | analysis | advice | report | medical_prescription. The prescription slot is medical_prescription, never bare prescription — settled in glossary.md → Two senses of prescription; the bare word belongs to the shipped protocols.kind='prescription' (exercise programs, live in production).
  • [x] Patient-side SELECT policy for the portal's bookable-offering browse — and, unlike F1.2, bounded by published AND is_public so a draft cannot reach a booking page.
  • [x] Permissions seeded in the same migration; RLS; data-classification entries; ?q= typeahead.
  • [x] Clinic UI: catalog list (search + specialty + status filters), detail with Details and Specialists tabs, create form, cover upload. Sidebar entry gated on offerings.view_org.

Delivered beyond the checkbox list: the OpenAPI contract with regenerated Go/TS types and @workspace/api-client wrappers, a dedicated s3.SurfaceOfferingCovers, and an RLS integration suite (offerings_test.go) pinning org isolation, the publish-bounded patient read, soft-delete visibility, hard-delete fail-closed, the ON DELETE RESTRICT specialty guard and the seeded permission matrix.

Decisions worth carrying into F3–F6:

  • minicrm_title was dropped, not deferred. It is a vendor-named column for an integration that is not confirmed on this platform, and F1 had already dropped its twin minicrm_name (000040) for exactly that reason. Sparse extras live in metadata; a real integration later gets a typed external_* column matching the four shipped precedents. T1 therefore does not apply to offerings today — if MerchantPro/miniCRM is confirmed, it arrives as a typed column in that PR.
  • published and is_public stay orthogonal, with no CHECK tying them. Effective patient visibility is published AND is_public, enforced in the RLS policy rather than in a query. A CHECK would force an order on two UI toggles the DB has no business sequencing, and collapsing them into one enum would delete the staff-only state, which is a real thing a clinic does.
  • published_at stamps the FIRST publish only. Unpublishing leaves it intact, so it keeps meaning "first went live at" rather than "last time someone flipped a switch".
  • cover_url holds a PUBLIC CDN URL and serialises directly (changed 2026-08-08; folded into 000041 rather than a new migration — F2.1 is on no environment, and the edit was comment-only, verified by a from-scratch rebuild diffed against the running database) — a cover depicts nobody and exists to be seen by unauthenticated booking visitors, so presigning it bought no confidentiality while costing a round-trip per catalog card. It follows specialists.avatar_url; the counter-example is specialists.signature_url, which stays a private S3 key because a leaked signature is forgeable. video_url is a third case: an external https URL the clinic publishes itself, validated at write time because a mixed-content embed fails silently in the browser.
  • specialties.CountInUse now counts offerings too. The two consumers block for opposite reasons — specialist_specialties CASCADEs (deleting would silently empty rosters), offerings.specialty_id RESTRICTs (deleting would die at the constraint and surface as a 500). One pre-check, one 409 that names both.
  • Roster priority is derived from list position in the UI, not typed into a number field. The schema permits ties; the editor always writes distinct ascending values, and F4's even-distribution strategy is the intended way to express "no preference".

Deliberately not built: leo's six other servicii detail tabs (acorduri, chestionare, parametri, evaluare-mobilitate, raport, recomandari) — every one is an offering_forms slot and arrives with F3.4; the calendar tab (F4); and atasamente / offering_attachments, which stays deferred because nothing in F3–F6 depends on it.

Explicit boundary. No pricing column, no offering_plans, no products, no purchase path, no entitlement binding on offerings. If a task starts reaching for those, it has left scope.

F2.2 Offering packages — DEFERRED

The commerce half: packages, sessions-included, validity windows, the old service_plans / patient_service_plans / patient_tier_inclusions design.

Why deferred, not just unscheduled. It is largely redundant against the shipped access chain — patient_tiers / patient_subscriptions / access_offers / patient_content_grants / the play gate. Two competing access models in one platform is a foundation problem, not a feature gap. Anything built here must reconcile with what already ships, not run beside it.

Two corrections to record before this ever restarts:

  • treatment_plan_assignments_total INT NULL on service_plansretired. It counted "specialist treatment plan assignments," a model superseded by programssessionsprotocols. There is no treatment_plans table and there will not be one.
  • The glossary's service_plans → enrollments rename target is blocked. enrollment is taken: protocols.kind IN ('prescription','enrollment') is shipped and live in production (000023_sessions.up.sql), where enrollment means "patient self-enrolled in a guided program." F2.2 needs a different name (offering_packages?) — decided in the docs pass, while it costs nothing.

F2.3 Products — DEFERRED

Reference catalog of physical goods. Nothing in F1/F3/F4/F5/F6 depends on it.

Open — not decided here

  • [ ] Appointment-package tracking ("this patient has N sessions of Offering X remaining," decremented as appointments are consumed). protocols.kind='enrollment' covers program enrolment only and does not cover this. It has no platform equivalent today. F2.2-adjacent; likely deferred with it, but it is a genuine gap and F5.1 must not silently assume it exists.

Exit criteria (F2.1 only): Clinic admin creates offerings, assigns a specialty and a prioritised specialist roster, and marks them public. Calendars, appointments and form attachment have a real FK target. No pricing, no plans, no purchase path exists.


F3. Forms, Custom Fields & Consents

STATUS (reconciled against the code 2026-08-04). F3.1–F3.5 are built and on the local database; migrations 000042000044 are not on staging or production. Cat E events shipped 2026-08-04 and were the last item blocking F4. Two things inside F3 remain open, neither blocking:

OpenWhy it matters
Predefined org templates on org creationThe starter field library seeds; the starter templates do not. A new clinic gets a populated library and an empty forms list. Needs a decision on which templates every clinic starts with, not new machinery.
The CNP block — designed, unbuiltPublish refuses national_id outright; patient_profiles has no encrypted column and nothing writes one. requires_national_id is carried on the template and in version history for when it ships. The last open question closed 2026-08-06 — the write path is patient-only via the Portal profile (see the CNP section below), so what remains is build work: national_id_encrypted BYTEA, its classification entry, the Portal field, and the audited reveal endpoint.

Deferred by design and NOT gaps: calendar_forms (belongs to F4), custom_field_versions (amendment 1), the GIN index on custom_field_values.value (no reader until F8 segments), treatment_specific_* consent purposes (struck 2026-08-05 — see F3.5.1; clinic treatment consents are offering_forms disclaimers, not ledger codes), and the drawn_kiosk / remote_link signature modes (both need a form session that is not the patient's own login).

None of the F3 UI has been exercised in a browser against a live session. Every verification so far is tests and typechecks, and all three UI defects found during the build were found by clicking.

Was old Layer 4 (and old 4.5 consent foundation). Forms must come before Scheduling because offerings and calendars both reference form_templates. See data-model.md Areas 6-7. Versioning + immutability are core (P14, P18, P19). Migrations 000042 (custom fields) + 000043 (templates + instances + offering_forms).

The template-version snapshot on the instance is the single most important structural fix in the whole port (port map C5). leo has none — editing a template retroactively rewrites how every historical form renders, which leo's own migration docs call the #1 reason to redesign. Everything else in F3 is secondary to getting this right.

Design settled 2026-08-03, before any schema. The user's direction was explicit: do not copy leo's forms. The model is three layers, and the rule that makes shared fields safe is the middle one.

LayerTableHolds
Field librarycustom_fieldswhat a field is — typed, keyed, org-scoped, reusable across templates
Canonical valuespatient_profiles (portable, patient-owned) or custom_field_values (org-scoped, never crosses clinics)what is true now
Form instanceforms.fields snapshot + forms.valueswhat was said then, frozen once signed

A template field binds to at most one canonical store via profile_field_key or custom_field_id; neither means form-only. That per-field choice is the entire "dynamic form with shared fields" mechanism.

Copy, never live-bind. Values are copied into the snapshot at creation; write-back on save is separate and separately audited. Live binding would make a signed form mutable — updating a weight next year would retroactively change what a signed consent said — and it is exactly leo's defect (C6).

Four amendments to the architecture docs, made in this pass rather than deviated from silently:

  1. custom_field_versions is not built, and custom_fields loses version / published / published_at. The table had no reader: the instance snapshot preserves historical rendering, audit_log records definition changes, and rollback is a template workflow, not a field one. form_template_versions stays. Adding field-level history later is a pure addition.
  2. Write-back is opt-in per binding, writes_back BOOLEAN DEFAULT FALSE. Prefill is always safe; propagating an answer back is deliberate — right for "current weight", wrong for date_of_birth, where one typo would rewrite the patient's canonical identity.
  3. Field types widen to text | textarea | number | email | phone | date | select | multiselect | radio | checkbox | scale | file | signature | national_id. scale is the VAS 0–10 shape the platform already captures on session runs — forms and runs must agree or the same clinical measurement means two things. national_id is a distinct type so it can be routed away from the generic store or rejected.
  4. entity_type narrows to patient | appointment. specialist and organization have no consuming surface; widening a CHECK later is a one-line ALTER with no data migration.

Two questions closed by the user (2026-08-03):

  • Field library is org-only, no platform-global tier. organization_id NOT NULL. Clinics get a starter set seeded as ordinary org rows at org creation and own them outright. Dual-scope (P20) was the alternative — rejected because cross-clinic field comparability serves nothing today and costs a nullable FK, two partial unique indexes and a fallback on every read.
  • Required-field validation fires at pending → completed, not per-field on save. Autosave must never block: a patient skips a field, comes back, and finishes. PATCH /forms/{id}/values always accepts; POST /forms/{id}/complete validates and returns 422 with the missing keys. The consequence is accepted — a form can sit in_progress with gaps indefinitely, so the patient wall's "is this done" query keys off status, never off completeness.

F3.1 Custom Fields (field library + canonical values)

SHIPPED, migration 000042. Checkboxes below reconciled against the code 2026-08-04 — they had been left unticked through the whole F3.1–F3.4 wave, which made the plan read as though the work had not started.

  • [x] custom_fieldsorganization_id NOT NULL, entity_type, key, label, field_type, options JSONB, description, is_private, sort_order, system_key. No versioning columns (amendment 1 above).
  • [x] UNIQUE (organization_id, entity_type, key) and UNIQUE (organization_id, system_key) — never global (C7). leo's meta_field.key is globally unique, which is why its cross-tenant template copy leaves templates pointing at another tenant's field definitions. Template copy remaps by system_keykey within the target org; a dangling cross-org reference fails the copy rather than resolving.
  • [x] custom_field_versionsdeliberately not built. See amendment 1.
  • [x] custom_field_values (polymorphic entity_type, entity_id, organization_id NOT NULL, plaintext value TEXT). The GIN index on value is NOT built000042 records why: segments (F8) are out of scope, nothing queries the column by content, and adding GIN (immutable_unaccent(value) gin_trgm_ops) later is a pure addition. Everything else here shipped. This is the canonical org-scoped value, not a form answer — no form instance reads or writes it live.
  • [x] Per-org starter field set seeded at org creation, as ordinary editable org rows — a new clinic is not handed an empty builder. Delivered as an AFTER INSERT ON organizations trigger plus a backfill for existing orgs, not off the 1A.9 event: the seed is pure DDL-adjacent data with no external side effect, and a trigger keeps the reversal a plain DROP rather than an event subscriber to unwind.
  • [x] field_type='national_id' never falls through to custom_field_values.value. Enforced by a database trigger, not only in Go. A TEXT value column can never legally hold a CNP. The type either routes to the dedicated encrypted column (see CNP handling below) or is rejected outright. leo stores CNP as plaintext meta_value.value (G22) — that is the failure this rule exists to prevent.
  • [x] Auto-fill resolver — at first write rather than at instance creation (the materialization change settled 2026-08-03). Copy from custom_field_values (via custom_field_id) and patient_profiles (via profile_field_key) into the instance snapshot; on save, an explicit, separately audited write-back restricted to bindings with writes_back = TRUE. Two steps, never one (C6 — leo's form-value middleware silently redirects answers into a shared user-scoped store, so answering in one form rewrites every other form the patient ever filled).
  • [x] system_key immutability enforced at app layer.
  • [x] Drop the five leo patient-identity meta bindings (patient_meta_birthdate / _residence / _occupation / _sex, and the franchise-level pointer columns that named them). patient_profiles already has date_of_birth, sex, occupation, residence, phone as native columns (verified 000006). Only CNP has no home — see CNP handling below.

F3.2 Form Templates

SHIPPED, migration 000043, except the org-creation seed.

  • [x] form_templates (with version, published, published_at, pdf_template_id — FK populated in F6).

  • [x] form_template_versions (append-only — SELECT and INSERT policies only, no UPDATE or DELETE).

  • [x] Publish / version / rollback modelled on the shipped patienttiers.Repository.PublishVersion. The version number is derived from the append-only history, never from the published flag — reading it off the flag produced a duplicate key on the publish → withdraw → republish sequence, which is a thing clinics do routinely.

  • [ ] Predefined org templates auto-created on org creation — NOT BUILT. The starter field library is seeded (F3.1 above); the starter templates are not, so a new clinic gets a populated library and an empty forms list. The same trigger shape 000042 uses would do it; what is missing is the decision about which templates every clinic should start with.

  • [x] Publish-time template validation — six rejections, not two: the required + private dead zone, both bindings set, writes_back with no binding, a national_id field on a template that has not set requires_national_id (or one that also names a profile field, or writes back to a custom field — both are stores that cannot hold it), a binding that does not resolve at this org, and an entry whose field_type disagrees with the library field it binds to. Every problem is returned keyed by field, not just the first.

  • [x] Field sections — grouping, 2026-08-07. FieldEntry.section {key, title} groups a run of consecutive fields under one heading, on every surface a form has: the patient's filler, the read-back record (packages/ui FormAnswers, used by clinic and portal), and the printed document. An intake questionnaire covers identity, history, complaint and consent, and forty questions in one undifferentiated column leave a patient with no idea how far through they are or what the next question is about.

    Flat, not nested, same as F6's block sections: a section is a RUN of the ordered field list, not a container holding children. Field keys stay flat, forms.values stays keyed by field key alone, the instance snapshot copies verbatim, and no walk over a template — validation, the required check, write-back, the PDF projection — has to learn to recurse. The price is contiguity, enforced at the gate; the builder therefore offers only the run DIRECTLY ABOVE as a join target.

    The title is normalised onto every field of the run, not just the one that opens it. Consumers routinely see a SUBSET — a report prunes staff-only questions, and the opener may well be one — so a heading resolved from whichever field survived would vanish exactly when a section's first question is private. The same normalisation was added to F6's block sections, where the C2 filter can remove the title-bearing block; that was a real bug, and it has a test.

    The heading reaches the PDF through Answer.group — the title, never the key: a printed document has no use for a clinic's internal identifier, and carrying one would put it a JSON hop from a page handed to a patient. verify:pdf asserts the headings appear once per group.

F3.3 Forms (Instances)

SHIPPED, migration 000043, except the Cat E events.

  • [x] formsfields JSONB NULL + template_version INT NULL snapshotted at FIRST WRITE, not at creation (settled 2026-08-03), values JSONB (GIN), files JSONB, status enum pending | in_progress | completed | signed, completed_at, signed_at, deleted_at, patient_profile_id, organization_id NOT NULL, appointment_id NULL, created_by_principal_id, signed_by_principal_id.
  • [x] Materialization on first write. Forms are created at booking, potentially weeks ahead. A pending form holds no snapshot and renders live from the template's current published version, so a typo the clinic fixes on day 5 reaches a form filled on day 21 and a patient's corrected address is not stale. The first mutating call snapshots fields, pins template_version, runs the auto-fill copy, and flips to in_progress — all in one transaction. Freeze point = the moment there is an answer worth protecting. CHECK ties the three columns so no path yields a half-materialized row.
  • [x] Materializing against an unpublished or soft-deleted template fails closed — never snapshot a withdrawn template.
  • [x] Actor columns follow C1: subject is patient_profile_id; actors are created_by_principal_id / signed_by_principal_id. There is no user_id — the platform has no users table.
  • [x] State machine: pending → in_progress → completed → signed (immutable from signed).
  • [x] P14b: 409 Conflict on any mutation once status='signed', enforced at the handler layer AND the service layer — and by a database trigger, which is the layer that makes it a guarantee rather than two checks a future code path could bypass.
  • [x] Instance create-from-template is one INSERT in one transaction. leo does N+1 POSTs in a client-side for await loop and carries a standing in-code TODO that navigating away mid-loop corrupts the form.
  • [x] Batched PATCH /forms/{id}/values — one transaction, one coalesced audit row carrying the changed-key diff. (At 20 fields × 20k patients, one audit row per field is the difference between a usable and an unusable audit table.)
  • [x] File upload integration via 1A.8 — s3.SurfaceFormsUpload is already registered (pdf/png/jpeg/webp, 10 MB).
  • [x] Profile sync: fields with profile_field_key write back to patient_profiles, audited — and only for entries whose template set writes_back, as its own audit row under entity_type='form_write_back'. Skipped propagations are REPORTED with reasons rather than swallowed.
  • [x] Server-side audience projectionpatient | staff (no separate admin tier: nothing distinguished the two, and an unused projection is a code path nobody tests) driving what is sent, what is validated, and what renders into the patient PDF. Not a client-side filter.
  • [x] Soft delete only; no DELETE RLS policy (C8 — leo hard-deletes forms, values, reports and patient uploads with no tombstone).
  • [x] Publish Cat E eventsform.created, form.completed, form.signed, form_template.published, registered and firing from the handlers (best-effort: an event is a notification, not part of the transaction that produced it, so a bus hiccup must not roll back real clinical work). These are the first real producers for the Cat C outbound-webhook framework, which shipped framework-only in 1C.4.
    • Payload is an egress. A webhook body is delivered verbatim to a URL the clinic configured, so every payload field needs a webhook_egress target in the classification registry — and webhook_egress was declared but carried by NO column before this. The columns that now carry it are exactly the ones these four payloads contain. forms.values / forms.files (clinical_sensitive, no egress at all) and the signature evidence (signed_name is pii_basic; the address and user agent are audit_only) deliberately do not, and an integration test asserts they never appear in a payload.
    • Two timing rules, both tested. form.completed fires only on a real transition — completing an already-completed form is an idempotent success, and re-announcing it would have a subscriber act twice on one completion. form_template.published fires only when a version was actually APPENDED; re-publishing a withdrawn template at its existing version is a legitimate flag flip that adds nothing to the history. The second required Publish to report the append, because it is not derivable from the version numbers: a first publish and an unchanged republish both leave before.Version == after.Version.
    • form.completed carries requires_signature. Completed is not necessarily finished — a consent form still needs the signature that is the patient's actual act of consenting, and a subscriber treating completion as the end of the road would unlock a booking on a consent nobody gave.
  • [x] Consent grant on signing a consent-purpose form (writes to consents, see F3.5).

F3.4 Offering + Calendar Forms Junctions

  • [x] offering_forms (M:M offerings ↔ form_templates, with category_key + category_is_single + sort_order) — renamed from service_forms per the glossary. This is the form-generation mechanism: "which forms does this appointment get" is answered by the offering. Every category may attach (revised 2026-08-10) — an earlier revision admitted five hardcoded slots and excluded report / medical_prescription, which left the one document a specialist writes during a consultation with no way to be assigned at all. What that exclusion was expressing is document_categories.filled_by: patient categories materialise at booking, staff categories when the appointment enters inprogress. Cardinality is still a partial unique index, over the denormalised category_is_single flag, and generation is one call in one transaction.
  • [ ] calendar_forms defers to F4 (forms specific to a booking channel, e.g. a promotional disclaimer). At appointment creation the two sets are merged and deduplicated.

CNP (national ID) handling — settled 2026-08-02

Cross-cutting across F3.1 / F3.2 / F3.3 and into F6's templates. Not a numbered sub-feature; a rule every part of F3 obeys.

CNP is required on some forms and documents, not all. The per-template opt-in flags this originally specified were both removed (2026-08-07) — a national_id question's presence, or the cnp field's selection on a block, already declares the intent, and a flag beside either restated a fact it could disagree with. The rest of this section's consequences still hold:

  • [ ] One home. CNP is pii_regulatedencrypted BYTEA via internal/core/crypto (wire format [1-byte version][12-byte nonce][ciphertext+tag]), stored once on the patient-owned patient_profiles. Never duplicated per-org, never a per-form value. The column is national_id_encrypted BYTEAcmd/check-classification enforces both the _encrypted suffix and the BYTEA type for any pii_regulated column, and rejects the suffix on any other class. organization_billing.tax_id_encrypted is the working precedent to copy: encrypt-on-write / decrypt-on-read in the repository via crypto.EncryptString, COALESCE($n::bytea, col) on partial update.
  • [ ] Write path is PATIENT-ONLY, via the Portal profile — settled 2026-08-06. The CNP is patient-owned data on a patient-owned table; the patient types it into their own profile and clinics read it through the audited reveal endpoint. The clinic never handles the regulated identifier directly, which is the cleanest posture available under Law 190/2018. No staff write path, and no national_id form-field route into the column — a capture surface inside a form is exactly where the "never in the generic value store" rule gets broken by accident, and custom_field_values already rejects the type by trigger.
    • Accepted consequence, and it is not small: a patient booked by the front desk who has never logged in (the F5.4 guest-booking / awaiting_onboarding case) has no CNP, so a medical_prescription generated for them cannot print one. F6's per-template opt-in must therefore degrade explicitly — a template that opted in, rendering for a patient with no CNP on file, fails with a typed error naming the missing field rather than silently printing a blank. Revisit only if a real clinic proves the rețetă is unusable without it; the fix would be a staff write path with its own permission code and per-row read audit, not a loosening of the storage rule.
  • [ ] Never in the generic value store. See F3.1 — custom_field_values.value TEXT is structurally incapable of holding it legally.
  • [ ] Opt-in per template, for form_templates and for pdf_templates alike. Default off.
  • [ ] Egress is explicit. A data-classification.md entry with an explicit egress target for the PDF renderer; the renderer calls classification.AllowedFor rather than hand-building the field list (P39). Note pii_contact is not a valid class — the nine classes are public, org_internal, pii_basic, pii_regulated, clinical, clinical_sensitive, auth_secret, audit_only, system_metadata, and make check fails on anything else.
  • [x] Reads are permissioned and auditedpatients.view_national_id on the /national-id reveal endpoint, plus the audit.ActionRead row it writes. (Originally specified as passing the profile_shared gate; that gate was removed 2026-08-20 — see P8, retired.)
  • [ ] Romanian specifics (Law 190/2018 has quirks on processing the CNP even with consent) are an F11.0.5 counsel item, not an engineering assumption.

Business rules carried from leo (port map §4.3) — none of these are in any spec on either side:

#Rule
F1Form cardinality is DATA, not a rule (revised 2026-08-10): document_categories.cardinality per category, seeded to leo's shape — disclaimer and survey many, the rest one. report and medical_prescription ARE attachable; they are filled_by = 'staff', so their forms are created when the consultation starts rather than at booking.
F2analysis deliberately does not pre-create value rows (createValues: false). The mobility-evaluation flow writes them from measurements instead.
F3Detaching a form must never delete shared profile-level values. Easy to get wrong here precisely because auto-fill copies values in.
F4required + private is a dead zone. Private fields are omitted from the patient DOM, from the submitted payload, and from the required-check — so a required private field can never block a patient submit. Catch it at publish time as a template-authoring error.
F5Autosave contract: 1s debounce + flush on blur + dirty ref (blur with no change is a no-op) + per-field spinner + per-field error + an "add missing entry" affordance.
F6Field keys are generated {type}_{4 alnum} and are immutable once assigned — PDFs and exports reference them.
F7Patient date input is three selects, not a date picker — deliberate and correct for older patients on mobile. leo hardcodes the year range to currentYear-100 … currentYear-10, making under-10s unrepresentable. Keep the control, drive the range from config.
F8Consent bodies interpolate {{patient.name}} / {{fields.<key>}}; unresolved paths render a neutral placeholder rather than erroring. The variable namespace is a server-side allow-list derived from the classification registry — leo interpolated {{patient.password}} into rendered consent text (G10).
F9Global (appointment-null) forms gate every appointment. This is how clinic-wide disclaimers work.
F10The blocking gate renders one form at a time, disclaimers before surveys, and the subtree never renders until the queue empties.

Open, not decided here:

  • [x] is_required enforcement pointsettled 2026-08-03: at the pending → completed transition. PATCH /forms/{id}/values always accepts so autosave never blocks and a patient can skip a field and return to it; POST /forms/{id}/complete validates and returns 422 listing the missing keys. The accepted consequence: a form can sit in_progress with gaps indefinitely, so the patient wall's "is this done" query keys off status, never off completeness. Per-field validation on save was the alternative — it breaks the proven 1s-debounce autosave contract (F5) and makes skip-and-return impossible.
  • [ ] Historical form import fidelity (§8.11) — legacy forms have no snapshots. Import with the template's current state (accepting that history was already rewritten), or as read-only "legacy answers" with a synthesized snapshot and a visible provenance marker? Different GDPR / medical-record posture. Phase 3 decision, but F3's schema must not foreclose either.

Layers form-driven medical consents on top of the foundation's consents ledger (1B.9). The schema, withdrawal semantics, RLS, trail view, and RequireConsent middleware all ship in foundation; this feature adds the form-as-content path + Tier B purposes + multi-modal signature capture.

What's already there from 1B.9 (do not re-ship):

  • consents ledger table with source discriminator, source_form_id NULL, withdrawal columns, append-on-grant + UPDATE-on-withdraw semantics. consents.source_form_id is already reserved000008_consents.up.sql declares the column plus CHECK ((source = 'form') = (source_form_id IS NOT NULL)) and a comment recording that the FK lights up when F3 ships the forms table. F3's migration adds the FK; it does not add the column. 000008 is applied to production, so the FK addition is a plain forward migration on an existing column — no catch-up DDL script needed for the column itself.
  • consent_purposes + consent_purpose_versions catalog with legal_basis and withdrawable columns
  • current_required_consent_versions(principal, org) helper + re-consent middleware
  • RequireConsent(purpose) middleware (stub in foundation; this feature adds real consumers via F5.6 / F9)
  • Self-toggle endpoints (foundation surfaces SaaS-level toggles in patient settings)
  • Staff-action endpoints (foundation surfaces staff grantor path)
  • Trail view (GET /v1/me/consents, GET /v1/organizations/{id}/patients/{patient_id}/consents)

Tier B adds (this feature):

SHIPPED 2026-08-04, migration 000044, with three deliberate deviations recorded inline below: the declaration carries an optional field_key, treatment_specific_* is struck (2026-08-05 — the three seeded purposes are the whole Tier B catalog), and drawn_kiosk / remote_link are not built. Local-only; not on staging or production.

F3.5.1 Tier B purposes register in the catalog

  • [x] Add to consent_purposes (org-scope, legal_basis='consent', withdrawable):
    • [x] telemedicine — telemedicine consultation acceptance + clinical disclaimers

    • [x] video_recording — recording the video call for clinical review

    • [x] biometric_capture — pose estimation, goniometer, biometric readings

    • [x] treatment_specific_*STRUCK 2026-08-05, not deferred to a shape. The three above stay the whole Tier B catalog.

      The dividing line is whether any code branches on it. A ledger purpose exists so software can ask before acting — do not start the recorder, do not run pose estimation. video_recording and biometric_capture earn their codes on exactly that basis. "I consent to dry needling" is checked by nothing: no branch reads it, no processing is withheld, and it cannot be withdrawn after the fact because the procedure already happened. It is a signed document, and F3 already stores those better than a ledger row would — immutable, template-version-pinned, typed name, IP, user agent, and the canonical text inside the record rather than referenced from it.

      It never existed in the system being ported. leo's per-service acorduri are form_templates with type = 'disclaimer'servicii/[id]/acorduri/add.tsx POSTs {title, franchise, type} and nothing more — and the patient's Acorduri tab is /forms filtered on that type. There is no purpose catalog, no ledger and no withdrawal path anywhere in it. The name came from a pre-audit spec of our own, not from the port. Same discovery as the F5 add-on array: a shape that describes behaviour which has never run.

      The mechanism it was reaching for already shipped. An offering can attach a template whose category is disclaimer (000043) — that IS leo's type, ported. "This offering requires this document signed" is expressible today with no schema change, and wiring it into booking is F5.6's actual job.

      Building it now is strictly worse than building it later. 000008 is on production, so the pre-prod editable window closed long ago: an organization_id column plus an RLS branch plus a namespacing scheme for a PK that consents and consent_purpose_versions both FK (both carrying real production rows) plus an INSERT grant reversing 000008's deliberate REVOKE costs the same catch-up DDL whenever it happens. Doing it in six months costs the same and buys a real requirement to design against. There is no cheap-now window to miss (P36).

      Upgrade path, if a clinic ever needs a withdrawable purpose of its own: add consent_purposes.organization_id UUID NULL + an RLS branch + a namespaced code. Purely additive — every existing row is NULL = platform catalog, so nothing migrates.

  • [x] Platform-default v1 version rows for the three, because consents.Service.Grant pins to the latest published version and 409s with version_unavailable when a purpose has none at any scope. The bodies are deliberately thin: for a form-driven consent the canonical text IS the signed form, reachable through source_form_id.
  • [x] Per-org override flow for clinic-defined purposesstruck with treatment_specific_*. Note this is only the per-org purpose flow; consent_purpose_versions.organization_id already exists and per-org override text for a platform purpose works today.

F3.5.2 Form-as-canonical-content for medical consents

Clinical-grade consents need the immutability + reproducibility + signature capture that signed forms give. The form template is the canonical legal text; the consents ledger row is the queryable index referencing it.

  • [x] form_templates.consent_purposes JSONBentries are {purpose_code, field_key?}, NOT (purpose_code, purpose_version). Two deviations, both deliberate:
    • No version pin. Grant resolves the latest catalog version itself and supersedes stale grants; a version stored on the template would either be ignored (a lie in a column an auditor reads) or honoured (breaking supersession). The version that legally matters is the FORM template's, already pinned on the instance and reachable via source_form_id.
    • An optional field_key. Without it, signing grants the purpose outright — right for a telemedicine disclaimer, where signing IS the affirmative act. With it, the grant follows a ticked checkbox. The flat array would have granted marketing_email to every patient who signed, including the ones who deliberately left the box unticked, which is consent manufactured rather than given (Art. 4(11)). Publish requires the tied field to be a checkbox with exactly one choice, patient-visible and not required — a checkbox here is a choice group, so one choice is what makes a tick unambiguous, and a consent the patient cannot decline is not freely given.
  • [x] The declaration is snapshotted TWICE — onto form_template_versions at publish, and onto forms.consent_purposes at materialization. The spec did not say so; it follows directly from copy-never-live-bind. A clinic that adds video_recording to a template after a patient started filling one must not have that patient grant it by signing a document whose text never mentioned it. chk_forms_materialization now ties it to fields + template_version so no half-snapshot is reachable.
  • [x] Form-signing hook: on the transition to signed, the consent rows are inserted in the same request transaction. A consent that fails to insert takes the signature down with it — a signed consent form with no ledger row, or a ledger row with no signature, are both worse than a failed request.
  • [x] Consent grants are audited as entity_type='consent', one row per purpose, separately from the form's own row. Declined purposes are audited too: "the patient was offered this and refused" is unrecoverable from the ledger, where a refused consent leaves no row at all.
  • [x] One signed form produces multiple consent rows.
  • [x] Withdrawal does NOT mutate the signed form — nothing in forms is reachable from the withdrawal path, and the immutability trigger refuses it anyway.

F3.5.3 Multi-modal signature capture

  • [x] In-portal clicksignature_mode = 'click_typed'. Stored on the instance: typed name, IP, user agent, timestamp — the four things the legacy publishedAt = now() signature has none of. IP and user agent are audit_only: recorded, never serialised into a form response.
  • [x] form_templates.signature_mode replaces a client-side heuristic. The Portal used to decide whether a form needed signing with type === "disclaimer" || has a signature field — a policy decision about a legal document, living in a React component where no clinic could see or change it. Migration 000044 backfills exactly the templates that heuristic covered, so behaviour is unchanged and the decision now belongs to whoever authors the form. forms.required_signature_mode snapshots it so the patient wall can ask "is this still outstanding" per row without a query per row.
  • [ ] Drawn on tablet at clinic (drawn_kiosk) and [ ] Sent to phone (remote_link)NOT BUILT. Both need a concept that does not exist: a form session that is not the patient's own login. A tablet handed across a desk and a one-time link sent to a phone are the same missing primitive, and it is load-bearing security work (an unauthenticated form-render path needs its own RLS story), not a UI variation. The signature_mode CHECK admits only click_typed; widening it is a one-line ALTER plus a constant, which is why the column is a TEXT enum rather than a boolean.
    • Note a drawn signature is already possible today and does not need either: a signature field on the template accepts an uploaded image and CheckRequired counts either capture mode. That is a field ANSWER, distinct from the signing transition signature_mode describes.
  • [ ] Trail-view rendering of the signature mode + inline image — deferred with the modes above.

F3.5.4 Real consumers of RequireConsent

  • [ ] F5.6 (consent gating in appointment flow) — two gates, not one. Ledger: telemedicine when the appointment is delivered remotely, video_recording when recording is configured, biometric_capture when pose is on. Documents: every required form the offering and calendar attach, signed. Clinic-specific treatment consents ride the second gate as ordinary disclaimer-category forms — see the struck treatment_specific_* entry in F3.5.1 for why they are not ledger purposes.
  • [ ] F5.5 (Daily.co video integration) — video room creation requires video_recording if the appointment is configured with recording enabled.
  • [ ] F9 (telerehab) — pose-estimation features require biometric_capture.
  • [ ] F7 (automations) — out of scope; the marketing-consent enforcement point moves with it.

G21 — what the ledger is defending against. In leo, a patient's consent signature is publishedAt = now() set by an ordinary PUT: no signer, no IP, no user agent, no version pin, no hash — and the same PUT can unset it. The shipped consents ledger (purpose_version, granted_by_principal_id, granted_via_ip, source, withdrawal columns, partial-unique active grant) is the whole answer. Nothing in F3 may write a consent by any other path.

Decisions resolved:

  • [x] Cross-purpose withdrawal cascade for Tier Bindependent, the documented default. Matches foundation 1B.9 semantics and needed no code: nothing in the signing path couples purposes, and each is its own ledger row.
  • [x] Signature image format — moot while drawn_kiosk is unbuilt. Defers with it.
  • [x] Sent-to-phone link revocation — moot while remote_link is unbuilt. Defers with it.

A cross-cutting fix this feature forced. cmd/check-classification only parsed CREATE TABLE; its own comment said ALTERs would be handled "when they become real". 000044 is the first migration that legitimately ALTERs already-shipped tables, and until the parser grew, every column added by an ALTER was invisible to the classification gate — no declared class, no declared egress, and make check silent about it. The parser now reads ALTER TABLE ... ADD COLUMN, and it immediately caught a column of this feature's own that had no registry entry.

Exit criteria. A clinic publishes a telemedicine-consent form template with consent_purposes=[(telemedicine, v1)]; patient signs it during booking via any of the three signature modes; one consents row is inserted with source='form' + source_form_id set; the appointment flow proceeds. Patient withdraws via the trail view; the row's withdrawn_at is set; next telemedicine booking re-prompts. Clinic admin sees the signed form (with signature image) and the consents row in their patient detail view.

Exit criteria for F3 overall: Forms are designable, instantiable per appointment, snapshot template versions, immutable after signing, file uploads work, profile fields sync, and medical consents flow through signed forms into the foundation's consents ledger.

Met, with two qualifications. "Instantiable per appointment" is instantiable per OFFERING — appointment_id is carried and nullable, and F5 supplies the appointments; until then the clinic generates a service's form set by hand, which is the same act performed manually. "Multi-modal signature capture" is one mode, click_typed; the other two are blocked on a primitive that does not exist (see F3.5.3) rather than unfinished.


F4. Scheduling

STATUS: F4 IS COMPLETE (reconciled against the code 2026-08-05, second pass). All 24 checkboxes are real. Built and on the local database only — migration 000045, not on staging, not on production.

The 8 boxes an earlier pass listed as open were both groups closed by the F5 calendar wave, which is where they always belonged:

Was openClosed by
F4.4 slot holds (6 boxes)Shipped as internal/core/holds with the F5 calendar wave. A hold terminates in an appointment, so it could not be finished until appointments existed.
Assignment wiring (2 boxes)Booking is the caller. scheduling/slot.go runs the engine through EnginePriorities, and scheduling/repository.go upserts specialist_assignment_tracking inside the booking transaction.

⚠️ F5 MUST NOT feed priority straight into the engine. The platform stores lower-first; the engine treats higher as better. Go through scheduling.EnginePriorities — see F4.3.

What exists end to end: a clinic can create a calendar against an offering, staff it (drag-ordered, subset-enforced), set a specialist's weekly hours and date overrides (single or bulk, this-calendar or global), and see real computed slots. The availability engine is differential-tested against restartix-intakes.

Not built: any patient-facing surface. F4 is org-scoped only (§8.10); the portal booking page and /v1/public/ land with F5.4. The clinic UI previews a booking URL (/programare/{slug}) whose route does not exist yet — the path shape is inferred from leo and is not settled (§8.11).

Never exercised in a browser beyond the calendar detail and availability dialog. The rich-text editor and the slug field have not been rendered at all. Every UI defect found this session was found by clicking, not by make check.

Calendars are the bookable unit. See data-model.md Area 4. Slot hold pattern from patterns.md P30. Migrations 000044 + 000045.

Detail lives in features/scheduling/ and port map §3 → F4 — the availability engine, the exclusion constraint, the hold protocol, cooldown and lead-time all have a running reference implementation in restartix-intakes. The checkboxes below track scope only.

F4.1 Specialist Availability

  • [x] CREATE EXTENSION btree_gistSHIPPED 000045. Was verified absent (000001 enables uuid-ossp, pgcrypto, unaccent, pg_trgm, vector, pg_stat_statements only). Must run on DATABASE_DIRECT_URL (P44).
  • [x] specialist_weekly_hoursSHIPPED 000045, organization_id NOT NULL. data-model.md Area 4 omits it, which violates a CLAUDE.md hard rule; the doc is the thing that is wrong.
  • [x] specialist_schedule_overridesSHIPPED 000045 (vacations, extra hours) — organization_id NOT NULL, same correction.
  • [x] location_id UUID NULL on both — SHIPPED (NULL = remote/telerehab), per the 1B.14 contract (P40).
  • [x] Single-true-availability invariant — SHIPPED. — DB-level EXCLUDE USING gist on both tables, regardless of location_id. A specialist cannot be in two places at once; locations label availability, they never partition it.
  • [x] Both tables are STATE — flat, never partitioned (P41).

F4.2 Calendars

  • [x] calendarsSHIPPED 000045. offering_id NOT NULL (F2.1 is the FK target; renamed from service_id), plus slot_duration_minutes, slot_gap_minutes, cooldown_minutes, min_lead_time_minutes, horizon_days, slots_open_at / slots_close_at, assignment_strategy, is_public, published, slug, deleted_at.
  • [x] DB CHECK enforcing window-XOR-horizon — SHIPPED. leo enforces it only in a client-side save handler.
  • [x] calendar_specialistsSHIPPED 000045, with priority. The override_weekly_hours JSONB is struck: JSONB on a junction is unqueryable by the slot engine and cannot participate in any overlap constraint. Per-calendar exceptions are specialist_schedule_overrides.calendar_id instead (§8.2).
  • [x] calendar_forms junction — SHIPPED 000045. (forms specific to this calendar; merged + deduplicated with offering_forms at appointment creation).

F4.3 Specialist Assignment Tracking

The table and the strategy column ship with F4; the ENGINE that reads them lands with F5. Assignment only happens when something is booked, so calendars.assignment_strategy is a stored setting with no consumer until appointments exist. The clinic UI writes it (the Distribuire uniformă toggle) and specialist_assignment_tracking stays empty until then.

⚠️ PRIORITY POINTS OPPOSITE WAYS on the two sides, and F5 must convert. The platform stores priority as "lower walks first" (000041, 000045, and the drag-ordered clinic UI, which writes list position). The ported engine treats HIGHER as better — its differential fixture proves it: priorities {5,5,2,1} select the two 5s. Feeding the stored column straight in would offer the clinic's FIRST choice LAST, silently.

scheduling.EnginePriorities is the conversion and the only place that knows about the flip; AutoAssignable mirrors its nil handling. The engine is deliberately NOT changed to match — it is pinned to the oracle by fixtures, and making the port unfaithful to fix a boundary problem is the wrong trade. Four tests cover it, including one that runs the converted map through the real ordering function and asserts the top of the roster is picked first.

  • [x] specialist_assignment_tracking (round-robin counters per calendar) — table shipped in 000045; WRITTEN since the F5 calendar wave (2026-08-05). scheduling/repository.go upserts assignment_count + 1 as part of the booking transaction, and the read path LEFT JOINs it to order the roster.
  • [x] EnginePriorities / AutoAssignable — the platform↔engine priority conversion, with tests.

F4.4 Slot Hold System (Redis) — ✅ BUILT with the F5 calendar wave (2026-08-05)

Why it moved. A hold's lifecycle terminates in an appointment, and appointments does not exist — verified against the database, to_regclass('public.appointments') is NULL. Two of the six checkboxes below say so outright: the booking-client id is "persisted to appointments.booking_client_id", and release happens on "successful booking".

Built here, the store could be claimed and could expire but never confirmed, and nothing would create a hold, so availability-minus-holds would be dead code in production until F5 shipped anyway. That is machinery written against a table that does not exist — the shape of speculation the Layer 2+ rule exists to prevent.

The checkboxes are unchanged and move intact to F5, next to appointments, where the lifecycle closes and booking_client_id has a column to persist into. Nothing else in F4 depends on them: the availability read is complete without holds, and subtracting them later is an additive change to one function.

  • [x] Hold key pattern + TTL, Lua-atomic heartbeat — SHIPPED as internal/core/holds, a sibling of locks rather than part of it: locks.Store keys by a single entity UUID, and a hold's identity is composite (calendar + slot + specialist). leo's numbers, ported: 30s TTL, 20s heartbeat, max 5 extensions, one hold per client.
  • [x] Every hold / stream key namespaced with cache.OrgResource(orgID, …) (C13) — SHIPPED, with a test asserting two orgs with identical calendar and slot ids do not collide.
  • [x] Server-derived booking client identity — SHIPPED for the STAFF path as holds.ClientIDForPrincipal, derived from the authenticated principal and never read from the body. The signed HttpOnly cookie persisted to appointments.booking_client_id is still needed for F5.4's public path, which has no principal to derive from.
  • [x] Hold release on timeout, cancellation, or successful booking — SHIPPED. The booking path CONSUMES the hold after the appointment row exists, never before: releasing first opens the slot to somebody else in the gap.
  • [x] SSE endpoint — SHIPPED, GET /holds/stream, opening with a snapshot then streaming transitions. Bounded at HOLD_STREAM_TIMEOUT (15m) so an idle tab does not hold a connection and a Redis subscriber indefinitely; the cap is on THIS stream, not the sse package, because the phone-and-TV run channel legitimately outlives it.
  • [x] Concurrent hold prevention — SHIPPED, Lua compare-and-swap, with a test racing twenty goroutines at one slot and asserting exactly one winner. Assignment also excludes clinicians already held, or two operators on a two-capacity slot are both proposed the same person.

F4.5 Availability Engine

  • [x] Compute slots from weekly hours + date overrides — SHIPPED (Service.Availability). Held slots and booked appointments subtract in F5, when either exists; both are additive changes to one function. Also: weekly hours + date overrides + calendar overrides.
  • [x] Conflict detection at the DB — SHIPPED: EXCLUDE USING gist on both availability tables. Appointment-level double-booking detection is F5.
  • [x] Round-robin and priority-based specialist assignment — WIRED in the F5 calendar wave (2026-08-05). scheduling/slot.go feeds the roster through EnginePriorities (never the raw column — see F4.3) and excludes clinicians another operator is already holding, so two staff on a two-capacity slot are not proposed the same person. A staff-chosen specialist pins absolutely rather than being substituted.
  • [x] Slot duration from calendar settings, seeded from offerings.default_duration_minutesSHIPPED. The offering's value is copied at creation and never read again; calendars.slot_duration_minutes is authoritative.
  • [x] Write the test suite that does not exist — DONE 2026-08-05. {availability,assignment,types}.go moved to services/api/internal/core/domain/scheduling/; they compile, vet, lint and test under make check. testdata/oracle-gen/generate.mts runs the production TypeScript engine over 21 cases and commits its answers to testdata/oracle/availability.json; availability_oracle_test.go replays them through Go. All the mandated cases are covered (spring-forward in two timezones, fall-back, overnight split, override replace-not-merge, override-false blocks, 90-day cap) plus grid alignment, window-XOR-horizon, appointment subtraction and assignment determinism.
    • It found two real defects. The DST spring-forward probe resolved a 03:30 request to 01:30Z where the oracle says 01:00Z — Go's time.Date normalises a gap time using the pre-transition offset, so it starts past the gap and can only overshoot. And now rounding used Truncate().Add(1min) where the oracle uses Math.ceil, silently dropping the first slot of every request whose clock landed exactly on :00.
    • It closed the fall-back question that availability-engine.md → Case 3 carried as UNRESOLVED: the repeated hour yields one slot at the second occurrence (standard time), and Go agrees with date-fns-tz — which that doc said could not be assumed.
    • A known defect is reproduced on purpose: spring-forward emits duplicate slot instants (2026-03-29 Bucharest offers 04:00 three times). That is leo's live behaviour; deduplication belongs at the F4.5 read endpoint, not inside the engine where it would make the fixtures unusable.
  • [x] Dedupe slot instants at the read endpoint — SHIPPED in Service.Availability (the union map also dedupes across specialists who share a slot). — see the defect above. The engine must keep emitting them so the differential fixtures stay valid; the projection must not.

Exit criteria: All calendar types from needs-on-day-1 work. Slot hold is collision-free. Availability returns correct slots end-to-end, differential-tested against the reference implementation.


F5. Appointments

See data-model.md Area 5. State machine pattern P33. Migration 000046.

⚠️ The appointments table does not exist and never has. june-launch.md claimed F5's substrate columns "already landed in the cadence redesign." That is false — the cadence redesign designed those columns against a table that was never created. Every appointments reference in the shipped migrations is a forward-looking comment. Building against that claim produces a broken migration.

The table shape is specified in architecture/appointments-substrate.md, and that doc is now buildable as written. The four defects this banner previously flagged (patient_persons / current_user_patient_person_ids(), class pii_contact, specialist_principal_id → principals(id), patient_service_plan_id BIGINT) were fixed in the doc on 2026-08-02 — the banner claiming otherwise was stale. Verified 2026-08-05 against the file: the schema block uses patient_profile_id, specialist_id → specialists(id), pii_basic, and the BIGINT column is gone.

Six open decisions ratified 2026-08-05 and folded into the substrate doc. Three changed the schema:

DecisionOutcome
specialist_id nullabilityNullable. calendars.assignment_strategy = 'manual' ships in 000045 and means "booking arrives unassigned"; NOT NULL would make it inert. Plus a partial index for the unassigned queue.
Add-onsStruck entirely. Checked against the live system: leo's appointment relates to exactly ONE speciality one-to-one, the report's "Servicii efectuate" row renders that single name, and restartix-intakes has no add-on concept. additional_offering_ids described behaviour that has never run.
Thresholdsorganization_settings.late_cancellation_hours (24) + noshow_grace_minutes (30). leo already parameterises the grace period.
Late arrival vs. terminal noshownoshow → inprogress permitted only when the noshow was system-set, within a bounded window, audited as a correction. Undoes a machine's guess, never a clinician's.
RescheduleMutate scheduled_at in place, audited. Preserves duration exactly (A1); no supersession FK.
Patient-self bookingDeferredSHIPPED 2026-08-06 (F5.7). Staff-side went first as decided, then the public path + holds (F5.4), then the authenticated patient's own booking, cancel and reschedule.

F5.1 Appointments

STATUS (2026-08-05). F5.1's substrate, domain and HTTP surface are built and on the local database only — migration 000046, not on staging, not on production. Every checkbox below is real.

What exists end to end: a clinic can book an appointment (including for someone with no account yet, and with nobody assigned), assign or reassign a clinician, drive the lifecycle through an enforced machine, cancel with attribution, reschedule in place, and read a month heat-map bucketed in the clinic's own timezone. Eight routes, the OpenAPI contract, regenerated Go/TS types, three permission codes on both sides.

The production gap this closed: adherence.AppointmentCounter had no implementation since 000023, so every supervised protocol returned ErrSupervisedNotImplemented and the UI dropped the ratio. It is wired, and pinned by a test asserting both directions.

The clinic surfaces are BUILT and exercised in a browser (2026-08-05). Two of them:

SurfaceWhat it is
/appointmentsThe filterable LIST — buckets (upcoming / unassigned / today / needs-closing / all), detail with the lifecycle actions, and a slot-picker booking form.
/calendarThe clinic calendar, reworked to leo's model 2026-08-05 after the first attempt got it wrong. Week + month only (the week IS the day view), Sunday-first, READ-ONLY — booking starts at Programează.

LIVE MODE is the load-bearing piece and is built end to end: selecting a calendar rebuilds the rows at ITS lattice step, hides every unrelated appointment, opens the SSE hold stream, and paints slots red as other operators take them. A slot is ONE region carrying capacity, never a lane per clinician — ten specialists cannot be ten columns, and the server picks who takes a slot via the assignment strategy. Staff who chose a named provider pin one, and that pin is honoured absolutely rather than substituted.

Live mode owns its reads client-side through SWR and revalidates on hold.confirmed, so another operator's booking lands without a refresh. One live session per operator across tabs, settled over BroadcastChannel. It exits after 10 minutes idle.

F5.4 public booking and F5.6 consent gating SHIPPED 2026-08-05; F5.2 files 2026-08-06; F5.7 the patient's own surface 2026-08-06 — see their sections. Of the original subsections only F5.3 reviews (deferred, premise unverified) and F5.5 Daily.co (split out, BAA-gated) remain, and neither is appointment plumbing.

Nothing is applied anywhere. 000046 is on no environment, and cmd/appointment-noshow-sweep is declared in both envs' Terraform and applied to neither. F5 is one migration: the follow-up 000047_appointment_slot_binding was folded back into 000046 on 2026-08-06 rather than shipped beside it, since neither had been promoted and a migration correcting an unapplied migration is pure noise in the chain. 000047 therefore stays free for F6, exactly as the port map assigns it.

Carried debt, recorded here because it is only otherwise in commit messages:

  • BOTH columns the slot-binding pass added are unconsumed. CLOSED 2026-08-06. appointments.contact_phone got its writer with F5.4's guest path. organization_settings.portal_booking_requires_confirmation was STRUCK rather than given one: it was designed before the status semantics were settled, and once they were it had nothing left to say. It would have made an authenticated patient's booking land booked — which means "the clinic does not know who this is yet", false for an account holder — and the need it reached for is already the upcomingconfirmed step, per appointment rather than as a global switch. It never affected slot occupancy, so there was no second thing it did.
  • TWO adapters bridge scheduling↔appointmentsCOLLAPSED 2026-08-05 while adding a third for F3.4's calendar forms. Production and rlstest now share one exported set in internal/core/server; there is no second place for a field to go missing.
  • Two booking entry points now existCLOSED 2026-08-06. The clinic's /appointments/new form is deleted; the calendar's Programează flow is the only staff booking path, which is what leo has and what the grid points at. The form predated the calendar rework and duplicated it worse — no live hold stream, so two operators booking at once found out at the 409. Its settings surface was kept and moved rather than deleted with it. (The Portal has a route of the same name; different app, different flow — see F5.7.)
  • BroadcastChannel is absent in some older browsers and privacy modes, where the one-session-per-operator handoff is inert. The ownership reconciliation makes that fallback safe, not silent.
  • [x] appointmentscreated here for the first time, with organization_id NOT NULL, offering_id NOT NULL (F2.1), calendar_id NULL, specialist_id NULL (settled 2026-08-05 — the pre-assignment state calendars.assignment_strategy = 'manual' already implies), location_id UUID NULL, channel, protocol_id, session_id, booking_client_id.
  • [x] Partial index (organization_id, scheduled_at) WHERE specialist_id IS NULL — the unassigned queue is its own bucket in the clinic list UI, not a scan of the org's whole history. The specialist index gains the mirroring WHERE specialist_id IS NOT NULL.
  • [x] organization_settings.late_cancellation_hours (default 24) + noshow_grace_minutes (default 30), with classification rows. Both are clinic policy, not platform constants.
  • [x] 9-status enum with cancelled split three ways — cancelled_by_patient / cancelled_by_clinic / cancelled_late — because the adherence denominator must exclude clinic-attributable cancellations. A patient is never penalised for a capacity shortfall.
  • [x] Two-phase booking identitypatient_profile_id set at booked with patient_id NULL; patient_id linked at onboarding. Shipped as a composite FK, not the pair CHECKs this line specified(patient_id, patient_profile_id) → patients(id, patient_profile_id), against a new uq_patients_id_profile. A CHECK cannot see another table, so it could only have asserted "both or neither"; the FK asserts the thing that matters, that the linked patient IS the booked person. MATCH SIMPLE keeps the phase-1 state legal. This is what lets a clinic accept a booking from someone who has no account yet, which is the common real-world case.
  • [x] No deleted_at. The status enum covers every did-not-happen case; a soft-deleted appointment would be a second, redundant way to express the same thing. (This is the deliberate exception to the platform's soft-delete-only rule for clinical records — the record is never removed, it is terminal-stated.)
  • [x] State-machine validator (P33) — this is new construction, not a port. leo has no enforced machine at all: Strapi accepts any status → any status, and done can be dragged back to upcoming from a free-text select.
  • [x] Cancel endpoint with attribution + cancelled_late derived from an org threshold; free-text reason capped at 500 chars. Its own endpoint rather than a status transition, so a caller can never name cancelled_by_clinic for a patient-initiated cancellation and lift that patient out of their own denominator.
  • [x] Wire AppointmentCounter into adherence. internal/core/domain/adherence/cadence.go already defines the interface and returns ErrSupervisedNotImplemented. Supervised protocols are shipped and live in production, waiting on exactly this.
  • [x] Bounded calendar-view endpoint bucketed by scheduling timezone, not toISOString() (leo's near-midnight wrong-day bug), with a month heat-map from a SQL COUNT … GROUP BY — never data.length.
  • [x] Auto-noshow sweep — shipped as cmd/appointment-noshow-sweep + an EventBridge schedule, NOT on internal/core/events/scheduler.go. That file is the in-process scheduler; every cross-tenant sweep on this platform (expired-sessions-sweep, expire-hard-cap-protocols, the backup runner) is a separate binary on an EventBridge → ECS RunTask cadence, because a cron with no org context needs the admin pool and must not ride a request-serving process. Every 15 min per A3; scheduled in both envs' Terraform, applied to neither. leo has none — nothing ever writes noshow. Apply the silence-timeout lesson learned on session_runs: only flip genuinely-unstarted appointments, never ones showing activity.
  • [x] contact_email is plaintext (pii_basic). Per the encryption-invariants rule (decisions.md → Why most PII is plaintext) all contact PII outside auth_secret and pii_regulated is plaintext + layered defense. The booking-flow dedupe-by-email path stays simple as a result.
  • [x] Add-on offerings via UUID arraySTRUCK 2026-08-05, not deferred to a shape. The premise was wrong: leo's appointments content type relates one appointment to exactly one speciality (one-to-one), the report template's "Servicii efectuate" row renders that single name rather than a list, and restartix-intakes has no add-on concept either. The array was inherited from a pre-audit feature spec and describes behaviour that has never run — so both shapes would have shipped a column with no writer and no reader. If it ever ships, the junction is the right shape (an array carries no FK, so pruning an offering from the catalog silently leaves dangling UUIDs in every historical appointment); adding either later is additive. additional_product_ids stays dropped with F2.3.
  • [ ] Plan session tracking via patient_service_plan_id + plan_session_numberremoved. F2.2 is deferred, so there is no FK target. See the open item below; do not add a nullable placeholder column "for later" (P36).
  • [x] Eight indexes (five partial) — more than the five the substrate doc listed, because the nullable specialist_id needs a matching pair (assigned / unassigned) and the calendar-channel breakdown needs its own. Three RLS policies (SELECT with three branches, INSERT, UPDATE; no DELETE policy, so a hard delete fails closed). RequireURLOrgMatchesScope("id") (P47) is inherited from the parent per-org route group's r.Use.

F5.2 Appointment Files — ✅ BUILT 2026-08-06 (local only)

Ported from leo's "Documente pacient" card. Folded into 000046, which the port map always assigned all three F5 tables to (appointments + appointment_files + appointment_reviews).

A FOURTH PERMISSION CODE — appointments.manage_files — and the reason is worth keeping. The obvious move was to gate uploads on appointments.manage, matching how forms.manage gates form attachments. That would have been wrong: specialists hold forms.manage but deliberately NOT appointments.manage, which F5.1 reserved for the front desk as authority over anyone's booking. Since the clinician conducting a session is exactly who needs to file its scans, folding the two together would have locked the primary user out of the primary use. appointments.create was the other candidate and is worse — it means "book on a patient's behalf", so overloading it would make a clinic unable to grant one without the other. The new code's grant shape matches forms.manage, not appointments.manage. appointment_files_test.go asserts the split directly and fails if the two ever merge.

Two narrowings against leo, both deliberate. leo models files as a Strapi media field admitting images/files/videos/audios with a permanent public URL per file. The registered surface admits PDF/PNG/JPEG/WebP only (a consultation video is a different retention, cost and consent story, and nothing in the ported UI ever uploaded one), and the row stores an S3 key the client never sees — reads are 15-minute presigned URLs, closing the never-expiring-public-link failure G15 names.

Soft delete, and the S3 object survives it. This diverges from forms.DeleteFile, which removes the object, and the divergence is the point: a form attachment is an answer on an unsigned form, while these are medical documents under the retention floor. Destroying the bytes would make the soft delete a lie the first time anyone tried to undo one. Audited as a DELETE regardless — the action IS a deletion, and recording it as an update because the storage is soft would hide removals from every compliance query looking for them.

  • [x] appointment_files via 1A.8 — s3.SurfaceAppointmentFiles was already registered and unused; it was reserved for exactly this and is now consumed.
  • [x] Presigned reads (15 min) + ValidateOrgScope. Never a raw, unsigned, never-expiring media URL (G15). The object key is clinical_sensitive with no egress target at all in the classification registry, and never serialises.
  • [x] Four routes + OpenAPI + regenerated Go/TS clients + the Clinic "Documente" panel on the appointment detail page.
  • [x] 8 integration tests: the permission split, cross-tenant denial, the uploader-identity clause, soft-delete persistence, hard-delete-fails-closed, appointment scoping. Mutation-tested — removing the identity clause from the INSERT policy flips the relevant test to failing.
  • [x] No Portal surface yet. CLOSED 2026-08-06. The four /v1/me/appointments/{id}/files routes are mounted and the Portal renders them, so the RLS INSERT policy's patient-own branch is reachable for the first time — until then the only mount was org-scoped, which made that branch as unreachable as the specialist one on /status was. leo still has the gap this closes: its card says "documentele încărcate de pacient" while staff upload on the patient's behalf.

F5.3 Appointment Reviews

⚠️ NOT A PORT — verify the premise before building. Checked against the live system 2026-08-06: leo has no review, rating or feedback concept anywhere. restartix-leo-api has no such content type, and the only greps that hit are the word "generating". This is the same shape as the additional_offering_ids column that was struck from F5.1 — a spec table with no writer and no reader in the system being ported. Building it now would ship appointment_reviews with no surface that writes a rating, no notification category for the "alert on low rating", and no dashboard widget that reads one. Decide who writes it and what consumes the alert first.

DEFERRED 2026-08-06 — no target. The user's read: this is most likely specialist rating rather than appointment rating, and neither the origin nor the need is settled. Revisit when there is a surface that asks the patient and something that consumes the answer.

  • [ ] appointment_reviews (rating 1-5, alert on low rating, publishes event for org dashboard). Deferred — do not build without settling who writes it and what reads the alert.

F5.4 Public Booking API — ✅ BUILT 2026-08-05 (local only)

P5 forced a real fix, and it is the finding worth carrying. calendars_select's public branch requires current_human_is_patient_at, which a stranger satisfies never — so on the app pool the endpoint returns an empty page for every clinic and would have shipped looking like "this clinic has no availability". Moving the reads to the AdminPool makes the WHERE clause the only tenant boundary left, and that surfaced the actual bug: LoadRosterSchedules accepted orgID and never used it, leaning entirely on RLS. Harmless on the app pool, a cross-tenant read on the admin pool. It now filters explicitly.

The admin surface is reached only through Repository.Admin(), never a context flag. A WithValue is one edit away from silently disabling RLS somewhere nobody intended, and that failure looks exactly like success.

  • [x] GET /v1/public/availability — no auth, org from X-Organization-ID, per-IP rate-limited under public_resolve. Resolves by slug (what /programare/{slug} carries) or by id.
  • [x] POST /v1/public/bookings — guest booking, no account required. Its own tighter public_booking rate policy: reading availability is idempotent, creating an appointment fills a clinic's diary.
  • [x] GET /v1/public/booking-identity — mints the signed cookie. Its own endpoint because availability is cacheable, and Set-Cookie on a cacheable response is how one visitor ends up wearing another's identity.
  • [x] Public routes run under /v1/public/ on the AdminPool per P5. The response is a distinct type, not classification.Filter over the org-scoped one — the filter approach was the plan, but a hand-audited projection of a struct that carries specialist_ids is one forgotten line from leaking the rota. A type that has no such field cannot leak it, which is stronger than any filter.
  • [x] Server-signed booking client identity (internal/core/bookingclient) — opaque, HMAC-signed, HttpOnly, public: namespaced so a forged value cannot be shaped to look like staff and steal their hold. Closes leo's caller-supplied-client-id defect. 7 unit tests, all about forgery.
  • [x] S18 projection — identity + timezone only when the calendar has exactly one bookable specialist. Resolved from the roster, not the computed slots: a team of three with two on holiday must not read as a one-person calendar and disclose who is in. No specialist_id parameter either, or a caller could ask for each in turn and diff the answers.
  • [x] Every failure is 404 — unknown, another org's, unpublished, published-but-not-advertised. Distinguishing them enumerates a clinic's private booking channels.
  • [x] Guest identity: reuse before create, or a returning patient accumulates a profile per booking and their history splits across rows nothing rejoins. Never provisions an account — typing an address proves nothing about controlling it.
  • [x] Status is always booked on the guest path, and there the word carries its full meaning: patient_id is NULL and somebody rings back. An authenticated booking opens upcoming. The slot is occupied either way.
  • [x] Portal page at /programare/{slug}, server-rendered first fortnight, server-action submit (the HttpOnly cookie must round-trip through the server).
  • [x] 7 integration tests — leak tests before feature tests: pooled calendars name nobody (asserted on the serialised form), S18 names the only one, cross-tenant refused by both id and slug, unpublished/private invisible, and the whole thing works with no RLS session.
  • [x] Slot hold during form fill — BUILT 2026-08-05. The public page takes a hold on selection, heartbeats it every 20s, releases on deselect (with keepalive, so it survives navigation), and greys a slot the moment somebody else takes it. The SAME handler implementation the clinic's live mode uses — only the org source and the client identity are injected, because a second copy for the public path would be a second copy of the concurrency logic that keeps two patients off one slot.
    • A pre-existing leak, fixed here: Store.broadcast published the raw Hold, and Hold.ClientID carries a JSON tag (it must — the same struct is what goes into Redis, where ownership is checked). The REST projection strips it by hand with a comment explaining why; the SSE stream beside it sent it to every subscriber anyway. On the staff path that named which operator was mid-booking. Now redacted through a distinct wire type, which cannot regain the field by someone adding a line.
    • hold.confirmed must NOT decrement. A confirmed hold is not a hold that ended, it is a hold that became an appointment — decrementing re-offers a time somebody just booked. leo keeps a separate confirmed set for exactly this; so does the portal hook.
  • [x] CS confirmation flow — BUILT 2026-08-06. The callback queue plus the onboarding action that resolves it.
    • ?awaiting_onboarding=true on the appointments list — patient_id IS NULL, phase 1 of the two-phase booking identity, with its own partial index. Distinct from unassigned, and that is the whole reason it exists: a public booking IS assigned (the calendar's strategy picked a clinician), so it never appeared in that bucket and on the list was indistinguishable from one the front desk took itself.
    • POST …/appointments/{id}/link-patient creates this clinic's patients row for the portable profile and links it. IDEMPOTENT — two operators working the same queue is ordinary, not a conflict — and unaudited on the no-op path, because a second row saying "linked" about an already-linked booking is noise in the one trail that has to stay readable.
    • A deliberate staff action, never automatic. A stranger typing an email has proved nothing about controlling it, so a booking must not mint a clinic relationship on its own. The composite FK (patient_id, patient_profile_id) guarantees the linked patient IS the booked person, so it cannot attach to the wrong one.
    • Clinic UI: a "De contactat" / "To call back" bucket, and a panel on the detail showing the exact phone and email (A10) beside the confirm button.

F5.5 Video Consultations — 📋 SPECIFIED 2026-08-09, not built

RENAMED from "Daily.co Video Integration" (2026-08-09). The vendor is a swappable Cat A adapter; the feature is video consultations. Naming a Curated Provider in the feature title is the exact drift the glossary forbids — it is what made the schema below get designed twice, once Daily-shaped and once not. Nothing in the database, the domain, or the API path carries a vendor name.

SPLIT OUT 2026-08-06 as its own feature, after the rest of appointments. It is not appointment plumbing — it is a Cat A curated-provider integration with its own capability, catalog row, room lifecycle and security model, and it is gated on a signed BAA/DPA that is not a code artefact. Treating it as an F5 subsection made F5 look blocked on a contract.

It is NOT "add a video call SDK". The honest scope is a Cat A provider adapter + a Cat D inbound webhook endpoint + two new tables + metering with a reconcile job + three UI surfaces. The half that makes it a feature rather than a library swap is everything that happens around the call: who joined, for how long, did it happen at all, and what did it cost. leo has none of that (see below), and its absence is why a clinic today cannot answer "did this consultation take place".

The leo survey (2026-08-09) — what to carry and what must never be reproduced. The room lifecycle lives in Strapi (restartix-leo-api/src/utils/videocall.js, 96 lines: create / update-exp / delete, called from appointment-scheduling.js), the clinic UI is a resizable side panel beside the consultation record, and the patient page is /videocall/[uid]. Carry: the two-pane layout (the clinician writes the note while on the call), one button that both transitions status and opens the room, and the leave-with-intent dialog ("close the patient's session too?" — distinguishing "I was testing my camera" from "consultation over"). Do not carry, in any form:

  • /videocall/[uid] has no authentication. The middleware guards only /consultatii. The page passes the URL segment straight through as the room name.
  • createToken is a public server action that mints a meeting token for any room name, with a caller-supplied user id and display name, and no exp on the patient token. createRoom is exported from the same file, so an unauthenticated caller can also create rooms against the platform's account.
  • The room name is the appointment uid, which also appears in /consultatii/{uid} links and emails. The capability leaks through ordinary correspondence, and staff distribute it deliberately via a "copy room address" button.
  • eject_at_room_exp: false in both the API and the dashboard, so expiry never ends a call in progress.
  • Participants join as user id "0", name "Guest"nothing is attributable, which is the root cause of the observability gap.
  • NEXT_PUBLIC_QUICKPOSE_API_KEY ships in the browser bundle, and patient body images go browser → third party directly: no server record of the disclosure, no processor path, no audit, no consent check.
  • Every provider call is try/catch { console.log } returning undefined, so a failed room creation reaches the patient as "Camera de așteptare este închisă."

PORT THE CALL, NOT THE MEASUREMENT STATION. leo's clinic UI has two room modes: a prebuilt iframe, and a custom call-object mode that grabs a frame off the patient's video track and (a) uploads it with a posture-grid overlay into the patient's documents, (b) sends it to an external quickpose service which returns per-side joint angles for 19 measurements, written back as {key}_left / {key}_right form values. (a) is a photograph — a clinician taking a picture during a consultation is record-keeping and carries no regulatory weight. (b) is a goniometer, which CLAUDE.md already places outside the Class I telemetry scope as "likely require Class IIa certification". It is deferred behind the same gate as pose ingest — the Class IIa step, since the registered device declares no measuring function. Landing it piecemeal is how a platform acquires MDR scope without deciding to.

The custom call-object path is PARITY-CRITICAL, confirmed by the clinic 2026-08-09. Grid-posture capture is a tool in intensive daily use, not a built-and-forgotten mode — which settles two things at once. The prebuilt iframe cannot serve as v1 (a cross-origin iframe cannot be frame-captured), and the provider question closes on Daily, whose call object is the proven path for exactly this. It also means capture ships with F5.5 rather than after it, and that the storage question below is live from day one instead of at extension time.

Provider decision: Daily ships; Whereby gets its server half written and nothing else. Two facts settled it. (1) A US sub-processor under SCCs/DPF is the platform's established posture, not a new exception — chk_psp_capability_provider already whitelists auth → clerk, and AWS and Anthropic are the same shape; Whereby's EEA-residency edge is real but buys less than it appeared to before the integration's shape was known. (2) The custom-UI path is load-bearing (a prebuilt iframe is cross-origin and cannot be frame-captured), and Daily's daily-js call object is the one already proven against this exact use. The server abstraction is cheap and the client abstraction does not exist@daily-co/daily-js and @whereby.com/browser-sdk are different component trees, so a second production provider means a second call UI across clinic and portal. Writing the Whereby adapter (≈ a day) and round-tripping it against a scratch room is the cheap proof that the interface is not Daily-shaped, which is the real risk of a one-provider abstraction. Building the Whereby frontend waits for a reason to switch.

F5.5.1 The video.Provider capability (Cat A)

  • [ ] video.Provider interface in internal/core/domain/video/: EnsureRoom, MintJoinToken, DeleteRoom, VerifyWebhook, FetchUsage. Registered through providers.Register(resolver, "video", …)cmd/check-cata-resolution fails the build on any env-var bypass inside Cat A code.
  • [ ] platform_service_providers catalog row; credentials are auth_secret class. Extend the chk_psp_capability_provider whitelist with both ('video','daily') and ('video','whereby') — a whitelist that admits only the shipped provider is a whitelist that has to be migrated before the alternative can even be tested.
  • [ ] Room properties are set by us, not the clinic: privacy: private, max_participants: 2, screenshare + chat on, geo: eu-central-1, recording off. These are what make the GDPR posture defensible, which is the structural reason video is Cat A and not a clinic-configured Cat B connection.
  • [ ] Whereby adapter: server half only, verified against a scratch room. No Whereby frontend, no second call UI.
  • [ ] Signed BAA / DPA before it carries real consultations, and the provider added to the platform's sub-processor list with clinics notified as controllers. Not a code artefact; it gates the feature regardless of how finished the code is.

F5.5.2 Room identity and access — the G14 fix

  • [ ] Room identity derives from a server-side secret (HMAC of the appointment id + secret), is never URL-visible, and is never emailed. The provider's join URL is derived at token-mint time and never stored — a stored URL is both vendor-shaped and a capability at rest.
  • [ ] POST /v1/organizations/{id}/appointments/{id}/video-token — authenticated principal, permission-gated, RequireURLOrgMatchesScope per P47. Resolves the room server-side; the client never learns a room name and never talks to the provider's REST API. The patient reaches the same endpoint on their own session.
  • [ ] Tokens carry a real exp bounded by the appointment window, and the room carries nbf so it is not joinable before (start − lead). eject_at_room_exp: true.
  • [ ] The participant reference sent to the provider is opaque — a principal-scoped id, never a name or email. Attribution has to work without putting patient identity into a third party's event log.
  • [ ] Audit: room create/delete, and every token mint. A token mint is disclosure of access to a live medical consultation, which is the admission test audit.ActionRead's doc comment sets.

F5.5.3 Provider-agnostic schema — one migration (000049 today; whichever is next-free when it lands)

The agnosticism rule, stated once so it is checkable: no vendor string ever reaches a column, and no vendor concept ever becomes a column. The adapter is the translation boundary. If a provider's own event name or URL shape gets stored, swapping providers stops being a factory change and becomes a data migration — and the abstraction was decorative all along.

  • [ ] video_rooms — STATE, flat, never partitioned (P41). organization_id NOT NULL, appointment_id, provider TEXT, provider_room_id TEXT, room_ref (our opaque name), not_before_at, expires_at, status, timestamps, created_by_principal_id. RLS scoped like every tenant table. No room_url, no daily_*, no whereby_*.
  • [ ] video_session_events — EVENTS, append-only, range-partitioned monthly (P41). One row per provider webhook: occurred_at, event_type, provider_event_id (dedup), participant ref, payload JSONB.
  • [ ] event_type is OUR vocabulary, normalised at the adapter boundary: session_started, participant_joined, participant_left, session_ended. Daily's meeting.* and Whereby's room.session.* / room.client.* are translated on the way in and never stored raw. The raw body stays in payload for forensics only — variable-class in the classification registry, read by nothing.
  • [ ] Record that the two providers do not mean the same thing by "ended". Daily ends a meeting when the last participant leaves (≈20s grace for reconnects); Whereby ends a session when fewer than two people are in the room for a minute. A duration derived from session_ended is therefore not comparable across providers, which is a reason to compute minutes from join/leave pairs rather than from session boundaries.
  • [ ] appointment_files.kindpatient_upload / staff_upload / clinical_capture, NOT NULL. Same migration, and the reason it belongs here rather than in the extension is in F5.5.8: it classifies rows, so it has to exist before the rows.
  • [ ] Cat D inbound webhook at /webhooks/{provider} — the framework has three precedents (Bunny Stream, WooCommerce, MerchantPro), per-provider signature verification, per-IP rate limit, inboundwebhooks/dedup keyed on provider_event_id.
  • [ ] Classification-registry entries for every new column, same PR. SOUP rows for the client SDK.

F5.5.4 Room lifecycle

  • [ ] Create on the existing booked/upcoming → inprogress transition — which already runs the F5.6 consent gate, so telemedicine is checked before a room exists rather than after. Delete on done / cancelled; recreate on reinstatement.
  • [ ] Idempotent throughout: a 404 on delete is success, a second create returns the existing room. Two operators working one appointment is ordinary.
  • [ ] A failed room creation must surface as a failed room creation. leo's swallowed errors reach the patient as "the waiting room is closed", which is indistinguishable from "the clinician has not started yet" and generates a support call instead of a retry.

F5.5.5 Metering — two meters, deliberately

Cost and billing are different questions and must not share a number. What the provider invoices the platform is actual participant-minutes, including a patient who joined an hour early. What the clinic's quota counts is a commercial choice. Recording one number and using it for both means either the platform under-recovers or the clinic is billed for a patient's early arrival.

  • [x] Cost meter — video.participant_minutes. BUILT. Actual minutes, computed from participant_joined / participant_left pairs in video_session_events. Source of truth for what the platform spends. Nothing else may supply it.
  • [x] Billable meter — video.billable_minutes. BUILT, recorded only when an appointment reaches done — a cancellation is not a call and a no-show is a room nobody joined. Derived from completed appointments × scheduled duration × participants. Predictable for the clinic, independent of provider quirks, and immune to the early-join problem. It will undercount against the cost meter; that gap is the point of keeping both rows — it is visible in usage_records instead of invented at invoice time.
  • [ ] Bound the early-join exposure at the source, not in the accounting. not_before_at on the room means a patient cannot sit in it for an hour, so the two meters stay close. Accounting around a problem the room configuration can prevent is the wrong end.
  • [ ] One limit_definitions row: video_participant_minutes_per_month, unit minutes (already permitted by chk_limit_def_unit — the schema anticipated this), per_calendar_month, soft_meter. Not hard_block: refusing to open a room because a monthly quota tripped means refusing a consultation a patient physically showed up for. Meter it, alert on it, invoice it — do not deny care over it.
  • [x] CORRECTED WHILE BUILDING (2026-08-09): Record once at session_ended, NOT BeginReservationSettle. The reservation design was specified above and is wrong for this capability, for two reasons that only appear once the pieces are real. A reservation is an in-memory handle and the two ends of a call are in different processes — a clinician's request opens the room, a provider's webhook ends it half an hour later, and nothing carries the handle across that. Worse, a closing webhook that never arrives leaves the reservation neither settled nor cancelled, so the org's quota stays inflated until the period rolls: a lost delivery becoming a permanent overcharge. And it would buy nothing anyway, because soft_meter does not block — reserving protects a limit that never refuses. So minutes are recorded once, when the true number first exists, and the monthly reconcile is what corrects for events that never came.
  • [ ] Monthly reconcile job against the provider's usage API (Daily /meetings, Whereby Insights), writing an adjustment record. Webhooks are lossy — reconnects, ghost participants, delivery gaps. Never invoice a clinic off our own counter unreconciled.
  • [ ] middleware.EnforceLimit is a known no-op (Subject.Limits is a stub nothing populates, so every plan cap silently passes). Do not hang the video meter off the route layer — use metering.Reserve / BeginReservation at the service layer, which is the path that actually works.

F5.5.6 Observability — the half leo never had

  • [ ] Live counts are derived, never stored — same rule as F9.4 and F16. "Rooms live now" is a query over video_session_events (started, no ended), not a counter column that drifts the first time a webhook is missed.
  • [ ] Console system-health panel: rooms created, live now, failed creations, webhook lag, minutes this period. Sits beside the existing media-failures and errors pages.
  • [ ] Clinic, on the appointment detail: "call took place · 34 min · 2 participants." The clinically useful one — and the record that settles a no-show dispute, which today is one person's word.
  • [ ] Whatever a specialist sees about the call is descriptive of the session, never of the patient's body. The moment a number derived from the video is presented as clinical information, F5.5 is standing where F10's ingest half is, behind the same counsel gate.

F5.5.7 UI

  • [x] Clinic: the two-pane side panel — BUILT 2026-08-09. /appointments/[id]/live, its OWN route group with its own layout, because the sidebar has to go: a clinician on a call needs the screen, and every item in it is a navigation that would tear the call down. Removing it is the layout and the safety rail at once. Record and call in react-resizable-panels; the record pane is the SAME server component the appointment page renders (AppointmentDetailView, layout="live"), not a reduced copy that would be a second place to add the next panel to.
  • [x] Links inside the record pane open in a NEW TAB. The pane carries the whole record, which contains links — a past appointment, an unsigned form, a document. Following one unmounts the route and ends the consultation, and nobody clicking "see the intake form" is asking to hang up. Intercepted in the shell rather than changed in the panels: it is a rule about this view, not about those links.
  • [x] One button, split by CAPABILITY rather than role. leo's single button starts the consultation and opens the room, which is right for a clinician and wrong for everyone else here — customer support opens the room days ahead for the pre-call check, and if that transitioned the appointment it would start the consultation, and run the consent gate, a week early. appointments.record_fields is what the conducting clinician holds and the front desk does not, so for them the button reads "start consultation" and does both; for anyone else it opens the room. Role codes are per-org and renameable, which is why the split is not on one.
  • [x] Leaving the call does NOT end the consultation. It drops that person's connection; the room stays open, the patient stays in it, and there is a rejoin. Ending it is marking it done, on the record beside the call — the one act that ejects a patient stays deliberate.
  • [ ] Portal: the patient's call surface, reached from the appointment (F5.7), never from a bare link. P46 — client-rendered live bit inside a server shell.
  • [ ] Grid-posture capture — IN SCOPE for v1 (the photograph, not the goniometer). Custom call-object mode: single patient tile, grid overlay with theme + opacity, frame grab off the participant's video track, composite onto a canvas, store as a clinical capture. Carry leo's controls; do not carry its storage.
  • [ ] Switching between prebuilt and capture mode must not drop the call. leo destroys the call object and rejoins, which the patient experiences as the clinician disconnecting mid-consultation. One call object with a swappable presentation layer is the design; if the provider genuinely cannot do it, the capture UI becomes the only mode rather than the call being re-established.
  • [ ] appointment_files.kind — the one column that cannot wait for the extension. See F5.5.8.

F5.5.8 Clinical capture — the extension seam (documented, NOT built this session)

The clinic wants more than a photograph: the computer-vision measurement stack that already exists gets integrated, and there will be more tools than the two that exist today. None of it is built here. What F5.5 owes the extension is a seam it can land on — and one column that genuinely cannot be retrofitted.

leo's storage is a workaround and is explicitly rejected as a source. Measurements are written into form_values as {key}_left / {key}_right — a number stored as text in an answer store, under a private key namespace invented by string suffix. Images are attached through Strapi's polymorphic file field to whichever row was handy (report, or a form-value created to hold it), and the only thing distinguishing a clinical capture from a patient's insurance scan is the filename (grid-posture-2026-08-09.jpg). A filename convention is not a data model. Every question worth asking — show me this patient's knee flexion over the last year, which image produced this number, was this measured or self-reported — is unanswerable without parsing strings.

A measurement is an F16 measure point, and that is the whole design constraint. F16 defines a measure as (patient, measure_key, value, observed_at, source) and a series as every point for one patient and one key. A goniometry angle is exactly that shape, so capture becomes F16's fourth source, alongside session_pain_events, session_runs and forms.values. This does not violate F16's "derived at read time, never stored" rule — that rule forbids a rollup table, not a source table (session_pain_events is itself a source table). What it does require is that measure_key lives in the same key space as custom_fields.key, so a knee flexion the clinician measured and a knee flexion a form asked about land on one series instead of two that never meet.

THE LINE MOVED, 2026-08-09. This subsection originally deferred all of itself behind the counsel gate, and only two-thirds of it belongs there. Grid-posture capture is parity-critical and ships in v1, so the capture act is real in v1 — deferring the row that describes it would mean v1 shipping the leo shape, a file whose only description is its filename. The test that sorts the three pieces is retrofit cost, not regulatory weight: kind is unrecoverable once rows exist, clinical_captures is real now, and capture_measurements is a child table with nothing to backfill and an MDR gate. Only the last two lines below are still deferred.

  • [x] appointment_files.kindpatient_upload / staff_upload / clinical_capture, NOT NULL, no default. BUILT in 000049 + an in-place edit of 000046 (the column belongs in that CREATE TABLE), with infra/scripts/000046-appointment-file-kind.sql as the catch-up for databases that already passed 46. The P36 reservation rule in its column-level form: a column that classifies rows must exist before the rows do. Once several hundred capture JPEGs sit in appointment_files indistinguishable from insurance scans, no migration can separate them and the fallback is parsing filenames — precisely the leo state being rejected. NOT NULL with no default is what forced the two upload mounts to become two handlers, so the route→kind mapping is visible where a new mount would be added rather than inferred from a join.
  • [x] clinical_captures — BUILT in 000049. One row per capture act: appointment_id, the appointment_files reference (UNIQUE — one capture, one source frame), tool (whitelisted, grows by migration) + tool_version NOT NULL, settings JSONB, captured_by_principal_id, captured_at. Tool-agnostic with a discriminator, never a grid_posture_captures table. Versioning is not optional: an artefact is interpretable only against the thing that produced it, and "the overlay geometry changed in March" cannot be answered retroactively.
    • video_room_id is NULLABLE, and that is the design. leo can only capture inside the video room because that is where it built the feature. The platform serves physical-only clinics, and a clinician assessing posture with the patient in front of them wants the same grid against a local camera — the capture is bound to the appointment, not to the call, so that becomes a UI addition rather than a migration.
    • Writes reuse appointments.manage_files rather than minting a code — a capture produces an attachment, the audience is identical, and "may attach documents but not documents you took yourself" is not a distinction any clinic would draw. No UPDATE or DELETE policy: rewriting a capture's settings or tool version after the fact would let the record be edited to match a conclusion. No deleted_at either — the artefact is the file, and two independent delete states for one thing is how a row stays visible while its evidence is hidden.
  • [ ] DEFERRED — capture_measurements, a child of clinical_captures: measure_key, side as its own column (left / right / bilateral — never a key suffix), value NUMERIC, unit NOT NULL. The unit requirement is where F16's recorded scale-mixing trap becomes real: degrees are a unit, custom_fields has no unit column, and a form asking "knee flexion" as free text carries none. The measured side declares its unit or the series is uninterpretable.
  • [ ] LATER, AND GATED — pose landmark JSON. Storing landmarks server-side is GDPR Art. 9 biometric processing and is the same substance as F10's excluded ingest half, reached by a different door. Same gate: the Class IIa step (F11.0.5 answered the class question — registered Class I, no declared measuring function). When it does ship it is an S3 blob referenced by the capture, never a queryable JSONB column — the moment landmarks are queryable someone builds an analysis on them, and that is the step that moves the device class without anyone deciding to.
  • [ ] LATER — the CV service is a Cat A capability (vision.Analyzer), resolved through the same providers framework as video. The frame travels browser → our API → provider, never browser → provider: leo ships NEXT_PUBLIC_QUICKPOSE_API_KEY in the bundle and posts patient body images straight out, so there is no server record of the disclosure, no audit row, and no consent check. Routing it through the API is what makes all three exist.
  • [ ] Consent is already designedbiometric_capture is a seeded purpose (000044) gating the Portal's client-side skeleton preview. A clinic-side measurement is the same purpose at a second consumption point, so this needs a gate call, not a new consent instrument. The photograph is not biometric processing and must not be gated on it — demanding biometric consent to take a picture blocks a tool the clinic uses daily for a branch that is not running.

F5.5 — BUILT 2026-08-09 (local only), and what the clinic changed

Everything below was built and browser-tested against a real Daily account on 2026-08-09. Nothing is promoted — prod is at 000039, staging at 000038, and 000049 has been edited in place three times under the pre-prod convention with catch-up scripts in infra/scripts/.

Built: the substrate (000049), the Cat A seam + Daily adapter, room_ref derivation, the room lifecycle service, the join endpoint, the Cat D webhook, both meters, the reconcile job (cmd/video-usage-reconcile), the webhook registrar (cmd/video-webhook-register), the clinic and portal join surfaces with a shared CallStage, the staff-only note log, and the activity timeline.

Six things the clinic's own workflow changed, each reversing something specified above:

  • Rooms open ON DEMAND, not on → inprogress. Gating creation on the transition made three ordinary things impossible: the pre-call check, a patient arriving early, and retrying a room that failed to open. The consent gate did NOT move — it still runs on the transition and still governs when treatment may be declared. It has no opinion about a room existing, and conflating the two is what made the room late.

  • customer_support HOLDS appointments.join_video. The seeded grant deliberately excluded it, reasoning that a receptionist in a consultation is a privacy event. The clinic's real workflow refutes that: CS rings the patient before a remote consultation to confirm they can see and hear, which means both of them in the room, often days ahead. Withholding the code does not prevent a privacy event, it prevents the check — and a clinic that cannot do it in the product does it over WhatsApp.

  • not_before_at bounds the PATIENT ONLY, and gates CREATING a room rather than entering one. A patient may enter a room that exists at any time, because a room existing days early exists because the clinic opened it — the room being open IS the patient's invitation, needing no second mechanism. Staff are unbounded.

  • No nbf is sent to the provider. Both were enforcing a not-before and only one can; the platform's rule is asymmetric and a provider enforces its own against everyone, so sending it silently overrode the asymmetry and refused staff after our own service had allowed them.

  • DeleteRoom ejects before deleting. Most patients pocket the phone rather than pressing leave, so a call outliving the consultation is the normal case. Moving the room's expiry to now is a documented eject (rooms carry eject_at_room_exp: true); deleting alone is not documented to remove anybody.

  • Cancellation closes the room too. It is routed through Cancel() rather than Transition(), so the teardown wired into the latter never ran — a cancelled consultation kept a live room a patient could walk back into.

  • A session ending does NOT close the room (seventh reversal, 2026-08-09). The provider fires meeting.ended the moment the LAST participant leaves — so a room is "ended" every time it is briefly empty. A clinician closing their tab for two minutes while the patient is late emptied the room, and they returned to a new room with a new name while a patient whose client auto-reconnected was still dialling the old one: both alone, each sure the other had not turned up. A room now outlives its sessions and closes only when the consultation does — marked done, cancelled, or swept. Creating on demand was never the complexity; closing on empty was. Creating the room at booking instead was considered and rejected: it would couple booking to the provider being up, make every reschedule an update-or-recreate, and destroy the patient gate above, since "the room exists" would stop meaning anything.

Metering, corrected TWICE. First: Record once per finished call, NOT BeginReservationSettle. A reservation is an in-memory handle and the two ends of a call are in different processes; worse, a lost closing webhook leaves it neither settled nor cancelled, so a lost delivery becomes a permanent overcharge. It buys nothing anyway, because soft_meter does not block. Second, when sessions stopped closing rooms: metering moved from session_ended to the close. It had been guarded by the room still being openable, and without the close that guard was gone — SummariseRoomAdmin sums the whole room, so a second sitting would have re-metered the first one's minutes on top of its own. At the close it counts every span exactly once whether the call was one sitting or five.

F5.5.9 Appointment activity + staff notes — BUILT 2026-08-09 (local only)

The timeline is DERIVED, not stored. audit_log already records everything that happens to an entity and is already indexed for it (idx_audit_org_entity_time). A second history written beside it would be guaranteed to disagree with the trail that has to be right.

It is NOT "the audit log, filtered". Only admin holds audit_log.view_org. "The clinic's audit log" and "what happened to this consultation" are different questions with different audiences, so this is a narrow second door — gated on appointments.view_org, scoped to one entity, allow-listed projection, admin-pool read.

internal/core/activity is GENERAL. Keyed on (entity_type, entity_id); appointments are simply the first registered Vocabulary. Sessions or programs later are a registration, not a subsystem. What is per-entity is small and deliberate: which extra sources merge in, and how each row is phrased.

  • [x] appointment_noteskind is tech_check (with an outcome of ok/issues/unreachable) or note. STAFF-ONLY: no patient RLS branch, deliberately, because a note written for a colleague reads differently from one written knowing the patient is looking. A LOG, not leo's single field, because the clinic rings again — a single field keeps only the successful attempt and loses the history that explains a difficult consultation. Staff-only is a visibility rule, not a DSAR exemption; which notes an export carries is an F11 decision.
  • [x] video_room_participants — maps the opaque provider-visible ref back to a principal, recorded when a token is minted. It cannot be derived: the ref is a one-way HMAC precisely so the provider cannot correlate a patient across their care, so nothing can invert it. Without this every join and leave reads "Someone".
  • [x] THE MAPPING IS WRITTEN ON THE CALLER'S CONNECTION AND READ ON THE ADMIN POOL — the opposite of the obvious arrangement, and it was wrong in both directions on the first attempt. Each failed silently, each produced "Someone", and each had a different cause. The read ran through the app pool, where the webhook has no tenant session; the SELECT policy is scoped to current_app_org_id(), so RLS returned zero rows and no error while the mapping sat in the table correctly written. The write ran on the admin pool, but a scoped request lives inside an RLS transaction that commits when the handler returns — so a join that OPENS a room was inserting a mapping whose foreign key pointed at a room no other connection could see yet. That failed for the FIRST person into every room and nobody else, was logged and swallowed, and the token was minted anyway. Since the opener is almost always the clinician, every consultation named the patient correctly and called the clinician "Someone". Both are pinned by integration tests with real policies — a unit test cannot see either.
  • [x] A vocabulary claims several audit entity types. A consultation's history includes video_join rows written against its own id under another name; filtering on the entity's own name silently dropped every one.
  • [x] SEVEN entity types, because a consultation's history is not held under one key (widened 2026-08-09 after the first version showed a real consultation as three lines). An audit row is keyed by the id of the thing that changed — correctly: a document's history belongs to the document. But that means the appointment's own id covers only mutations of the appointment ROW, so the files filed against it, the medical documents generated from it, the forms the patient signed and the consents those forms granted were all invisible. Vocabulary.RelatedIDs collects those ids in ONE UNION ALL (every arm an existing index) and the same audit query then covers them all. Reading the domain tables directly instead was rejected: a table holds current state, so a removed file and a document published-withdrawn-republished would be one row rather than the three lines somebody opens a history to see. custom_field_value needs no collecting — the clinic record IS keyed by the entity it describes.
  • [x] Describe receives BOTH sides of the change. audit_log.changes has held before (narrowed to changed fields) since 000001 and the reader was throwing it away, so every line said that something changed and never what to what: "moved it to another time" without the times, "assigned a specialist" without the specialist. First-assignment and handover are now different lines — a patient asking "why am I seeing someone else" is asking about the second.
  • [x] Ids in the trail, names on the screen. A stored name is wrong the day somebody is renamed, so Describe emits raw ids and Vocabulary.Resolve batch-fills specialist names and record-field labels once over the assembled timeline. It writes the id in first and overwrites it — next-intl throws on a missing placeholder, so an unresolvable value must degrade to something ugly rather than to a blank page.
  • [x] Field changes are ALLOW-LISTED, and the clinic record names FIELDS ONLY. custom_field_value audit rows carry the values themselves — that is what answers "what did this say before" years later — but this panel is gated on appointments.view_org, a wider audience than the record itself, and a field a clinic put on a record is by definition clinical. So the line is "updated the record: Diagnosis, Pain level" and anyone who needs the values opens the record. Everything unrecognised still collapses to a bare "updated": a reader learns somebody touched the row and when, without the panel leaking column names.
  • [x] Notes come through Extra, not through their audit rows, and appointment_note is deliberately absent from the audit types. A note's audit row records that one was written; the trail cannot hold the sentence, because a sentence is not a mutation. A line reading "somebody wrote a note" would send the reader to another panel — defeating the reason both were asked for in one place: "we rang, no answer" sitting immediately above "patient rescheduled" is what explains the consultation. The notes panel stays — the front desk working a callback queue wants notes alone, newest first.
  • [x] A failing source costs its own lines, never the page. RelatedIDs, Resolve and each Extra source degrade independently: the appointment's own history is load-bearing and must not vanish because a video provider is unreachable or a documents table is unreadable. RLS does the rest — a caller who cannot see consents gets no consent lines rather than an error.
  • [x] summary is a translation key, not prose, with underscores rather than dots (next-intl reserves . for nesting). Identical strings on both sides — a rewrite between them would be somewhere they can drift.
  • [x] Names resolve server-side, from TWO places. humans.name is staff; a patient's is the portable patient_profiles.name (P6). Reading only humans rendered every patient as "Someone". Soft-deleted principals are included on purpose (check-softdelete:allow) — a colleague leaving does not un-happen what they did.
  • [x] Joins and leaves come from the call's events, per participant. Leaving is not a mutation, so the audit trail has nothing to say about it. Per-participant rather than aggregate, because a patient who drops at two minutes and rejoins averages out to full attendance in a summary.

F5.5.10 Live presence + the record's own reconcile — BUILT 2026-08-09 (local only)

The clinic asked for this INSTEAD of the portal live-update, and the reasoning retires that item rather than deferring it: the patient always joins first and waits, so the person who needs to be told something changed is the clinician, not them. The portal's page stays a page load.

  • [x] Who is in the room, on the appointment card, for the staff who are NOT in the call. A clinician inside a consultation can see who else is there; the front desk asking "has the patient turned up" and the clinician about to join asking "how long have they been waiting" cannot. Gated on appointments.view_org rather than join_video — requiring the permission to ENTER a consultation in order to see whether one is happening would withhold it from exactly the people it is for.
  • [x] OUR OWN EVENT STREAM LEADS, AND THAT WAS MEASURED — the opposite of how it was designed. It was built with the provider as the authority, on the reasoning that it observes presence directly while we only hear about it. A real call disagreed: our events recorded a patient joining at 19:47:35.88 and leaving at 19:47:39.27, while Daily's /presence, sampled at 19:47:52, still listed them as present with "duration 16s". The provider's presence endpoint lags on departures by tens of seconds; the participant.left webhook is immediate. The panel was showing a patient waiting who had already gone — the error that matters, because a clinician who believes somebody is waiting joins an empty room.
  • [x] Each source is wrong in its own direction, so the disagreement is what is displayed. The provider over-reports for a window after somebody leaves; ours is instant but depends on delivery, so a LOST webhook leaves somebody present indefinitely. The card shows our roster and adds one line only when the provider has somebody we do not — a join we never heard about. The reverse direction is the departure lag and would fire on every call, so it is dropped: a permanent second roster was noise, a line that appears on disagreement is a signal.
  • [x] SSE carries a NUDGE, never the news. A new appointment:<uuid> channel — not the existing holds:<org>:<calendar>, which carries every other patient's bookings on it. The event names the appointment and nothing else; the subscriber re-reads a permission-gated endpoint that resolves names on the caller's own session. Redis pub/sub is not somewhere this platform has ever put a patient's name (the hold broadcast strips even its client id), and the saving would be one small request on an event that fires a handful of times per consultation.
  • [x] Both durations tick locally. "Waiting 8 min", "occupied for 20 min" and the close countdown are computed from values fetched once. Occupancy is measured from the earliest join among those CURRENTLY present, so it reads as the length of this sitting and resets when the room empties — the room's own age would report a four-day consultation for a pre-call check opened last week.
  • [x] FetchRoomSpans repairs the record from the provider, in the sweep. The answer to "our copy can drift": every join and leave arrived by webhook, and the MINUTES had a monthly reconcile against the provider's accounting from day one while the RECORD had nothing. The same /meetings resource returns per-participant user_id + join_time + duration — which the usage decoder was reading and discarding. The sweep calls it just before closing each room, the one moment the provider's record is final and the room is already in hand. Additive only and marked: repaired rows carry {"source":"reconcile"} and an id derived from the span, so a gap repaired twice is repaired once and nothing downstream mistakes a repair for a delivery. events_repaired is logged every run — usually zero, and a number that suddenly is not is the earliest signal that delivery has degraded.
  • [x] The room name is checked before the provider is asked. On an account shared with the legacy system's production, IsRoomRef is the only thing between a span read and another product's calls.

F5.5 — still open

  • A patient may now enter a room from the moment staff opened it, which for a pre-call check can be days ahead — where before, the room closed when the check hung up and they were held to the 15-minute lead. This follows from the ratified rule that the room existing IS the patient's invitation, but that rule was written when a room's life was one sitting. The exposure is metering, not privacy: a patient who joins and wanders off idles alone in a room and meters participant-minutes against the clinic (~240 for four hours, against 10,000 included per month). Mitigations if it turns out to matter: gate entry on staff actually being present (derivable from the event stream, but then it depends on webhook liveness — which can die silently), or give the invitation its own expiry. Deliberately NOT decided yet.
  • Grid-posture capture (F5.5.8) is unbuilt. appointment_files.kind and clinical_captures are in place; nothing writes a capture yet.
  • The Daily account is leo's production account — 13,228 meetings, real patient names visible in /meetings. A separate account for the platform is strongly recommended before real clinics onboard: the invoice cannot be split, our webhook receives leo's production events (discarded only by the IsRoomRef filter), and rotating the key affects the live system.
  • TestPublicAvailability_WorksWithoutAnRLSSession fails on Sundays. publicWindow() asks for the next Monday, which on a Sunday is inside the fixture calendar's 24-hour min_lead_time_minutes. Pre-existing, unrelated to F5.5, and a nasty property for CI.

F5.5 — infra and ops, NONE of it built (blocks promotion, not local work)

grep -i video infra/ matches nothing. Every item below has to land before 000049 reaches an environment, because each one fails silently — a consultation that looks fine and leaves no record.

The full file-and-line plan, including three defects found while working it out, lives in video-infrastructure-plan.md. The summary below stays here; that document is what to work from.

  • Partitions need no Terraform. video_session_events is registered in partitionroll.rollers, so the existing daily api-partition-roll cron (-ahead=3, both envs) rolls it, and partitions.EnsureMonthly applies the same per-leaf REVOKE ALL … FROM restartix_app the migration hand-wrote for _2026_08. Nothing to add — recorded here so nobody adds a second roller.
  • BUT the promotion-day gap is real, and it is not video-specific. 000049 seeds only its authoring month per the minimal-seed pattern. Applied in any later month, the current month's partition does not exist until 02:00 the next morning, and there is no DEFAULT partition by design — so every inbound call event fails its INSERT for up to a day. Ordinary domains surface that as a user seeing an error; here the rows come from a provider webhook, so it surfaces as deliveries we answer non-2xx, a provider that retries for a while and then stops, and a permanently empty timeline for those consultations. The deploy workflow does not roll partitions after migrating. Fix: run the api-partition-roll task once immediately after migrate, as a promotion-runbook step — it is idempotent, and it covers every future new partitioned table rather than just this one.
  • Three secrets, two different shapes. VIDEO_ROOM_SECRET is ours and never leaves the process — same shape as handoff_reentry_secret, its own generated aws_secretsmanager_secret. Empty disables video outright rather than falling back, deliberately: a room name derived from an empty secret is derived from the appointment alone, which is precisely the legacy defect arriving unannounced. DAILY_API_KEY + DAILY_WEBHOOK_SECRET are bootstrap-only, like clerk_bootstrap / email_bootstrap — read once to seed the platform_service_providers row, after which the row is canonical and rotation goes through Console rather than a redeploy. DAILY_GEO defaults in code; wire it into the task-def anyway, because "where does the consultation's media land" is the first question a clinic's DPO asks and a default buried in Go source cannot answer it.
  • video-usage-reconcile is not scheduled. It exists and is additive-only, exiting non-zero on an overcount; it needs an EventBridge entry plus the success_pattern alarm every other cron carries. Unscheduled, the meter drifts quietly downward and nothing says so.
  • cmd/video-webhook-register is a one-shot per environment, not a cron. It refuses non-https and needs the env's real API hostname. If it is missed, the platform still opens rooms and consultations still run — and the entire event stream, the minutes meter and the per-participant timeline are simply never populated. This is the single easiest item to forget and the most expensive to notice late.
  • A registered webhook can go FAILED on its own, and nothing on our side notices. Daily circuit-breaks after repeated delivery failures and then stops sending EVERYTHING; the record says state: FAILED, retryType: circuit-breaker. Observed in dev on 2026-08-09 after the tunnel dropped mid-session: rooms opened, patients connected, calls ran, and the timeline showed the join grants (audit rows, ours) with not one join, leave or end (webhook events, theirs) — a difference invisible unless you know the two halves come from different places. Re-running the register command re-arms it, and it now prints the state before and after and exits non-zero if the update leaves it non-ACTIVE. What is still missing is an alarm: production has no check that the provider still considers our endpoint healthy.
  • video-room-sweep needs a cron (built 2026-08-09; every 15 minutes, same cadence as the no-show sweep). It is the ONLY unconditional closer now that sessions no longer close rooms, and it is what deletes rooms at the provider — expiry does not reclaim them, so without it the account's room count only ever grows. It exits non-zero when a room was found and not closed.

No ALB or WAF change: /v1/webhooks/daily sits on the api service behind the existing listener.

F5.5 — open decisions

  • Recording is not in v1. The video_recording consent purpose exists with no consumer, which is correct per F5.6 — a gate that demands consent for a branch that never runs blocks care for nothing. When recording does ship, the storage target is the open question: Daily documents recording straight into our own S3 (never touching theirs); Whereby's own-bucket support is unconfirmed.
  • Which meter the clinic's plan actually charges on — the billable meter is specified as scheduled-duration-derived above, but whether a plan prices on it, on actual minutes, or on appointment count is an F12 question, not settled here.
  • Per-org provider override ("bring your own video account"). The mechanism exists — platform_service_providers supports one override row per (capability, org) — but it moves the DPA to the clinic, so it stays Console-provisioned and unbuilt until a clinic asks.

The gate is on booked → inprogress, NOT on booking, and that placement is the design. Booking is not treatment: a front desk must be able to write down that someone is coming on Tuesday, and refusing that until the paperwork is signed would mean a clinic cannot record its own diary. leo does not gate booking either. Starting is treatment — where care is delivered, the video room opens, the session begins — so the check sits where the processing it protects begins. The guarded no-show correction passes the same gate: an appointment does not become exempt by having been marked absent first.

TWO GATES, because there are two legal instruments. The ledger gate is GDPR processing consent: withdrawable at any moment, read by code before it acts. The document gate is the signed form: nothing branches on it, and it cannot be withdrawn after the procedure happened. A clinic's own treatment consents are the second kind — which is what the struck treatment_specific_* entry in F3.5.1 is about.

RequireConsent had zero callers before this. F3.5.4 predicted F5.6 would be its first real consumer, and it was — though the gate is a service-layer check rather than the route middleware, because the required purpose depends on the appointment's channel and RequireConsent takes a static code.

  • [x] Global org consents (Terms, GDPR) — already enforced, no new work. RequireCurrentConsents is mounted and blocks any request until the patient holds current versions. Not appointment-specific; it just has to have happened.

  • [x] Aggregate forms from offering + calendar (offering_formscalendar_forms, deduplicated) — F3.4's deferred half, closed here. Dedupe is BY TEMPLATE: a clinic that attaches its disclaimer to both means one document, and generating it twice would ask for two signatures and write two ledger rows for one act. Slot order is declared rather than alphabetical, or advice renders ahead of the disclaimer that gates the appointment.

  • [x] Forms materialize AT BOOKING, in the booking transaction, and a failure returns rather than logging — the opposite call from the hold consume beside it, because the failure modes are opposite. A lost hold costs a slot for 30s; a lost form set costs the appointment its gate, because there is then no unsigned form to find.

  • [x] Block until required forms signed. "Required" is required_signature_mode IS NOT NULL, already snapshotted, so a template edited after booking cannot retroactively add a requirement. Pending forms count — the snapshot is taken at first write, so excluding them would let an appointment start by virtue of the patient never having opened the consent.

  • [x] telemedicine gates online_live only. video_recording (F5.5) and biometric_capture (F9) gate at their own consumption points — demanding recording consent to start an appointment that is never recorded blocks care for a branch that never runs.

  • [x] Readiness() and checkGate() are ONE evaluation with two callers, surfaced on the appointment detail so the clinician sees what is outstanding before pressing start. Two implementations of one rule would drift into a UI that says ready and a 409 on the button.

  • [x] Nil dependencies REFUSE rather than degrade. A consent gate that silently disables itself when misconfigured is worse than no gate, because the clinic believes it has one.

  • [ ] Surface the DORMANT gate to an admin — not on the appointment page. (Raised 2026-08-20 from testing; placement settled the same day.) The telemedicine gate arms on the clinic having published the agreement, which is right — a clinic that does no telemedicine must not be blocked by a consent its patients cannot give. The cost is that "consent satisfied" and "consent never asked for" look identical: journey-panel renders only when !can_start, so an Online appointment at a clinic with no published instrument shows nothing at all. The failure mode is a clinic running remote consultations for months believing a consent is in force when none was ever written — and it is invisible precisely because nothing is wrong with any individual appointment.

    The appointment page is the wrong home for it and that is the decision, not an omission: the front desk confirming Tuesday's appointment cannot publish a clinical agreement, so telling them there is what the clinic owes reads as noise on a screen where nothing is actionable. It belongs on an admin surface — the clinic dashboard or the Organization → Legal documents page — as "you deliver remote consultations and have not published a remote-consultation consent", where the person reading it is the person who can write it. The signal already exists server-side: OrgPublishesInstrument plus whether the org has any online_live calendars or appointments.

F5.7 The Patient's Own Appointments — ✅ BUILT 2026-08-06 (local only)

NOT in the original F5 breakdown, and its absence was the gap. F5.1–F5.6 built the clinic's view of an appointment and a stranger's path to booking one, but the Portal had no appointments surface at all — the F9 Phase 2 nav restructure removed the disabled placeholder and nothing replaced it. A patient could be booked, be sent forms about the booking, and have no way to see when it was. No migration: 000046 already carried the RLS branches, and this is what finally reaches them.

upcoming HAD NO WRITER, which is the defect this work surfaced. Every booking opened booked regardless of who made it, so authenticated patients piled into the "to call back" queue the CS flow exists to drain. The three statuses now say three different things — booked = the clinic does not know who this is yet (patient_id IS NULL), upcoming = a known patient is on the diary whoever entered it, confirmed = staff have checked the appointment can actually run: consents signed, video room set up. One rule decides the first two (initialStatus), and it is the presence of a patients row rather than the caller's identity — the front desk booking for a known patient means the same thing as that patient booking for themselves.

ONE ADMIN TRANSACTION, not four per-domain bypasses. Self-booking writes appointments, forms (F5.6 materialises the gate's forms inside the booking transaction), and reads availability across specialist_weekly_hours, which is staff-only by design. The first attempt added an Admin() view to the forms repository, matching the appointments one — and was reverted: appointments.Repository's own doc comment warns that four per-domain bypasses are four things to audit and four chances to miss one. Identity resolves on the RLS session first, then the whole booking runs inside one withAdminTx, mirroring the public path.

PatientFacing is one flag carrying three patient-only rules — the calendar's notice period, the admin-pool availability read, and cooldown_minutes. Staff bypass all three: a receptionist must be able to book a walk-in for ten minutes' time, and rate-limiting the front desk on the calendar a patient just used would stop the clinic recording its own diary.

Reschedule pins, then falls back. The options endpoint computes availability POOLED while the commit re-resolves PINNED to the appointment's current specialist, so a slot the picker offered could be refused at commit with "that time is no longer available" on a visibly free cell. It now retries unpinned when the pinned resolve fails.

Verification found seven defects that no test would have. All were React lifecycle or cross-tab sequencing — an infinite fetch loop from an unmemoised callback that tripped the per-IP limiter and 429'd unrelated reads; a StrictMode hang from pairing a cancelled flag with a once-only ref; a rules-of-hooks violation that only fired on the success path of the action causing it; a reschedule that never consumed its hold and so broadcast the slot free again. The linter had been reporting the hooks violation as one warning among 1,321. react-hooks/rules-of-hooks is now an error, vendored public/mediapipe/** is ignored, and the warning count is 76.

  • [x] Eight /v1/me/appointments routes — list, book, get, cancel, reschedule, reschedule-options, plus F5.2's four file routes. Cancel and reschedule REFUSE a staff caller outright: a receptionist cancelling "as the patient" would attribute a clinic cancellation to the person it penalises in the adherence denominator.
  • [x] Cancel takes a reason and nothing else — there is no by_patient field to send, because a patient cancelling IS a patient cancellation.
  • [x] Patient reschedule validates through the same resolver a booking uses, notice period enforced. The staff path deliberately does not: the front desk records what is actually happening.
  • [x] RescheduleOptions returns calendar_id alongside the instants — PatientAppointment omits it, but the picker needs it to subscribe to the right hold stream. Scoping the disclosure to the one flow that needs it beats widening every read, and it is not sensitive (the public booking page already names calendars to strangers).
  • [x] Authenticated booking has NO FORM. The clinic already holds this person's name, email and phone; asking again is what would send a logged-in patient to the stranger's page. The caller names a calendar and an instant, and identity resolves server-side.
  • [x] Holds on every patient path, through /api/holds rather than /api/public-holds — the lease keys on their principal and survives a new device. Rescheduling takes a slot exactly the way booking does, so skipping the protocol there would reintroduce the double-booking it exists to prevent, in a new place, because moving felt like a smaller act than booking.
  • [x] ONE slot grid (components/booking/slot-picker.tsx) and one hold hook across the public page, the authenticated booking page and the reschedule dialog. Three copies of the rule deciding whether a slot is bookable is three places to fix a double-booking, two of which get found by a patient.
  • [x] Times render in the patient's browser timezone per P23 — the clinic's resolved zone stays what slots are COMPUTED in. Day headers stay pinned to UTC because the server's grouping key is a UTC calendar day rather than an instant.
  • [x] Hold lifecycle closed on both sides. pagehide + sendBeacon releases on tab close (beforeunload misses mobile Safari's bfcache path, where "closed the tab" mostly happens), and both hooks prune lapsed leases on a 1s tick — nothing broadcasts a Redis key expiry, so without the prune a lapsed lease stayed red until the stream recycled. The clinic additionally needed hold.extended: it recorded a lease's first expiry and never refreshed it, so once the pruner shipped it freed slots at t=30 that patients held for ~2m10s.
  • [x] A freed slot reappears without a reload — for reschedules and bookings. useHolds exposes confirmedTick, mirroring the signal the clinic calendar has used since live mode shipped, and the three Portal surfaces re-read on it (the public page through router.refresh(), since its availability is server-rendered — which only works because initial is read directly rather than seeded into state, P48). The two directions are not symmetric, and that is why a counter rather than a richer event: a slot becoming TAKEN is expressible as occupancy, but a slot becoming FREE is absent from days entirely rather than greyed, so nothing that only adds can put the row back. Only a re-read can.
  • [x] A cancellation announces itselfappointment.changed, published by the appointments service through a narrow OccupancyNotifier (sibling to HoldConsumer, same reason: the clinical record must not import a Redis primitive). Fired on cancel, no-show, reschedule, booking and assignment. Cancelling was the case with no other signal — every other way a slot changes hands rides the hold protocol, but a cancellation takes no hold, so nothing described it and the freed slot stayed missing from every open grid.
    • It publishes on the HOLDS channel, which is why this is a handful of lines rather than a subsystem: every client already has that stream open, so there is no second EventSource, reconnect path or snapshot protocol — one listener each in the clinic hook and the portal hook, both folding into the confirmedTick that already existed.
    • The payload is the calendar id and nothing else. The public booking page subscribes to the same stream, so a stranger receives every one of these; that is safe only because there is nothing in it — no patient, no specialist, no time, not even which appointment moved. It says "ask again", and the answer is the public projection they were already entitled to.
    • Fire-and-forget. A mutation that was not announced is a stale-looking grid; a mutation that failed because its announcement did would lose the clinical act.
  • [ ] Cross-calendar fan-out. Availability is computed per specialist ACROSS calendars, so freeing a clinician's 10:00 on Physiotherapy also frees it on Nutrition — whose watchers do not hear, because the channel is per calendar. hold.confirmed has had this gap since live mode shipped, so it is one limitation on both paths rather than a new one. Closing it means resolving the specialist's calendars per mutation and publishing to each. Left until the front desk reports the symptom, which is the signal that would size it.
  • [x] Seven integration tests, mutation-verified: disabling the gate fails exactly the four that assert blocking and leaves the three that assert passage green.
  • [x] Carried debt paid — the twin scheduling↔appointments adapters are now one exported set shared by production and rlstest. That drift had already cost a dropped offering_id; adding a third copy for calendar forms would have compounded it.
  • [ ] Digital signature capture for physical clinics (F3.5.3's drawn_kiosk mode)NOT BUILT, blocked on a primitive that does not exist: a form session that is not the patient's own login. A tablet handed across a desk needs its own unauthenticated-render RLS story. Unchanged from F3.5.3; the signature_mode CHECK still admits only click_typed, and widening it is a one-line ALTER once that primitive exists.

Business rules carried from leo (port map §4.2):

#Rule
A1Reschedule preserves duration exactly — it is not re-derived from the offering, which may have changed since booking.
A2Cannot reschedule into the past.
A3Auto-noshow grace is 30 min after scheduled start; the sweep runs every 15 min. Make the grace period organization_settings.noshow_grace_minutes — leo already parameterises it.
A5Cancellation captures a free-text reason capped at 500 chars, behind a confirm dialog.
A6Late cancellation is flagged at a 24h threshold.
A7Unscheduled appointments are a first-class bucketstarted_at IS NULL renders as its own tab and summary card ("Consultație fără programare" / "Programare inițială" on the portal). Real rows exist; the list UI must not assume every appointment has a slot.
A8Specialist overlap on the staff-created path is a WARNING, not a block — deliberately, because appointments are created manually and different calendars may share a specialist. (The DB exclusion constraint in F4 still guards the booking path.)
A9Converted bookings are de-duplicated on the staff calendar so one booking does not render twice.
A10The actions dropdown shows the exact phone number and email in the confirmation modal before dispatch — staff verify the destination.

Settled 2026-08-05 (detail + reasoning in appointments-substrate.md):

  • [x] Terminal-status policy vs. late arrival (§8.7) — noshow → inprogress permitted only when the noshow was written by the system actor, within a bounded window (proposal: noshow_grace_minutes × 2), audited as a correction carrying the superseded status. A human-set noshow stays terminal. The distinction is the point: this undoes a machine's guess, never a clinician's judgement. Without it the failure is silent — a new row orphans the forms, the video room, and the protocol_id/session_id pair, and leaves the auto-noshow permanently in the denominator, penalising a patient who attended.
  • [x] Reschedule semantics (§8.13) — mutate scheduled_at in place, audited. leo's behaviour, and it preserves duration exactly (A1). Cancel + new row is forensically cleaner but needs a rescheduled_from_id self-FK and makes every downstream reader walk a chain. A2 still holds: no rescheduling into the past.
  • [x] Late-cancellation thresholdorganization_settings.late_cancellation_hours (default 24), alongside noshow_grace_minutes (default 30). Clinic policy, not platform constants; leo already parameterises the grace period, so a constant would regress against the system being ported.
  • [x] Patient self-booking at launch? (§8.10) — No. F5 ships staff-side first. The public path, the F4.4 slot-hold system, and the server-signed booking-client cookie all land in F5.4 after the staff surface, the state machine and the adherence wiring work. leo's real creation surface is staff-side, so this matches the port and keeps the unauthenticated abuse surface out of the critical path.
  • [x] specialist_id nullabilitynullable. calendars.assignment_strategy = 'manual' ships in 000045 and means exactly "a booking arrives unassigned"; NOT NULL would make that shipped value inert. NULL is a pre-assignment state, never terminal — a service guard rejects entry to inprogress while unset. The RLS "specialist sees own" policy needs no NULL branch (NULL IN (…) is never true), which is the intended behaviour: the unassigned queue is an org-staff surface.

Still open, not blocking F5.1:

  • [ ] Appointment-package tracking — "this patient has N sessions of Offering X remaining," decremented as appointments are consumed. No platform equivalent exists; F2.2-adjacent. F5.1 ships without a plan-session column rather than with a speculative nullable one (P36).
  • [ ] Cross-org double-booking (§8.12) — specialists.organization_id NOT NULL gives a specialist working at two clinics two independent profiles, so the "cannot be in two places at once" invariant holds only within an org. leo has the same limitation. Accept as documented, or add a cross-org conflict check keyed on specialists.human_id — which is a cross-tenant read and would need anonymised or break-glass treatment.
  • [x] treatment_specific_* consent namespaceCLOSED 2026-08-05: struck, not deferred. Reasoning in F3.5.1. In short: a ledger purpose exists so code can ask before acting, and nothing branches on a treatment consent — it is a signed document, which an offering-attached disclaimer-category template already carries. leo has no purpose catalog at all. This was never a blocker for F5.6.

Exit criteria: End-to-end booking flow per needs-on-day-1 works in staging: guest → CS confirm → account → appointment with consents and forms gated, video call working, and a supervised protocol's adherence denominator computed from real appointment rows.


F6. Documents

STATUS (2026-08-07) — BUILT AND WORKING END TO END, LOCAL ONLY

Migrations 000047 (pdf templates) + 000048 (appointment documents). Neither is on staging or production — prod is at 000039, staging at 000038. 13 commits ahead of origin/staging, unpushed.

The loop closes: a clinic authors a block template → publishes it → generates a report from an appointment → reads the draft → releases it → the patient opens it in the Portal. Browser-verified by the user at each step.

What shipped

PartState
F6.1 block builderpdf_templates, pdf_template_versions, pdf_template_components + Clinic builder UI
F6.2 renderer@react-pdf/renderer server-side in apps/clinic
F6.3 appointment_documents✅ generation, publish gate, presigned reads, 10 RLS tests
F6.4 form → document✅ per-block form sources (A1), see below
B1 Portal patient surface✅ read-only, published-only

Settled decisions — do not re-litigate

Rendering is @react-pdf/renderer v4, server-side, synchronous, in apps/clinic. Settled by measurement, not preference: 41 ms p50 / 31 MB against 193 ms / 518 MB for a headless-shell sidecar and 410 ms / 2.45 GB for Gotenberg. The legacy .tsx report components port near-verbatim and there is no HTML stage. @react-pdf/renderer MUST stay in serverExternalPackages — bundling rewrites its module graph and it fails at font load.

⚠️ TURBOPACK DEV CORRUPTS RENDERED TEXT — production is fine

next dev --turbopack silently deletes every character up to and including a capital I in a generated PDF. Intervenții chirurgicale renders as ntervenții chirurgicale, Ionescu as onescu, XIntervenții loses two characters; a string with no capital I is untouched. It is not the font, the data or the layout — the string is already truncated when it reaches the layout engine (verified: the label going in is 35 chars starting at ASCII 73, and comes out at 34).

next build is CORRECT, measured on the same strings. Production builds the standalone image with webpack, so real generated documents are right; only the local dev loop lies.

Cause: serverExternalPackages: ["@react-pdf/renderer"] is honoured by webpack but NOT by Turbopack, which compiles the package anyway — Next resolves it as [project]/…/react-pdf.js [app-route] (ecmascript). The rewritten module graph is exactly what the next.config.ts comment warns about; it just fails more subtly than "render error". Most likely a re-encoded Unicode lookup table (fontkit embeds compressed tries as string literals), but the mechanism is upstream.

pnpm --filter clinic verify:pdf CANNOT catch this — it runs in plain Node through esbuild, outside Next. Do not treat a green verify as proof that a local preview is faithful.

Accepted, not fixed (user's call, 2026-08-07): production is correct, and dropping --turbopack would slow the whole clinic dev loop to fix one library. Revisit if Turbopack becomes the build default too — that would put the corruption on real patient documents.

Romanian diacritics need an explicitly registered font, from a real filesystem path. The built-in fonts drop ș and ț with NO error while â survives, so broken output looks almost right. The legacy dashboard registers by browser URL, which resolves to nothing in Node. There is a module-load guard that throws if the TTFs go missing, and pnpm --filter clinic verify:pdf asserts the round-trip. There is no frontend test runner in this monorepo, so that check is a script and has to be run deliberately.

lineHeight in @react-pdf IS NOT THE CSS lineHeight. A unitless value multiplies the FONT'S OWN line height — ascent + descent + gap, ≈1.8em for Inter — not the font size. Measured on 10pt text: unset → 12.1pt line pitch, 1.0 → 18pt, 1.2 → 21.6pt, 1.5 → 27pt. The CSS-intuitive lineHeight: 1.5 is therefore 2.7× the font size, and it shipped that way on report prose until a rendered page was looked at. Body paragraphs now set none at all; the font's own metrics are ordinary reading leading. Any future value here gets measured, not reasoned from CSS.

medical_prescription, never bare prescription. The bare word is live in production as protocols.kind.

Three reconciliations against data-model.md Area 11, all made rather than deviated from silently: the four HTML columns dropped (no HTML stage, so nothing would write them); appointment_document_files NOT built (D6 puts clinical images on the appointment, and F5.2's appointment_files already does that); and UNIQUE (appointment_id, type) reconciled with "regeneration produces a new row" via superseded_at + a PARTIAL unique index on live rows.

superseded_by_id is DEFERRABLE INITIALLY DEFERRED, and that is fine. The partial unique index refuses a second live row and the self-FK cannot point at a row that does not exist, so neither insert order works with immediate checking. Deferring was briefly called "the only deferred constraint in the schema" — that was wrong: uq_session_exercises_ sequence, uq_program_phases_order and uq_session_audio_items_order are all deferred, for exactly this reorder/swap class. Integrity at rest is identical (verified: a dangling pointer rolls back the whole transaction). No SET CONSTRAINTS anywhere, so it is P44-safe.

CNP: BUILT. Column national_id_encrypted BYTEA plus a national_id_hmac blind index for search. Two patient-entered capture paths — a national_id form question and the Portal profile page — and staff READ it through a permissioned, audited reveal endpoint. Selecting cnp on a patient_details block is itself the decision to print it; there is no separate opt-in on either template. See the C1 + C3 section below.

Generation order is SUPERSEDE then INSERT. The reverse duplicate-keys the partial unique index, and only on the SECOND generation — nothing but a test that regenerates catches it.

Bugs found by using it, and the rules they left behind

Any slice or map that crosses the wire is initialised at declaration. A nil Go slice marshals to JSON null, and the renderer reads .length. This bit twice — editor_state.blocks, then answers — so it is now a rule with a mutation-verified test.

A form's ownership is checked against the appointment's patient. The forms list folds in clinic-wide rows, which belong to whichever PATIENT filled them in, and staff may read every form in their org — so RLS does not stop a caller naming another patient's form. Without the refusal, one patient's answers printed into another's medical record. Two paths now: the declared-block path resolves BY QUERY (ownership by construction), the explicit-id path REFUSES (403/409).

organization.logo_url is a public CDN URL, not an S3 key. Branding lives on Bunny under platform/org-branding/, outside the tenant bucket. s3.Download correctly refused it; it is passed through and @react-pdf fetches it server-side.

A server action must log the real error. Swallowing it into a generic message made two rounds of debugging pure guesswork.

A1 — per-block form sources (the design that removed the in-between state)

Each form_answers block names the form TEMPLATE it prints; generation resolves one form per block. A template, not an instance — an instance differs per appointment and would tie the whole template to one consultation. Sources are read off the FROZEN snapshot, so a document generated against v1 prints exactly the sections v1 declared.

A declared block does NOT fall back to the picked form when its own resolves to nothing: it renders nothing, heading included. The generate-time picker survives as an OVERRIDE and is shown only when the template has an undeclared block.

WHICH INSTANCE, when the patient holds several of one template on one appointment — settled 2026-08-07, in SQL. The order is: this appointment's own, then the most recently FINALISED within that tier, then by id.

The linkage decides first. A document is about ONE consultation, and forms.appointment_id is what says which answers belong to it. A clinic-wide form carries no appointment — it is the patient's, not this visit's — so it is a FALLBACK, reached only when this appointment has no instance of the template at all. That fallback is what lets a standing consent, signed once and applying to every visit, still print.

Then by completed_at, not created_at. Within one tier that is what "the latest instance" means: a correction is usually created later AND completed later, but not always — a form opened Monday and finished Friday holds newer answers than one opened Wednesday and finished Thursday. The id tie-break is what makes a regeneration print the same answers.

THE FALSE LEAD, recorded because it cost a round trip. The tier clause was briefly demoted below recency, on real data that looked damning: an appointment whose linked form was a thin four-field snapshot of template v7 while the patient's actual answers sat in a twelve-field v12 the document could not reach. The ordering was not the fault. Nothing could create a linked form after bookingGenerateForBooking binds the offering's and calendar's templates at booking, POST /forms has always accepted an appointment_id, and the clinic UI's only send action was the patient-level one whose own doc comment reads "unattached to any service". Every form the clinic sent afterwards was patient-level by construction.

Matching the ordering to the broken surface would have made a consultation's report print answers given for no consultation at all, and quietly. The fix was the missing surface — see below — not the rule.

One live instance of a template per appointment. Built 2026-08-07.

A consultation asks a given questionnaire once. Two live instances are not a richer record but an ambiguous one: a generated document has to pick between them, the readiness gate has to decide which unsigned copy blocks the session, and the clinic's panel shows the same title twice with no way to tell which is the real answer. uq_forms_appointment_template, added to 000043 in place (on no environment; verified by a from-scratch rebuild diffed against the running local database).

The invariant already held, unenforced. GenerateForBooking deduplicates the offering's and calendar's attachments by template, and the local database had zero duplicates across every linked form. The index makes it true of every path rather than the one that remembered — including the "send a form for this consultation" control added the same day, which is precisely the path that could have broken it.

Partial on both columns, and each half is load-bearing.appointment_id IS NOT NULL, because a clinic-wide form belongs to the PATIENT and they legitimately hold many over the years (a standing consent re-signed annually) — constraining those refuses the second signature. deleted_at IS NULL, because a withdrawn instance is history; if it counted, the first resend would be the last one possible.

Re-asking is a RESEND, not a second send. POST .../forms/{id}/resend withdraws the current instance and issues a fresh one in ONE transaction — one endpoint rather than a delete plus a create, because the two halves cannot be separated without passing through the state where the consultation has no form of that template at all. The withdrawn instance keeps its answers, soft-deleted: "we re-asked" is a different fact from "this was never filled in", and a filled clinical form is never destroyed. Permitted on a SIGNED form — the immutability trigger allows archival by name, and re-issuing a consent does not rewrite what was signed.

One consequence worth stating: the linked tier can now hold at most one candidate, so the answer-source ordering's recency clause only ever discriminates among the patient's standing forms. The integration tests that used to seed two linked instances were rewritten onto the fallback tier — the index made the scenario they described unreachable, which is the point of it.

The appointment had no forms surface. Built 2026-08-07.

AppointmentFormsPanel on the appointment detail: this consultation's forms, and a Resend on each row. Gated on forms.manage rather than documents.manage — the two travel together for a clinician but not for the front desk, and gating a control on the wrong one hides an act the server allows.

NO PICKER, deliberately — and it briefly had one. What paperwork a consultation carries is decided by the OFFERING and the CALENDAR and materialised at booking; a picker here listing every published template in the clinic was a second, parallel answer to a question offering_forms already answers, and the two would drift. Attaching a form to the service is where that decision belongs. The patient's standing documents are the patient record's page for the same reason.

So the only act on this surface is the one booking cannot perform: re-asking a form the consultation already carries. Confirmed before it runs, because from where the clinician stands it is destructive — the current form leaves the record and a blank one takes its place.

The withdrawn instance is archived, not legible, and that is the settled position (2026-08-08). RLS exposes deleted_at IS NOT NULL rows to data.view_deleted (admin-only), but buildListConds hardcodes deleted_at IS NULL, so no API call and no screen returns them. A history UI was considered and DECLINED: a resent form is one that was not filled in properly, so its answers are the ones a clinic decided not to keep, and a disclosure showing them would put discarded data back in front of a clinician reading a medical record. Admin-only recovery is the right level.

BOTH SIDES OF A RESEND ARE AUDITED, and it took two rows rather than one. The CREATE names its source, but only the replacement's id leads to it — somebody auditing the WITHDRAWN form watched it disappear with nothing to say why, which is precisely the question a soft delete exists to be able to answer. So the handler writes a DELETE on the old id (with its lifecycle payload) and a CREATE on the new. One act, two state changes, each answering for itself.

Left to do — in the user's own priority order

ItemNotes
A2Source-aware patient/field blockCLOSED 2026-08-07 — see below.
A3Appointment-entity custom fields have no capture surfaceCLOSED 2026-08-07 — see below.
B2Template preview with mock dataCLOSED 2026-08-07. Renders the blocks ON SCREEN (not the stored version) through the SAME renderDocument generation uses, into an iframe on a blob URL so the browser's own PDF viewer draws it. The sample context is DERIVED from /meta — every catalog key gets a value, so a field added to patientprofiles.Fields appears in the preview with no edit, and one with no sample renders as «key» rather than vanishing (a row that silently disappears teaches an author the field does not print). Nothing stored, nothing gated, no audit row — but the org's REAL branding is fetched, because a letterhead that is not the clinic's own defeats the point. Sample contact details follow the house rule. verify:pdf covers both paths.
B3Document history UICLOSED 2026-08-07. GET .../documents/history?type= plus a per-type disclosure in the appointment panel. The repository could already answer this — nothing exposed it, so a clinic could regenerate a report and lose sight of what it had issued even though every generation was retained. A superseded document stays downloadable, marked rather than hidden: the stored object exists either way, so refusing it would cost the clinic its answer to "what did we actually hand this patient" without removing anything from the record. Ordering had to gain a tie-break — two generations inside one transaction share created_at, so (superseded_at IS NULL) DESC, created_at DESC, id DESC keeps the live row on top and the order stable between refreshes. Both actors are attributed — see below.
B4Block variants / component library UIpdf_template_components is a table nothing writes
C1Patient identifierCLOSED 2026-08-07 — see below.
C2Conditional content at generationCLOSED 2026-08-07 — see below.
C3CNP capture pathCLOSED 2026-08-07 — see below.
D1leo annex prose as seed templatesnutritional.tsx + nutritia-durerii.tsx, ~1,180 lines of clinician-authored Romanian. Pure data entry now that the builder exists — and the annexes are the C2 case, so they seed as one optional choice each (page break + prose under one key) rather than as two templates.
D2F3 starter templates on org creation
D3PushPromotion beyond origin/staging is the user's alone

A2 — source-aware fields, and the clinic's own. CLOSED 2026-08-07.

Three groups, by what the value DESCRIBES. profile is the patient's own portable record, so a value may legitimately be absent because the patient never filled it in. record is this clinic's own record of them, which crosses no tenant boundary. appointment is derived per document. Grouping by storage table was the obvious alternative and the wrong one: an author does not care where a value is kept, they care whether it might not arrive.

ONE CATALOG, and that is the part that matters. The same set of patient_profiles columns was written out by hand in three places that each knew a different subset — the form builder's binding list, the document builder's printable list, and two message catalogues captioning them — and they had drifted. patient_profiles.residence was captioned Domiciliu on one surface and Reședința on the other, which in Romanian are not synonyms: one is the registered legal address, the other where the person actually lives. Two surfaces were making different claims about one column.

patientprofiles.Fields is the single source now. Each entry declares its column, its storage shape, its answer shape for form binding, whether it is bindable and whether it is printable; the form binder, the document catalog and the render resolver all derive from it, and captions live once in packages/i18n/messages. Adding a patient field is ONE entry, and three tests make that true rather than aspirational: a derived list that drops an entry fails, a field with no caption in either locale fails, and a caption naming no field fails.

The seven columns a patient could fill in but no document could print are printable now — sex, blood_type, allergies, chronic_conditions, both emergency-contact fields and insurance_entries. They were never refused on their merits; they were absent because the list was inherited from the legacy report. Three needed real work rather than a flag: allergies and chronic_conditions are TEXT[] and print comma-joined whole or not at all (a document showing one of three allergies is more dangerous than one showing none), and insurance_entries is JSONB with a formatter, because raw JSON on a medical document discloses how the platform stores things to a patient who asked what their insurance is. sex arrives as its stored code and is captioned client-side from the same shared catalogue, so a page reads Bărbat rather than male.

The clinic's own F3 fields print, and interleave into those groups rather than forming a fourth. A block stores custom:<entity_type>:<key>; both patient and appointment entity types resolve, since both ids are in hand at generation. The entity type is IN the key because custom_fields is unique on (organization, entity_type, key) — one clinic may define notes for both, and without the segment the two collide silently.

By key, not by UUID. custom_fields.key is immutable by design — customfields.UpdateInput omits it because "changing a key breaks every PDF and export referencing it" — so it is as stable as an id and stays legible inside a frozen snapshot. Answering "what did v1 print?" should not require resolving a UUID against a row that may since have been deleted.

What replaced the compile-time guarantee. The built-in catalog is safe because a Go slice enumerates it; a clinic-defined field cannot be. Its bound is the LOOKUP: resolution joins custom_fields on the caller's own organization, so the worst an author can name is one of their own clinic's fields. Private and national_id fields are excluded in the same SQL that resolves values, so the set a template may REFERENCE and the set a document PRINTS cannot drift apart. Existence is checked at save AND at publish — the library can move underneath a draft.

The label travels with the value. Built-in captions come from the clinic app's own copy; a custom field's label is data the clinic typed, so the render context carries patient_labels for those keys only and the app merges it over its own map.

P39: the registry was documentation claiming to be a control. Nothing calls classification.AllowedFor at runtime, and the markdown is not in the API image — the build context is services/api alone. Reading the registry against the renderer found four fields printed with no permission to be: email (humans.email), specialist_name (specialists.name), offering_name (offerings.title) and appointment_date (appointments.scheduled_at). All four were live for the whole life of F6. check-classification verified every column HAD a row and never that code honoured one, and the doc's own prose claimed a startup-time parse that does not happen.

Resolved in the registry's favour, and the gap closed with a CI assertion rather than a runtime registry: each catalog entry now declares its backing Table/Column, and make check fails if that column's row does not allow patient_document. A generated embedded table was the alternative and was deliberately not built — while the printable list is fixed and enumerable, the build-time check gives the same protection for a fraction of the machinery. Build the runtime registry with F11's DSAR export, where the field list is genuinely dynamic and a CI assertion cannot cover it.

D3 retired in the same pass. A private field is now never printed, on any document type — see the note under F6.4's business rules.

The value store had no read or write path — closed 2026-08-07

Found by using it: custom fields selected on a template printed nothing, and there was no way to tell whether a value had ever been stored. There had not been. custom_field_values had no reader and no writer beyond form write-back on completion — so a clinic could define a field, tick it on a document template, publish, generate, and get a document silently missing a row with nowhere to check.

The RLS policy has gated staff writes on patients.manage since 000042 and its comment says so; nothing ever reached it. Three endpoints now do — GET .../profile-fields, GET/PUT .../custom-fields — behind a new Record tab on the clinic patient page.

Two sources, two postures. The portable profile is READ-ONLY: it is the patient's own record across every clinic they attend, so rectification is theirs through the Portal. The clinic's own fields are editable per-field on blur, which the partial PUT makes safe — two clinicians editing different fields do not overwrite each other. CNP stays presence-only behind the audited reveal.

Withheld and never-filled are indistinguishable, deliberately. Separating them would tell a clinic "this patient HAS an occupation recorded, but not for you", which is a disclosure about a record the patient chose not to share. The page states once whether the profile is shared at all. The profile read reuses the RENDERER's gate query, so P8 has one implementation rather than two.

A3 — the appointment half of the same store. CLOSED 2026-08-07.

The patient half had no reader and no writer; the appointment half had no writer and no policy that could ever admit one. custom_fields accepted entity_type = 'appointment' from 000042, the builder offered custom:appointment:<key> from F6.4, and the resolver read it — but every write policy on custom_field_values gated on patients.manage, which is authority over a patient record and says nothing about a consultation. A clinic could define an appointment field, tick it onto a template, and print a blank line forever.

No new migration. The policies had to read appointments, which does not exist until 000046 — so this landed in 000046 itself, the same placement 000043 used for the write-back branch it could not express in 000042. Nothing here is promoted anywhere, so an edit in place was available; the local database was brought up by hand and then verified by a from-scratch rebuild diffed against it — columns, indexes, constraints, every RLS policy expression, the permission table and the system-role grants all identical, with monthly partitions the only difference and those are the roll job's.

The permission is appointments.record_fields, and choosing it was the whole design. appointments.manage is wrong for the reason 000046 already wrote down when attachments hit the same fork: manage is front-desk authority deliberately withheld from the specialist role, and the person recording what a consultation found IS the clinician conducting it — gating there repeats the exact defect 000043 documented when it found specialists unable to write back a measurement they had just taken. appointments.manage_files has the right audience and the wrong noun, and folding the two together would leave a clinic unable to grant "record a measurement" without also granting "attach a scan". It is spelled record_fields rather than manage_fields because one character from manage_files is not a distinction a role editor can carry.

The patient branches were scoped by entity type in the same pass.patients.manage had been unqualified, so it authorised writing a value against any entity — unreachable rather than intended. Each branch now names the entity it is about.

The appointment branches EXISTS against appointments rather than checking a permission alone, mirroring appointment_files. entity_id is polymorphic and therefore un-FK'd, so the policy is the only referential check there is: an id naming another org's consultation, or naming nothing, is refused instead of stored as an orphan. Both cases have tests.

No patient branch, and that is the decision rather than an omission. A patient reads their own appointment, its documents and its attachments. These are the clinic's internal observations about the visit; what the clinic asks the patient directly is a form, which the patient reads as a form.

The editor is now ONE component for both entities — the patient Record tab's, moved to apps/clinic/components/ and given its save action as a prop (a bound server action, since a server component may hand a client component nothing else). Two copies would have drifted silently: a fix to the select-commits-on-change behaviour applied to one and not the other looks like a working feature until a clinician loses a choice. PatientCustomField became EntityCustomField for the same reason — naming an entity-generic shape after one of its two entities is how a reader comes to believe an appointment value is a patient value.

The card is absent entirely when the clinic has defined no appointment fields, which is most clinics: every field in the starter library is patient-scoped. It sits ABOVE the documents panel, because these values are what a document prints and the layout should not suggest recording them afterwards.

Who generated it, and who sent it — 2026-08-07

generated_by_principal_id was always on the row; only the name lookup was missing. published_by_principal_id was not stored at all — the release actor lived solely in audit_log, which is backwards: releasing is the act that puts the document in front of the patient and cannot be undone once a copy is downloaded, and it had the weaker attribution of the two.

audit_log remains the forensic source, but it is monthly-partitioned with a 12-month hot window before S3 archival while the document row lives as long as the medical record. Without the column, "who released this" is answerable on screen for a year and then only by restoring an archive. Added to 000048 in place — it is on no environment, so no catch-up DDL; verified by a from-scratch rebuild diffed against the running local database.

Stamped on the FIRST release and left alone, exactly like published_at beside it, so the pair reads as one fact. A withdraw-and-re-release by someone else is a sequence, and a sequence belongs in the audit log rather than in a column that would overwrite the first answer.

Names resolve at read time, never stored. A denormalised name freezes at write time and goes stale on a rename. humans has no name column, so resolution prefers specialists.name and falls back to the account email — on the caller's own RLS session, since organizations.view_directory is staff-baseline and every system role holds it. One lookup per list, not per row. An unresolvable id is ABSENT rather than a placeholder: the principal outlives the human, and "we no longer know who" is honest.

C2 — conditional content at generation. CLOSED 2026-08-07.

One report that sometimes carries a nutrition annex, decided per patient. The alternative a clinic reaches for otherwise is a template per combination, which multiplies the thing that has to stay correct; the alternative a builder invites is letting the clinician edit blocks at generation, which puts layout authoring in the middle of a consultation. The author declares the axis, the clinician answers it — the split that keeps the layout under one owner and the content decision with the person seeing the patient.

A HIDDEN BLOCK IS REMOVED BEFORE ANYTHING RESOLVES. The filter runs at the snapshot boundary, in the adapter, and every derivation downstream reads the result: requested patient fields, answer blocks, the block list the renderer walks. So a declined patient_details block does not fetch its fields — a declined section that selected the CNP does not decrypt one — and a declined form_answers block reads nobody's form. The renderer is handed a shorter list, never a full list plus instructions about what to skip, because a renderer that never receives a block cannot leak it and a lookup never made cannot disclose. This was the user's stated constraint and it is the reason the whole design hangs off FilterOptional rather than off a flag the renderer checks.

THE CHOICES REACH THE FROZEN RECORD, as appointment_documents. optional_choices — its own JSONB column, added to 000048 in place (on no environment; verified by a from-scratch rebuild diffed against the running local database), classified clinical / support_export. NOT a metadata key: metadata is non-clinical by contract — render duration, block count — and what a medical document contains is the opposite of that. Both halves are stored, offered and answered, because a plain list of keys taken cannot tell "the clinician declined the annex" from "the template never had one", and the frozen template version answers only the second.

GROUPED BY CHOICE KEY, because an annex is rarely one paragraph — a heading, the prose, a boxed caveat — and three checkboxes for one annex is both more work and a way to produce half an annex. Blocks sharing a key move together; the first in document order owns the label and the default, and a later member picks the group from a list in the builder rather than retyping a key. An empty key means "my own choice, keyed by my block id".

ONLY rich_text MAY BE OPTIONAL (user's call, 2026-08-07). Every type was briefly allowed and should not have been. Optionality is for PROSE — an annex, a recommendation, a caveat this patient needs and the next does not — and prose is also the only content the AUTHOR wrote, so declaring it optional means knowing exactly what the clinician is being offered. A patient_details or form_answers block prints the RECORD, and those are either part of a document or not, decided once by whoever designed it; a checkbox invites omitting data from a medical record one tick at a time with nothing on the page to show what was left out. letterhead and footer are the page frame and have no "sometimes". An optional signature would hand a clinician a checkbox that walks straight past the publish gate refusing an unsigned prescription. The publish gate gained a second rule for the same class of hole — a template whose every content block is optional cannot publish, because it can otherwise produce a letterhead over an empty page, one checkbox at a time.

One consequence worth stating plainly: because prose resolves nothing, the filter currently withholds no lookup — a declined section costs no query only in the sense that it never had one. The filter still belongs at the snapshot boundary, because "a declined section fetches nothing" then holds BY CONSTRUCTION and widening the optionable palette stays a one-line change instead of an audit.

NIL AND EMPTY MEAN DIFFERENT THINGS on the wire, which is why include_optional is tested by PRESENCE (url.Values.Has) and not by length. Absent is "nobody was asked" and each choice falls to its author's default; empty is "asked and declined everything". Collapsing them would make every API caller that has never heard of C2 silently strip the optional sections out of the documents it generates.

DISAGREEMENT ERRS TOWARD EXCLUDING. The generate panel reads the LIVE template while generation resolves against the frozen snapshot, so a republish between opening a consultation and generating leaves them out of step. A key the client sent that the snapshot does not declare is ignored; a choice the snapshot declares that the client did not name is excluded. A missing section is visible on the document the clinician then reads, while a section they never chose is content they did not decide to print.

The stored record is echoed from the render context, like pdf_template_version beside it, and only its SHAPE is re-checked. The trust model is the one the bytes already run on: a caller able to lie about the choices is a caller able to lie about the whole document, so re-deriving would cost a template lookup and secure nothing. What is refused is a record that cannot be read back — a keyless entry, a repeated key, a list past the ceiling.

The preview (B2) renders every block, optional ones included: a preview answers "what does this template lay out", and a section hidden because its checkbox defaults off is a section the author cannot see they authored.

Sections — the gutter layout. Built 2026-08-07.

A clinical report reads as a title in the left margin and the findings across from it: Examen funcțional in the gutter, and beside it Bilanțul funcțional al coloanei toracale, Mersul pe vârfuri, each with its own bold subtitle and its content under it. A flat run of headings makes the reader carry the hierarchy themselves, and the builder could not express the shape at all.

Block.Section {key, title} puts a block in a gutter row with the consecutive blocks that share its key. Same grouping mechanics as the C2 choice key — empty key means "my own, keyed by my id", the opener carries the title — so the two controls in the builder behave identically.

FLAT, NOT NESTED, and that is the decision worth recording. A section could have been a block type holding children, which is what a builder usually does. Nesting would mean every walk over a template learns to recurse — RequestedPatientFields, AnswerBlocks, FilterOptional, ValidateBlocks — and each is a place a child could be missed, silently, in a security-relevant direction. Grouping a RUN of the flat list keeps one ordered list with one set of invariants: ids stay unique, order stays the order on the page, and a section that loses every block to a declined C2 choice simply stops existing without anything having to notice.

The price is that a section's blocks must be contiguous, which the server enforces: a key reappearing after a block outside the section came between would draw the same gutter title twice — one section in the builder, two on the page. The builder therefore offers only the run DIRECTLY ABOVE as a join target, so an author cannot pick a section they cannot be in.

letterhead, footer and page_break may not sit in one. The first two render fixed — positioned against the page rather than in the flow — so a gutter row cannot contain them in any meaningful sense, and a break inside a row would split it from its own title.

A section whose blocks all render nothing renders nothing at all, title included. Every block type can come back null — fields all withheld, no resolvable form, a declined choice — and a gutter title over an empty column claims the clinic has a section here and found nothing to put in it. verify:pdf asserts both halves: the titles and subtitles appear, and the empty section does not.

One gutter serves the whole page: patient_details rows and section titles share the same 30% column, so every label on the document lines up and the content column starts at the same x wherever the reader looks.

C1 + C3 — settled and built 2026-08-07.

No migration of its own: both columns belong on tables 000006 creates and the forms ban belongs beside forms in 000043, so they were folded in rather than bolted on. A 000049 existed first, written under the older "applied migrations are immutable" rule; production is a demo and edit-in- place is the standing instruction, so the ALTER-plus-backfill it needed disappeared with it. Verified by a from-scratch rebuild diffed against the running database — columns, nullability, indexes, triggers and functions all identical.

⚠️ STAGING AND PRODUCTION STILL NEED THE 000006 HALF, BY HAND. NOT YET APPLIED. Folding did not remove that work, it relocated it: both environments passed version 6 long ago, so golang-migrate will never re-run it and the edit is invisible to them — which is exactly how staging lost four indexes the last time a migration was edited in place. A tracked migration applies itself; a script has to be remembered. infra/scripts/000006-patient-identifiers.sql is the catch-up, and it keeps the ALTER + backfill + SET NOT NULL a fresh database no longer needs — 000006 creates patients empty so it can declare the column NOT NULL outright, and a populated table cannot.

Nothing else needs catching up: 000042, 000043, 000047 and 000048 have reached no environment (prod 000039, staging 000038 as of 2026-08-04 — confirm before running anything), so they apply normally on promotion.

Proven rather than asserted: built a probe at v39 carrying the PRE-FOLD 000006, seeded it including a patient who left and returned, ran the script. Every number carries its group's first registration month, and the returning patient's two rows — one soft-deleted, one active — share a single number. Re-ran it unchanged, so it is safe on an already-caught-up or freshly reset database. Then migrated the probe to 000048 and diffed the full catalog against a from-scratch build: identical.

Număr registru was leo's primary key. report.service.ts resolves it as patient?.id, typed id: number — the Strapi autoincrement, rendered under a Romanian label. Every franchise shared one patients table, so it was effectively a platform-wide sequence. The first F6 implementation was wrong on a second axis: shortRegistry(appt.ID) derived from the APPOINTMENT, so one patient got a different number on every document.

patients.patient_number is YYMMNNNNN — registration year+month, then five random digits. 260847316 is an August-2026 registration.

It is deliberately NOT sequential, and the first draft of this migration got that wrong. A per-org sequence fixes the scope of the leak and keeps the leak: 000489 tells the patient, their employer and their insurer roughly how big the clinic is, printed by us on every document it hands out. That is the same objection that rules out a platform-wide sequence, and it applies one level down.

The date half is deliberate too, and bounded. It encodes when someone registered — an operational fact about the clinic's records — and never a personal attribute. The CNP it superficially resembles encodes sex, birth date and county, which is precisely why a CNP has to live encrypted. A number that leaks a personal attribute is a small CNP handed out unprotected.

Allocated by the patients_allocate_number BEFORE INSERT trigger with a collision-retry loop — in the database rather than the three Go call sites, because a NOT NULL column that five writers must remember to fill has one that forgets. An explicit value still wins, so an import can carry numbers across. Stable across re-onboarding: a returning patient gets a fresh patients row but the same register entry, so the unique index is partial on deleted_at IS NULL. No counter table — random allocation needs no allocator.

Legacy leo ids are NOT carried across as the printed number (they were a sequence, so they leak). They need a home so staff can find someone who quotes an old one; patients.consumer_id already exists for external identifiers and is the candidate, settled at migration time rather than now.

CNP: patient_profiles.national_id_encrypted BYTEA, AES-256-GCM. The column name is not a style choice — cmd/check-classification enforces pii_regulatedBYTEA + _encrypted on every make check.

Two entry points, both patient-owned:

  • A national_id question on a form. Captured at SAVE rather than at Complete, breaking the copy-in-early / copy-out-late rule every other write-back follows — a CNP cannot be half-typed past NormalizeCNP (control digit verified), and it has nowhere to wait, since the trigger refuses it in forms.values.
  • The Portal profile page, beside name and date of birth.

There is NO opt-in left, on either side. Both form_templates.requires_national_id and the PDF template's checkbox were removed, for the same reason and in that order: the presence of a national_id question, or of the cnp field on a patient_details block, already declares the intent. A flag beside it was a second statement of one fact — which is why PDF publish validation needed a check in EACH direction, block-without-flag and flag-without-block, each catching the other's disagreement.

I kept the PDF one a day longer on the argument that it "gates decryption". That was wrong on inspection: appointmentdocuments has never consulted it, and once staff read the CNP from the patient record it gated nothing that was not already open.

pdf_templates.requires_national_id survives as a DERIVED column — recomputed from the blocks on every editor_state write, refused by the API — because "which of our templates print a CNP" is a question a DPO asks and answering it by JSON path into editor_state.blocks[].config.fields[] is fragile. The warning now sits on the CNP field itself, where the decision is made.

Removing the two-way check surfaced a defect it had been masking: two template-level publish problems both key on template and the second silently overwrote the first, so an author with a content-less AND signature-less prescription saw one of them. They are joined now.

STAFF READ THE CNP LIKE ANY OTHER PROFILE COLUMN. Two earlier attempts hid it from them; both were wrong, and the reasoning matters more than the code.

decisions.md is explicit that column encryption defends "a narrow, specific threat: a logical-backup or pg_dump leak", and that it "does not protect against the realistic attacker" — who rides the application and holds the key by definition. I turned that storage decision into an authorization rule. The result was a clinic that could print a CNP on a PDF and not read it in the record, which defends nothing, because the value already leaves through the renderer.

On the regulatory question: a CNP is ordinary Art. 6 personal data. It is NOT Art. 9 special category — the diagnoses and allergies every staff member already reads are. Romanian Law 190/2018 art. 4 (under GDPR Art. 87) asks for "appropriate technical and organisational measures" — a designated DPO, defined retention, staff training — not a dedicated permission code. So the gate is patients.view, the same as the date of birth — plus, on the reveal endpoint specifically, patients.view_national_id and an audit row. Confirm at the counsel review.

The measure an art. 4 reviewer would look for is READ AUDITING, and it is BUILT (2026-08-07). audit.ActionRead records every staff disclosure of a CNP: HandleGetNationalID fails closed — an unrecordable disclosure does not happen — and the row carries the entity, never the value, since writing the CNP into a plaintext append-only table kept six years would defeat both the encrypted column and the redaction rule. No migration was needed: audit_log.action is bare TEXT (only actor_type carries a CHECK), so the verb cost zero DDL on a prod-immutable table. This is a narrow exception, not a general read log — the admission rule lives in the constant's doc comment, and F11.5 holds the doctrine plus the deferred general-access-log design.

Search by CNP works through the existing patient search box — patient_profiles.national_id_hmac, a blind index (crypto.BlindIndex: HMAC-SHA256 under a key derived from the active encryption key, version-prefixed). Random-nonce AES-GCM cannot be searched, the same constraint that keeps phone in plaintext (000006), and the blind index is the workaround decisions.md names. It leaks equality by design — two rows with the same digest hold the same CNP, which is how the lookup works and how a duplicate record surfaces. Rotating the encryption key requires recomputing every digest; the version byte makes that a detectable mismatch rather than a search that silently returns nothing.

The patient sees and can edit their own CNP on the form. Showing "recorded" and nothing else was the first attempt and was worse than it looked — a patient who mistyped their CNP could not see that they had.

Five things worth carrying forward:

  • The structural ban had a second door. 000042 closed custom_field_values.value; forms.values is a second plaintext JSONB store, and 000043 adds the sibling trigger. A text field labelled "CNP" still bypasses both — the bans only work because there is now a correct path to route people to.
  • Patient-entered only, refusing out loud rather than skipping quietly the way writeBackProfile does. A receptionist who transcribes a CNP and sees the form save cleanly would believe it was captured.
  • The answer never comes back, because it is stripped from forms.values. FormView.national_id_on_file reports PRESENCE so the Portal draws "recorded" rather than an empty box — otherwise the patient re-enters 13 digits, which is where a typo comes from.
  • is_required on a CNP question is satisfied by the profile, not by a value. The previous version skipped the field entirely, which made Obligatoriu silently do nothing.
  • The plaintext never reaches audit_log. profileToAudit strips it and records national_id_on_file instead; the table is plaintext, append-only and retained six years, so there is no taking it back out. Pinned by a mutation-verified test.

requires_national_id is the whole gate, in both directions. A cnp key only reaches RenderableProfileFields from a published template that set it, so an un-opted-in template can never cause a decryption, and the plaintext never exists in the process. The patient's OWN /me response does carry it decrypted: GDPR Art. 15, and hiding a person's national ID from that person is inconvenience rather than privacy.

Added an egress target, patient_document, because none of the seven existing ones described the PDF renderer. Known gap recorded in the registry: RenderableProfileFields is hand-built SQL and does not call AllowedFor — P39 says egress paths must. Folded into A2.

CNP security posture — the whole of it, in one place

SurfaceWhoGate
WritePatient onlyRLS patient_profiles_update_self_or_caregiver. A clinic-entered answer is refused out loud, not skipped quietly
Read — clinic recordstaffpatients.view (masked) + patients.view_national_id to reveal, which writes an audit row
Read — a formstaff or patienta patients row at the org, or the caller's own profile. No audience branch
Read — patient's ownthe patient/me/* only, ungated. GDPR Art. 15
Read — a documentthe renderera published block selecting the cnp field
Searchstaffpatients.view, matched against the national_id_hmac blind index

patients.view and nothing more, deliberately. A CNP is ordinary Art. 6 personal data; the diagnoses and allergies every staff member already reads are Art. 9 special category, the higher bar. Law 190/2018 art. 4 asks for technical and organisational measures — DPO, retention, training — not a dedicated permission code. Gating the identifier harder than the health record it accompanies is backwards.

Where it can never appear, and why that holds:

  • custom_field_values.value — database trigger (000042)

  • forms.values — database trigger (000043)

  • Logs and audit_logredact masks any key normalizing to nationalid or cnp, and it feeds BOTH the slog handler and the audit JSONB writer. Three hand-written exclusions also strip it (profileToAudit, patientToAudit, and the forms service stripping the key before applyPatch can put it in a values diff), but the class rule is what holds when someone adds a fourth call site.

  • Any URL, on either hop. The clinic search posts the CNP through a server action, and that action calls POST /patients/lookup — a body again. ?q= still serves names (pii_basic).

    Both halves were needed. The browser hop mattered because ALB access logs record the full request line into S3. The internal hop was GET ?q=<cnp> and nothing logged it — VPC service discovery rather than the ALB, and the Go service records r.URL.Path alone — but that is a property of the current logging CONFIGURATION, not of the design. Turn on request logging, add tracing, or wire an error reporter into the clinic app (Sentry is in the portal only today) and a regulated identifier starts being captured. A body cannot be captured that way.

  • Error messages — invalid_national_id never says which part failed. A CNP encodes a birth date and a county, so a validator that distinguishes "bad checksum" from "bad month" is an oracle for guessing one.

  • Egress registry — national_id_encrypted allows only bulk_export + patient_document; national_id_hmac allows nothing.

Masked in the clinic UI, plain in the Portal. Every clinic surface that shows a CNP — the patient header and the form record — renders it behind a reveal control (RevealableValue). The patient's own Portal views do not: it is their identifier on their own record, and hiding it from them is friction dressed as privacy.

That is not cosmetic. Permission answers "may this person read it"; masking answers "must they read it right now, for the thing they opened this page to do" — and almost nobody opening a patient needed the CNP, including everyone behind them at reception, in a screen share, or in a support screenshot. The second reason matters more: revealing is a discrete, deliberate action, which is the hook read auditing needs. onReveal on that component is where the audit call goes the moment the platform has a read verb, and nothing else has to change.

The mask is a fixed run of dots, never a partial value — showing the last four digits of a CNP would disclose the check digit and part of the county code for free.

Read auditing — CLOSED for the CNP, 2026-08-07

audit.ActionRead exists, and every staff disclosure of a CNP writes a row.

It needed no migration. audit_log.action is TEXT with no CHECK constraint, so a new verb is a pure Go change. I said "a read verb plus per-env catch-up DDL" several times before checking; the DDL half was wrong.

The design point that makes it real: auditing a reveal CLICK on a value the browser already holds records intent, not access. So staff payloads carry has_national_id and never the value — GET /patients/{patientId}/national-id is the single path that discloses one, and it is the single place that records it. The clinic's reveal control fetches through that endpoint; nothing is in the page until someone asks.

The row is action=READ, entity_type=patient_national_id, entity_id=<patient>. A distinct entity type, not patient — filed under patient it would sit in the same bucket as every other patient row and be distinguishable only by request_path, which is incidental metadata rather than the meaning of the event. "Show me every read of a national identifier" should be one WHERE entity_type = ..., not a LIKE over paths that breaks the day a route is renamed. Same reasoning that split form_write_back out of form.

It fails closed: if the audit row cannot be written, the value is not returned. A miss (none on file, or profile not shared) returns null and is NOT audited — no access occurred, and "someone looked and saw nothing" is noise in a table an auditor has to read. The row carries actor and patient, never the value.

Not a general read log, deliberately. Auditing every SELECT would bury the rows that matter in a table kept six years. The rule for a new call site: would a supervisory authority ask who accessed this specific value? Today that is one thing.

The patient reading their own CNP in the Portal is not audited — their record, and a row per person-looks-at-themselves is noise.

Opening a patient page is NOT audited, and that is a decision. General record-access logging is a different feature at a different scale: every staff member opening every patient, forever, in an append-only table kept six years. GDPR asks for measures proportionate to risk, not a log line per read — the narrow class that earns one is values where the DISCLOSURE is the harm. It is worth revisiting for HIPAA readiness (§164.312(b) expects record-access logging) if US clinics ever come into scope, and it would want its own table and retention rather than sharing audit_log.

Not done, deliberately: no staff-entered CNP path (it would break the patient-owned-profile invariant), and NormalizeCNP validates the Romanian CNP only — a second country's identifier is a new function, not a loosening of this one.

See data-model.md Area 11. Migrations 000047 + 000048. Depends on F3 (the signed form is the source of truth) and F5 (documents attach to appointments).

⚠️ Reject leo's document_templates design — this is settled, not open. restartix-leo-api/docs/go-migration/08-document-generation.md specifies per-org HTML/CSS templates with margins, and the go-migration survey recommends it as "the best starting point for the new F6 Documents spec." It is not. data-model.md records that both designs were evaluated, the block-based pdf_templates design won, and document_templates was deleted. Architecture docs beat foreign migration docs. Mine leo's document only for its funcmap, its DocumentData struct shape, and its error→HTTP mapping.

Rendering — settled 2026-08-06, from measurement

@react-pdf/renderer v4, server-side in Node, synchronous, rendered in apps/clinic. No headless browser.

The prior framing (chromedp / headless-shell vs. Gotenberg vs. "Go-native") missed the option that was already in production: leo renders with @react-pdf/renderer in the staff browser, and that same library runs in NoderenderToBuffer() in place of pdf(doc).toBlob(). leo's template components port near-verbatim instead of being rewritten as HTML.

Measured on one representative A4 report (D4 geometry, fixed header/footer, Pagina n / total, RO clinical prose, base64-inlined signature). The two container engines ran under --cpus=0.5 --memory=768m, the budget a sidecar could take inside the production API task (1024 CPU / 2048 MB):

@react-pdf in Nodeheadless-shell + chromedpGotenberg
Warm p5041 ms193 ms410 ms
Warm p9551 ms602 ms570 ms
Worst single51 ms602 ms1.96 s
Footprint31 MB node_modules518 MB image2.45 GB image
Reuses leo's templatesyesno — rewrite as HTMLno — rewrite as HTML

Two figures in the old open item were wrong: the headless-shell image is 518 MB, not ~150 MB, and Gotenberg is 2.45 GB because it bundles LibreOffice this platform will never invoke. Memory was a non-issue for every option (all under 175 MB even saturated), and the ffmpeg-reads-host-cores failure did not reproduce — throughput scaled with the CPU quota. CPU was the only constraint, and @react-pdf barely touches it.

Synchronous, not queued. A clinician waits ~41 ms. Queueing that would attach the whole exercise_renders claim / attempts / backoff / dead-letter apparatus to hide a sub-50 ms wait, and D8 requires the artifact to download immediately — which a queue breaks.

Renders in apps/clinic, not a dedicated service. Generation is staff-only (the Portal reads documents via presigned URL and never generates one), so a route handler in the clinic app costs zero new infrastructure. A fourth Node deployable would be a service to size, alarm, deploy and pay for — and prod already shows that cost, with media stuck at desired_count = 0. It also puts the block editor's preview and the final render on one code path, making preview-vs-final drift structurally impossible; a sidecar or separate service reintroduces it.

Why server-side at all, when leo's client-side render already auto-uploads (report-actions.tsx does toBlob() → upload → link → download; the storage path is not the gap):

  • Determinism. One Node version, one font set, one library version, ours. A browser render depends on whichever Chrome and fonts the staff member has — and a document that must be reproducible from a frozen pdf_template_version cannot depend on that.
  • The CNP never reaches a staff browser. With CNP patient-only (below), the API filters through classification.AllowedFor on the way to the renderer.

Recorded, not solved: service_accounts does not exist (000002 carries forward-looking comments only) and every clinic-app call to the API carries the user's bearer token, so the API validates preconditions — specialist signature on file, template version resolvable, appointment state — rather than attesting the bytes it receives. This is upgradeable when Cat F service accounts ship. Note it applied equally to a dedicated Node service, so it never discriminated between the options. Separately: there is no path to generate a document without a staff session (bulk backfill, scheduled generation). Acceptable today.

⚠️ Romanian diacritics are silently dropped by default — this is a required build step, not an optional one. With @react-pdf's built-in font the output reads "Raport de consultaie", "Bucureti", "Reedina": ș and ț vanish with no error, while â survives (which makes it worse — it looks like it works). leo already fixes this with Font.register({ family: "Inter", ... }), but registers by browser URL (src: "/fonts/Inter-Thin.ttf"), which resolves to nothing server-side; the register needs a real file path or a loaded buffer. Verified: with Inter registered from disk, "consultație" and "București" render correctly, at a cost of 24 ms → 41 ms and an embedded font subset per PDF. Any renderer choice owes this a test asserting ș/ț/ă/î/â survive a round-trip.

F6.1 PDF Templates

  • [ ] pdf_templates (with versioning, editor_state JSONB, layout_config JSONB).
  • [ ] pdf_template_versions (append-only — no UPDATE/DELETE policies, C9).
  • [ ] pdf_template_components (reusable blocks: letterhead, footer, signature).
  • [x] CNP on a patient_details block. No opt-in control: selecting the field IS the decision. pdf_templates.requires_national_id survives as a DERIVED column, recomputed from the blocks on every write and refused by the API, so a DPO can ask "which templates print a CNP" without a JSON path into the block config.
  • [ ] Cross-tenant template sharing is P49 platform-tier + clone. Never move. leo authorises cross-tenant template copy/move on a role-string check, and move re-parents historical clinical documents across a tenant boundary (G16).

F6.2 Document Generation

  • [ ] pdf.Renderer capability in internal/core/pdf/, registered via capabilities.WrapInternal — the glossary's own worked example of a capability. The Go side owns the contract and the record, not the rasterisation: it assembles DocumentData, calls the clinic-app renderer, and remains the only writer of appointment_documents.
  • [ ] Pipeline: signed form + frozen template version → DocumentData@react-pdf component tree → PDF → s3.SurfaceDocuments (already registered). No HTML step — the block editor's editor_state JSONB is walked directly into @react-pdf primitives.
  • [ ] Server-side rendering only. leo renders PDFs client-side in the staff browser and pulls images by remote URL (C14). Images are fetched server-side and base64-inlined.
  • [ ] Register a Romanian-capable font from a real file path and assert ș/ț/ă/î/â survive a render round-trip. Default fonts drop them silently — see the rendering banner above.
  • [ ] Digital signature embedded base64 — PDFs are self-contained, no external URLs (hard compliance rule).
  • [ ] Prescription generation refuses without a specialist signature → typed 422, with a test that asserts it.
  • [x] Route the patient-demographics block through the renderer's own resolver, not as a template-authoring convention — it scopes to a patients row at the printing org. leo prints full demographics unconditionally for any patient at any franchise (G23). (Originally specified as the P8 profile_shared gate; that gate was removed 2026-08-20 and the org scoping is what survived.)
  • [x] Egress via classification.AllowedFor(table, target) / classification.Filter — never a hand-built field list (P39). Enforced at BUILD time instead, 2026-08-07 (A2). No egress path calls AllowedFor at runtime and the registry markdown is not in the API image, so the guarantee is "code and registry agree when the build passes". cmd/check-classification asserts every printable field's backing column permits patient_document; it caught four live mismatches on its first run. The runtime call belongs with F11's DSAR export, where the field list is data rather than a fixed catalog.
  • [ ] Presigned reads (15 min) + a document.pdf_accessed audit row.
  • [ ] Generated PDF caching strategy; document_url stores the S3 key, not a URL.
  • [ ] Port prepareReportPDFData as ONE type-parameterised builder — leo duplicates it across report and prescription.
  • [x] Decide (open): PDF rendering engine + sync-vs-queuedSETTLED 2026-08-06 from measurement: @react-pdf/renderer server-side in apps/clinic, synchronous. See the rendering banner at the top of F6. No Chrome, no queue, no new ECS service — so the SOUP row is a package.json dependency rather than a container image, and there is no ECS sizing question to answer.

F6.3 Appointment Documents

  • [ ] appointment_documents — reports + prescriptions unified by type ∈ report | medical_prescription (never bare prescription — see glossary.md → Two senses of prescription), UNIQUE (appointment_id, type), pdf_template_version frozen at generation, generated_by_principal_id, published, document_url (S3 key).
  • [ ] Regeneration produces a new row, never an in-place file swap (C9).
  • [ ] appointment_document_files (additional supporting files).
  • [ ] Postural-analysis and other clinical images attach to the appointment, not to the report entity (D6) — leo's report-bound model forces a hard dead-end ("a report must exist first").

F6.4 Form-to-PDF Wiring

  • [ ] form_templates.pdf_template_id FK populated.
  • [ ] Render-to-PDF API on signed forms.

Business rules carried from leo (port map §4.4):

#Rule
D1Projection algorithm, in this order: flatten groups → prune is_private (a private group hides all children) → prune empty values → elide now-empty groups.
D2Age is computed at the appointment date, not today.
D3Prescriptions show ALL fields to the patient; reports filter private fields. RETIRED 2026-08-07 (A2). Private is private on every document type — the carve-out is gone from the port, the adapter, the contract and the UI copy.
D4A4 geometry: paddingTop 140 / paddingBottom 65 to clear the fixed header and footer; footer reads "Pagina n / total".
D5The prescription layout is the report layout minus "Servicii efectuate" and minus the support box.
D7Goniometer measurements are written as form values keyed <key>_left / <key>_right / <key>_file.
D8Generate/Regenerate wording differs, and the produced artifact downloads immediately.
D9"Reports pending" = appointments where ended_at < now() AND no published report, self-scoped when the caller is a specialist. This is what actually drives report completion in a real clinic.
T6Upload UX: full-surface click target + "N / M documents uploaded" progress + per-file failure surfacing. leo completes the progress bar silently for failed files — fix that.
T7Patient document policy copy: upload 12h before the appointment, and uploading "does not guarantee they will be reviewed".

F6 — clinic feedback from the first working build (2026-08-07)

The builder and generation were exercised end to end against a real clinic appointment. Everything below came out of that session. None of it is a defect — the six items are the gap between "a document generates" and "a clinic would actually use this".

Ordered by how much they change the design rather than by size.

1. The patient-details block is too narrow, and conflates three sources

CLOSED 2026-08-07 (A2). Kept as written because the diagnosis is the record of why the design is what it is. See the A2 section in the F6 STATUS block above for what shipped — including the four registry mismatches this item's last paragraph turned out to be pointing at.

pdftemplates.PatientFields is a flat list of twelve keys that quietly mixes three different origins:

OriginExamplesWhere it lives
Patient profile (portable, patient-owned)name, date_of_birth, residence, occupation, phonepatient_profiles, scoped by the printing org's patients row
Appointment (derived, not stored on the patient at all)age_at_appointment, appointment_date, specialist_name, offering_namecomputed in addDerived
Org-scoped custom fieldsanything the clinic defined in F3custom_field_valuesentirely absent today

An author sees one undifferentiated checkbox list, and the clinic's own custom fields — the whole point of F3's field library — cannot be printed at all.

What this needs: the block (or a set of blocks) should present fields grouped by source, and the source list should include custom_fields.

The hard part, and why this is not a small change. The allow-list is currently a compile-time Go slice, and that is precisely what makes it safe — RequestedPatientFields re-checks every key against it so a stale snapshot cannot smuggle one through. Custom fields are per-org and defined at runtime, so the list becomes dynamic. It stays bounded (an org can only select its own fields) but "bounded" and "enumerable at compile time" are different guarantees, and the re-check has to become a live lookup rather than a slice membership test. CNP stays excluded regardless — custom_field_values already refuses national_id by trigger.

2. Conditional content — leo's checkbox workaround needs a real mechanism

In leo, showing something only for certain patients was done by adding a checkbox field to the form and branching on it. That is a workaround, and it is the shape of a real requirement.

The direction (user's, 2026-08-07, not yet designed): a template declares optional blocks, and the GENERATE step presents them as checkboxes — the clinician picks what appears in this particular document.

Two consequences worth stating before anyone builds it. Whichever way the choices are recorded, they must land in the FROZEN record: a document must answer "why does this one have the falls-risk warning and that one not" years later, so the toggles belong in appointment_documents.metadata or the version snapshot, not in transient UI state. And a block hidden at generation must not be resolvable afterwards — hiding it in the renderer while still passing its data into the render context would put withheld content one client-side toggle away from being read.

Deliberately not designed yet — the user's own framing is to experiment first.

3. Template preview with mock data

The builder shows a block list; the author cannot see the document until they publish and generate against a real appointment. A preview rendered from sample data would close that loop.

Cheap now that the renderer exists: it takes a render context, and a mock one is a fixture. The care needed is that the preview must use the SAME renderer as generation — a second preview implementation would drift, and an author would be laying out against a lie.

The palette offers one letterhead and one footer, singleton per template. The user wants variants — different header and footer STYLES to choose between.

Note the two axes do not conflict: singleton-per-template stays correct (two fixed blocks would draw over each other on every page). What is missing is a choice of style within the one block. pdf_template_components was built for exactly this — saved, pre-configured block groups — but has no UI, so today it is a table nothing writes.

5. "Număr registru" is invented, and there is no patient identifier

The sample document prints RG-<last 8 of the appointment UUID>. The platform has no human-readable patient identifier at all — verified against the schema: patients carries id, patient_profile_id, consumer_id (the legacy-import field) and timestamps. No number, no MRN, no register column. leo printed one; where it came from is not yet established.

This is a schema decision, not a rendering one: a per-clinic sequential patient number is a column and a uniqueness constraint, and it must be decided before any clinic starts quoting one to patients — a number that changes meaning later is worse than no number.

6b. Per-block form source — repeated answers blocks print the same form

Found in use immediately after F6.4 shipped: two form_answers blocks in one template render the SAME answers under two headings, because generation resolves ONE form for the whole document and every block prints it.

form_answers is therefore a singleton for now, and the restriction is temporary in a way the other three are not — letterhead, footer and signature are singletons for a layout reason (two fixed blocks draw over each other), this one only because the feature behind it does not exist.

The feature: a form_answers block declares its OWN source — "the intake questionnaire here, the mobility assessment there" — and generation resolves one form per block rather than one per document.

Two consequences worth stating before it is built. It largely subsumes the generate-time form picker: a template that declares its sources does not need a clinician to choose them, and the picker would shrink to an override for the unusual case. And the per-block source has to be a form TEMPLATE rather than a form instance — the template is authored once and the instance differs per appointment, so binding to an instance would make a template work for exactly one consultation.

Un-restricting is a one-line edit to SingletonBlockTypes, which is why restricting now forecloses nothing.

⚠️ A template already carrying two answers blocks will be refused on its next save with duplicate_singleton_block until one is removed. That is the intended signal, not a migration failure.

6. The form-answers block does nothing yet

Correctly diagnosed by the user: Răspunsuri din formular renders nothing because F6.4 is not built. form_templates.pdf_template_id has its FK but no UI sets it, and no caller passes form_id to generation. This is the one item on this list that is simply unfinished rather than a design gap.

Non-code assets to extract now — independent of build order, and the genuinely irreplaceable part:

  • [ ] restartix-leo-dashboard/core/utils/report-templates/default.tsx — the exact Romanian label set (Nume pacient, Vârsta la consult, Reședința, Ocupația, CNP, Telefon, Număr registru, Nume specialist, La data de, Servicii efectuate, Pagina n / total) and the A4 geometry above.
  • [ ] nutritional.tsx + nutritia-durerii.tsx (~1,180 lines) — clinician-authored Romanian annex prose. This is domain content, not code. It must not live in TSX again: park it as seed content for platform-tier pdf_templates, with strings through next-intl.

Open, not decided here:

  • [x] The prescription naming collisionalready settled in glossary.md → Two senses of prescription; this item was stale. The shipped exercise-program sense keeps the bare word (it is live across a DB CHECK, a unique index, a seeded permission code, an entitlement code, a Go constant, a wire enum and patient-facing copy — renaming it is a cross-surface break with a 000023 catch-up script attached, for zero gain). The F6 document type is always medical_prescription, which costs one enum value chosen before the migration is written. F6.3 below already spells it correctly.

Exit criteria: Specialist generates report/prescription PDFs from signed form data, with the specialist signature embedded base64 and the template version frozen. Prescription generation fails closed without a signature. Documents stored in S3, downloadable via short-lived signed URLs, every access audited.


F7. Automations & Webhooks

OUT OF SCOPE (settled 2026-08-02). Kept as a design record, not active scope. See platform-completion.md → Scope.

Two consumers of the event bus from 1A.9. See data-model.md Areas 12, 13. Note that Cat C outbound webhook subscriptions already shipped in foundation 1C.4 — F7.3 below predates that and overlaps it.

F7.1 Event Bus Consumers

  • [ ] Automation engine: matches events to enabled automation_rules, executes actions.
  • [ ] Webhook dispatcher: matches events to webhook_subscriptions, delivers signed payloads.

F7.2 Automation Rules + Executions

  • [ ] automation_rules table.
  • [ ] automation_executions (append-only audit trail).
  • [ ] Decide: Email/SMS/push transport providers — AWS SES, SendGrid, Postmark; Twilio, AWS SNS.
  • [ ] Action handlers: require_form, suggest_form, send_email, send_sms, send_whatsapp, show_notification, send_push, block_booking, grant_access, require_consent, update_segment, schedule_action, send_session_reminder, book_followup_appointment.

F7.3 Webhook Delivery

  • [ ] webhook_subscriptions (HMAC signing, signing_secret generated server-side as whsec_...).
  • [ ] webhook_events (delivery log, retry with exponential backoff, idempotency).
  • [ ] Delivery worker process.

Exit criteria: Onboarding automation works end-to-end (patient.onboarded → required consents + welcome email). Webhooks deliver signed payloads with retry and idempotency.


F8. Segments

OUT OF SCOPE (settled 2026-08-02) — belongs to a later patient-data-segmentation feature. Kept as a design record.

⚠️ The trade this section recorded has been reversed (2026-08-08). It read: "leo's filter-by-form-answer dialog is in daily staff use, so its absence is a visible regression when clinics migrate. That is a known, accepted trade." It is no longer accepted — that dialog is now F15 Advanced Filtering, and it is in scope.

What stays out of scope is the persistence half, not the rules. F15 owns the rule language and the live evaluator; F8.1's segments / segment_members / segment_versions, the materialisation and the event-bus auto-update are still deferred. When F8 is built it consumes F15's rule language unchanged — a second rule language would let a saved segment and an ad-hoc filter answer the same question differently, and nobody finds out until a cohort is wrong.

A segment is not a saved filter — its payload is MEMBERSHIP HISTORY (2026-08-08). That is the distinction that decides when F8 comes back. A filter answers "who matches right now, so I can act on this list"; a segment answers "keep telling me who matches", and segment_members with entered/exited timestamps is what makes "how many patients left the high-pain cohort this quarter" answerable at all.

This is also what F16.2 cohort evolution depends on. Aggregating today's cohort backwards answers "how did people who match now used to look", not "how did this group change" — the classic survivorship error, and it produces confident, wrong clinical trends. If cohort evolution is genuinely wanted, F8 returns to scope; it is not optional decoration on F16.2.

Patient cohorts driven by rules over forms + custom_field_values + appointments. See data-model.md Area 8.

F8.1 Segments

  • [ ] segments (rules JSONB, match_mode, version).
  • [ ] segment_members (materialised cache).
  • [ ] segment_versions (append-only history).

F8.2 Rule Engine

  • [ ] Multi-source rules: forms.values + custom_field_values + appointments.
  • [ ] Tiered evaluation.
  • [ ] Auto-update on data changes (event bus consumer for form.signed, appointment.completed, patient.profile_completed).

Exit criteria: Segments auto-update on relevant data changes; admin UI builds rules visually.


F9. Programs, Assignments & Patient Stats

Exercise library + programs + assignments + cadence engine + patient stats surface. First feature inside the regulatory boundary — anticipated Class I MDR posture (IEC 62304 Class A/B), designed for Class IIa-upgrade if/when treatment decisions are driven from the data. The Class I posture means the system displays informational data; the specialist makes clinical decisions. See CLAUDE.md → Medical Device Readiness, data-model.md Areas 9-10, and telemetry/index.md → Aggregation engine.

Canonical spec: features/programs-and-assignments/. The old F9.2 Treatment Plans / F9.3 Patient Enrollment design is supersededtreatment_plans / patient_treatment_plans / patient_session_completions tables don't exist; the content hierarchy is exercisessessions (kind: exercise | audio) → optional program_phasesprograms, and the patient-side is unified into protocols (polymorphic: prescription | enrollment) with a two-cadence engine (flexible | scheduled) along orthogonal supervision_mode (unsupervised | supervised) and per-appointment channel (in_person | online_live) — see cadence & supervision (2026-05-28 redesign, replaces the earlier 3-cadence + modality shape). F9.1 Phase 2's dual-ownership work is folded into F9.2 below.

Sequenced as Phase 1 (substrate, shipped) → Phase 2 (launch features). Phase 3 deferred items live in the canonical spec.

The June-10 deliverable was the narrow single-session demo (june-demo.md, SHIPPED 2026-06-10 to production). The full F9.3 / F9.4 prescribe + program + cadence + stats scope is the multi-clinic generalisation of the items the demo cut back to a single owners-clinic slice. Substantial parts shipped during the 2026-06/07 F-tier wave — the checkboxes in F9.3 / F9.4 predate that and are stale.

F9.0 Regulatory Habits (start of F9, maintain throughout)

  • [ ] Add requirement IDs to exercise library spec (REQ-EX-001: System shall display contraindications).
  • [ ] Add requirement IDs to programs-and-assignments spec (REQ-PA-001: System shall display prescribed sessions with correct parameters).
  • [ ] Add "Safety Implications" section to exercise + programs-and-assignments feature docs.
  • [ ] Reference requirement IDs in test comments.

F9.1 Exercise Library

Phase 1 — composer integration + cache (shipped, commit 3d95e38)

  • [x] Decided: Bunny Stream for delivery + AWS S3 for raw primitives. The exercise video composition pipeline is documented in P56 and features/exercise-library/composition.md.
  • [x] exercises table — minimal Phase 1 shape: slug, kind (reps_based | duration_based), status (draft | published | archived), asset_version, default_preview_render_id, video_collection_id. Platform-curated (no organization_id), RLS SELECT for any authenticated principal, AdminPool-only writes.
  • [x] exercise_renders cache table — keyed by (exercise_id, recipe_hash, language)video_id. Both kinds use it; duration_based has one row per language with recipe_hash='_imported'.
  • [x] Media service (services/media/) — Go service, S3 bundle → ffmpeg → Bunny upload + per-slug collection auto-create.
  • [x] Admin endpoint POST /v1/admin/exercises/{slug}/renders — Console-only render trigger with cache lookup.
  • [x] Shared-secret bearer-token auth between API ↔ media service.

Phase 2 — taxonomy + pose-tracking (scope expanded 2026-05-25; one feature, ordered sub-phases A → B → C, with Console UI as sub-phase D)

Scope expansion 2026-05-25

F9.1 Phase 2 now bundles taxonomy + pose-tracking schema as one feature with three ordered build sub-phases (A → B → C), plus Console UI as sub-phase D. Each sub-phase ships independently. Authoritative design at exercise-taxonomy-pose-tracking.md (locked 2026-05-25). Schema details in data-model.md Area 9. The original Phase 2 backlog (the locked F9.1 design at composition.md) is subsumed by sub-phase A; dual-ownership stays absorbed into F9.2 per the P49 catalog-ownership pattern.

Sub-phase A — Taxonomy schema + Class IIa columns

  • [ ] Migration: Class IIa columns (tagged_by_principal_id, tagged_at, clinical_basis) on existing tag-association tables (exercise_tags, exercise_contraindications, exercise_instructions).
  • [ ] Migration: per-tag deprecation columns (deprecated_at, replaced_by_id) on existing tag entities (exercise_categories, exercise_body_regions, exercise_equipment) + DB trigger enforcing "never modify in place" (per D4).
  • [ ] Migration: CHECK constraint locking exercise_body_regions to platform-only (per D5).
  • [ ] Migration: new tag entities (exercise_movement_patterns, exercise_recovery_phases, exercise_skill_prerequisites — platform-only; exercise_conditions — dual-scope with nullable icd10_code) + extended exercise_tags.tag_type ENUM (per D2 / B5).
  • [ ] Migration: exercise_prerequisites self-M2M table.
  • [ ] Migration: migrate exercise_contraindications.condition_name freetext → condition_id FK to exercise_conditions (per B5).
  • [ ] Go domain: internal/core/domain/exercises_taxonomy/ (model, repository, service, handler, errors).
  • [ ] API: tag CRUD endpoints (GET/POST/PATCH/DELETE /v1/admin/exercises/{id}/tags) covering all axes via tag_type + vocabulary endpoints (GET /v1/exercises/vocabulary/{axis}).
  • [ ] RBAC: new permission codes (catalog.tags:write for platform/superadmin, catalog.tags:write_private for per-org clinic admin).
  • [ ] Data-classification registry entries for all new columns (covered by sibling chat 2D — see data-classification.md).
  • [ ] Audit: every tag CRUD audit-logged per CLAUDE.md.
  • [ ] RLS policies on new tag entity tables (organization_id IS NULL OR organization_id = current_app_org_id() union).
  • [ ] Tests: integration tests covering tag CRUD + RLS + audit.

Sub-phase B — Pose-tracking foundation (reference tables only)

SHIPPED — migration 000028_pose_tracking_foundation. The unticked boxes below were stale; ticks reflect a direct check against the schema and the code on 2026-08-08. This is the authoring half of pose tracking and it carries no MDR scope — see the F10 status banner for where the regulatory line actually falls.

  • [x] Migration: pose_engines reference table, seeded with mediapipe.holistic.
  • [x] Migration: pose_landmarks reference table, seeded with the MediaPipe holistic catalog (~543 landmarks).
  • [x] Go domain: internal/core/domain/poseengines/ (read-only). Note the spelling — the package is poseengines, not pose_engines as this line originally specified.
  • [x] API: read endpoints (GET /v1/pose/engines, GET /v1/pose/engines/{id}/landmarks?body_part_category=...). Wire types mirrored in packages/api-client/src/pose.ts.
  • [ ] Static seed data file: services/api/seed/pose_landmarks_mediapipe_holistic.jsonbuilt differently, deliberately. There is no services/api/seed/ directory; the catalog is seeded by four INSERT INTO pose_landmarks statements inside 000028 itself. Re-seeding is therefore a migration replay rather than a file re-import. Kept visible rather than ticked because the reproducible-re-seed property the file was for does not exist in the same form.
  • [x] Tests: RLS/integration coverage at internal/test/rlstest/poseengines_test.go.

Sub-phase C — Pose-tracking per-exercise config

SHIPPED — migration 000029_pose_tracking_per_exercise, with the Console editor in sub-phase D. Ticks below reflect a direct check on 2026-08-08; the four items left unticked are unverified, not known- missing — they need a read of the handler and service layer to confirm.

  • [x] Migration: exercise_pose_configs (1:1 with exercises per D8) — all columns per data-model.md Area 9 (D15/D16/D17/D20/B2 columns + Class IIa cols + pinned_asset_version + status enum).
  • [x] Migration: exercise_pose_config_history (immutable full-row snapshots per D8).
  • [x] Migration: exercise_pose_landmarks + exercise_pose_metrics + exercise_pose_feedback_rules (per-config detail tables).
  • [x] Migration: DB trigger on exercises.asset_version UPDATE that flips pose-config status to invalidated and reverts tracking_enabled to FALSE (per D9) — trigger_invalidate_pose_config_on_asset_version() + exercises_invalidate_pose_config_on_asset_version.
  • [x] Migration: clone copy-on-clone function for pose configs (per D9 clone behavior) — clone_pose_config_for_exercise().
  • [x] Migration: pose_data_quality_overrides table (per B3) + CHECK constraint enforcing scope/event-id shape.
  • [x] Go domain: internal/core/domain/pose_configs/.
  • [x] API: pose config CRUD (GET/POST/PATCH /v1/admin/exercises/{id}/pose-config) + sub-resource endpoints for landmarks/metrics/feedback rules + override endpoint (POST /v1/admin/sessions/{run_id}/pose-override).
  • [ ] App-layer validation: orphan landmark/metric prevention per B4, weight_pct sum = 100 per config, JSONB params validated per rule type, condition_expression syntax (text_v1 = freetext for F9.1 Phase 2; DSL deferred per DF1). Unverified.
  • [x] RBAC: new permission codes — shipped as catalog.pose_configs.manage, catalog.pose_configs.invalidate, clinical.pose_overrides.create and clinical.pose_overrides.view. Four codes with a . separator, not the three :-separated ones this line specified; manage replaced write, and a view code was added.
  • [ ] Audit: every pose config CRUD + every override audit-logged (Class IIa requirement). Unverifiedcmd/check-audit-coverage would have caught a missing mutation handler, so this is likely satisfied, but it has not been read.
  • [x] RLS policies on new pose-tracking tables; override scope checked at app layer — internal/test/rlstest/pose_configs_test.go.
  • [ ] Tests: integration tests covering pose-config CRUD + asset_version invalidation trigger + clone behavior + override. Partially verified — RLS coverage exists; trigger + clone + override coverage unconfirmed.

Sub-phase D — Console UI (Wave 5; depends on A + B + C APIs)

  • [ ] Tag management UI (CRUD across all new axes).
  • [ ] Vocabulary picker components (per-axis, async typeahead per CLAUDE.md picker rule).
  • [ ] Exercise edit form: tag chips, condition picker with ICD-10 lookup, prerequisite chains.
  • [ ] Body-map filtering for clinic-side exercise browsing.
  • [ ] Pose-config authoring surface: camera setup (D16), landmark subset with body silhouette (D17), metrics editor with weight-summing validation (D18), feedback rules editor (D19), rep success rule editor (D20).
  • [ ] "Asset re-filmed — pose config invalidated" UI flow (per D9).
  • [ ] Pose config history viewer (read-only, per D8).
  • [ ] Register surfaces in 1d-ui-inventory.md.

Notes on what's DEFERRED (NOT in F9.1 Phase 2)

Per the design doc's deferred items:

  • DF1 — condition expression DSL: pose-aggregation engine dependency.
  • DF2 — validation metrics shape: aggregator dependency.
  • DF3 — Sofia AI auto-config: no schema impact; build later if scoped.
  • DF4 — per-vocabulary org-private extension Console UI: schema + API ship in sub-phase A; only Console UI deferred.
  • DF5 — specialist signoff workflow for contraindications: Class IIa preparation work.

Separately out of scope (other F-tier work, not this feature):

  • Pose-aggregation engine on the telemetry side (the POST /v1/pose/frames ingest + aggregator pipeline) — separate F-tier work, unscheduled.
  • Patient-side pose-tracking UX in Portal — consumes the configs shipped here; depends on the engine above.
  • Program-builder integration with the new taxonomy — separate F-tier; consumes tag data.

The original locked Phase 2 backlog (translations JSONB, difficulty rating, Console exercise CRUD, asset_version bump endpoint, patient catalog endpoint, composer queue wrapper, duration_based import workflow, Cat A Bunny resolver) is subsumed by sub-phases A + D above and the existing exercise-library spec — see features/exercise-library/composition.md → Phase 2 backlog for the implementation-detail view.

F9.2 Programs & Assignments — Phase 1 substrate (SHIPPED 2026-05-22/23, reshaped 2026-05-28)

Schema + plumbing. No new user-visible features. Settles the foundation so Phase 2 launch features and Phase 3 evolution don't migrate patient data later. Canonical spec: features/programs-and-assignments/.

Checkboxes below describe the original substrate plan — significantly reshaped 2026-05-22 + renamed 2026-05-23

The substrate shipped, but the actual shape diverges from the bullets below: program_sessions junction + program_versions + session_versions retired (replaced by sessions.program_id + three-tier copy-on-derive); patient_assignments renamed to protocols; assignment_pausesprotocol_pauses; session_runs.assignment_id dropped (derivable via sessions.program_id chain); session_runs.session_id + patient_id made nullable + ON DELETE SET NULL for the patient-erasure cascade; programs.derived_from_program_id added for org-tier variant lineage. See features/programs-and-assignments/ → "Three-tier copy-on-derive model" + "Implementation status" for the authoritative shape.

Content substrate (shipped shape diverges from the original plan — see the warning box above; checkboxes corrected to reality)

  • [x] content_files registry — kind (audio | video | image | document), ownership_kind (platform | org), nullable organization_id, storage_provider / storage_ref / mime_type / file_size_bytes / duration_seconds / metadata JSONB. The single registry for consumable media. Does NOT store exercise source clips (those stay in S3 private) or patient uploads / generated documents (separate future tables).
  • [x] exercises dual-ownership refactor (formerly F9.1 Phase 2 dual-scope) — add nullable organization_id + ownership_kind ENUM + RLS visibility union + partial unique indexes for slug per tier. Backfill existing rows to ownership_kind='platform'. Per the P49 catalog-ownership pattern.
  • [x] exercise_renders refactor — add NOT NULL content_file_id FK to content_files. Backfill from existing storage refs (deterministic). Media service updated to create both rows atomically on new renders.
  • [x] sessions extensions — add kind ENUM (exercise | audio, default exercise), ownership_kind (platform | org | patient_specific), nullable patient_id plus copy-on-derive program_id / phase_id (the three-tier copy-on-derive model replaced the program_sessions junction). CHECK constraint enforces tier shape.
  • [x] session_audio_items (new) — playable items in kind='audio' sessions; references content_files with kind='audio'. Schema present; no rows at the demo cut.
  • [x] session_assets (new) — downloadables attached to a session; references content_files.
  • [x] programs (new) — multi-session container; three-tier ownership; status (draft | published | archived); derived_from_program_id for org-tier variant lineage. Programs are kind-agnostic; sessions inside can be exercise + audio.
  • [x] program_phases (new) — optional ordered subgroup within a program; entry_criteria JSONB forward-compat for future advancement rules.
  • [x] program_sessions junction retired — sessions carry program_id + nullable phase_id directly via copy-on-derive (a session can be in a phase or flat in the program).
  • [x] program_assets (new) — downloadables attached to a program.
  • [x] Versioning — program_versions + session_versions snapshot tables retired in favor of the three-tier copy-on-derive model (protocols pin the derived content rows active at assignment creation). See the warning box + canonical spec.
  • [x] RLS: visibility union (organization_id IS NULL OR organization_id = current_app_org_id() [OR patient_access check]) on all new catalog tables.
  • [x] RBAC seeding: content:read, content:write, content:publish, content:platform_write.
  • [x] Audit: new entity_type values registered (content_file, program, program_phase, program_asset, session_audio_item, session_asset) — the retired program_session / program_version / session_version entity types dropped with their tables.

Assignment + cadence substrate

  • [x] protocols (replaces patient_assigned_sessions / patient_assignments; pre-prod rename + reshape, no production data migration risk) — single table addressing both content kinds via nullable FK columns. Content reference: session_id / program_id (nullable, CHECK exactly-one-set) + parallel session_version_id / program_version_id (CHECK matched-with-side). Real Postgres referential integrity, no trigger or domain-code enforcement. kind (prescription | enrollment), supervision_mode (unsupervised | supervised — renamed from modality on 2026-05-28), cadence_kind (flexible | scheduled) NULL for enrollment, cadence_config JSONB, end_date_is_hard_cap BOOLEAN, completed_at TIMESTAMPTZ, approval_status (auto_approved | pending_approval | approved | rejected), start_date, end_date NULL, status (active | paused | completed | ended). See cadence & supervision.
  • [x] protocol_pauses (new; renamed from assignment_pauses) — full pause history; one row per pause interval with paused_at / resumed_at / reason / paused_by_principal_id.
  • [x] session_runs.assignment_id dropped — the link is derivable via the sessions.program_id chain; session_runs.session_id + patient_id made nullable + ON DELETE SET NULL for the patient-erasure cascade instead.
  • [x] Cadence engine (services/api/internal/core/domain/adherence/) — pure Go, no DB calls. ExpectedOccurrences(cadence_kind, config, supervision_mode, start_date, end_date, window, pauses, appointment_counter) → *int. Outer dispatch on supervision_mode (supervised → appointments table count via injected counter; unsupervised → cadence walk); inner dispatch on cadence_kind. Unit-testable in isolation; table-driven tests. See cadence & supervision.
  • [x] RBAC seeding: assignments:read, assignments:prescribe, assignments:enroll, assignments:pause, assignments:approve, stats:read.
  • [x] Audit: new entity_type values (protocol / protocol_pause, renamed from the original patient_assignment / assignment_pause).
  • [x] Indexes designed for stats access pattern — session_runs (patient_id, completed_at DESC), media_session_metrics (patient_id, started_at DESC), protocols (patient_id, status) WHERE status IN ('active', 'paused'). The session_runs (assignment_id, …) index was dropped along with the assignment_id column.

F9.3 Programs & Assignments — Phase 2 launch features

User-visible features built on F9.2 substrate.

Full scope vs demo cut. Single owners-clinic, no audio content, and no org uploads were the June-10 demo cuts — they let the demo ship one curated slice. Full scope is multi-clinic (all onboarded clinics), and audio sessions + org-tier content uploads come back into scope as the program-authoring surface generalizes. Parts of this shipped during the F-tier wave; the checkboxes below are not a reliable status signal — verify against code.

  • [ ] Clinic UI: create/edit/publish standalone sessions (extending existing sessions MVP UI).
  • [ ] Clinic UI: create/edit/publish programs with optional phases; attach sessions; reorder.
  • [ ] Clinic UI: prescribe a program to a patient — cadence picker (flexible sessions_per_week ± rest_pattern | scheduled days_of_week), supervision_mode (unsupervised | supervised), start/end dates ± end_date_is_hard_cap, optional approval routing. Per-appointment channel (in_person | online_live) is set at appointment booking, not at protocol creation. See cadence & supervision.
  • [ ] Clinic UI: assignment management — list, view, pause/resume, end, approve.
  • [ ] Patient UI: assignments visible on patient home; library browsing (exercises catalog); guided sessions discoverable; session execution flows write assignment_id on runs.
  • [ ] Patient UI: self-enrollment in guided programs (kind='enrollment').

F9.4 Patient Stats Surface

Patient-detail-first per the design conversation.

⚠️ THE CHECKBOXES BELOW ARE STALE (noted 2026-08-08). They read as unbuilt while the stats domain ships HandleOverview, HandlePainSummary, HandleExercises, HandleActivity, HandlePlayback, HandleRunDetail and the org-level surface (HandleOrgFunnel, HandleOrgRuns, HandleOrgFeedbackPoints, HandleOrgPainEvents) behind the live /analytics page. This is the same staleness found in F10 and F9.1 — the boxes predate the build. Reconcile line by line before trusting them.

This matters for F16: per-patient pain evolution already works over session_pain_events, so F16.1 is largely a generalisation of shipped code to form answers, not greenfield.

Full scope vs demo cut. No cross-patient roster, an empty library-curiosity tile, and reserved/empty pose-derived metrics were the June-10 demo cuts. Full scope keeps the patient-detail-first shape but lands across all onboarded clinics; the cross-patient roster, the library tile, and pose tiles remain gated on their own dependencies (telemetry NULL run_id support; telemetry pose aggregates + Class I MDR validation) rather than on a demo-vs-launch line.

  • [ ] Telemetry read API — /internal/v1/patients/{patientId}/media-summary?window=... returning aggregated media_session_metrics. Service-to-service auth.
  • [ ] API proxy — GET /v1/patients/{patientId}/stats/playback joins telemetry summary with session_runs; unified DTO. P47 URL-scope guard applied.
  • [ ] API stats endpoints — /v1/patients/{patientId}/stats/overview, /exercises, /activity, /playback. Query-time aggregation against raw event tables — no rollup tables, no materialized views.
  • [ ] Clinic UI: /patients/[id] route with tabs scaffolding (patient-detail shell).
  • [ ] Clinic UI: /patients/[id]/stats tab with header (adherence sparkline, pain trend, RPE trend, days-since), per-exercise breakdown, activity log with playback-health badges, operational health collapsed section, correlation alert.
  • [ ] Add recharts dependency + SOUP entry for charting.
  • [ ] Library curiosity tile — empty-state until telemetry NULL run_id support lands (separate telemetry chat handoff, see features/integrations/telemetry/).
  • [ ] Pose-derived metrics section — reserved + empty; ships in Phase 3 when telemetry pose aggregates ready and Class I MDR clinical validation completes. (The F10 pose-frame ingest pipeline is out of scope — client-side skeleton preview only.)

F9.5 Telerehab Automations

  • [ ] Triggers: assignment.created, assignment.session_completed, assignment.completed, assignment.ended, low-adherence detection (based on cadence-engine-computed gap).
  • [ ] Actions: send_session_reminder, book_followup_appointment (depends on F7).

Exit criteria for F9 v2: Specialist creates programs at any onboarded clinic, prescribes to patient, patient executes sessions in the portal, every run produces session_runs + telemetry, /patients/[id]/stats renders correctly with adherence + pain trend + playback health. (The single-owners-clinic slice of this was demonstrated at the June-10 demo; full scope generalizes it across clinics.)


F10. Telemetry Service

Separate Go service (services/telemetry/). PG aggregates on the same RDS as API + S3 replay blobs. No ClickHouse, no separate compliance Postgres, no audit_log forwarding. Doesn't block any other feature — runs in parallel with F1–F9. Locked design in /telemetry/index.md and /telemetry/api.md. Rationale in decisions.md → Why telemetry is PG + S3, not ClickHouse.

STATUS (2026-08-08) — THE SERVICE IS HALF SHIPPED, AND THE HALVES SIT ON OPPOSITE SIDES OF A REGULATORY LINE

⚠️ The F10.1–F10.5 checkboxes below predate the build and are NOT a status signal. They were written when nothing existed; the media half has since shipped to production without them being ticked. This banner is authoritative until someone reconciles them line by line.

The media half — BUILT and LIVE in production

services/telemetry/ runs in prod: /healthz, the HS256 signed-token verifier with three audiences (session, library, pose), media-event ingest, the in-memory aggregator + silence-sweep reaper, its own dedicated Postgres on shared Aurora (migrations 000001_media_metrics, 000002_video_errors), monthly partition rollover, and the internal read endpoints the API proxies (/internal/v1/patients/{patientId}/media-summary, /internal/v1/platform/media-summary).

The pose half — DEFERRED BY SCOPE, not merely unbuilt

This is the distinction that matters, and the reason this feature kept reading as absent from the remaining-work list. Everything else on this plan is unbuilt work that will be built. Pose ingest is a deliberate scope exclusion with a named trigger, locked in CLAUDE.md → Scope and platform-completion.md → Scope: client-side skeleton preview stays; no MDR / IEC-62304 scope is pulled in.

What IS built on the pose side — and it is substantial. The entire authoring stack shipped with F9.1 Phase 2 (migrations 000028 + 000029), and the patient-facing preview shipped with the portal player:

PieceWhere
pose_engines + pose_landmarks reference catalog000028, seeded with mediapipe.holistic
Per-exercise config — exercise_pose_configs, _history, _landmarks, _metrics, _feedback_rules, pose_data_quality_overrides000029, incl. the asset-version invalidation trigger + clone function
Go domains poseengines/ + pose_configs/services/api/internal/core/domain/
Four permission codescatalog.pose_configs.manage / .invalidate, clinical.pose_overrides.create / .view
Console pose-config editorapps/console/app/(dashboard)/exercises/[slug]/pose-config/
Client-side MediaPipe skeleton previewapps/portal/components/session/use-pose-skeleton.ts + pose-skeleton-draw.ts, WASM + models vendored under apps/portal/public/mediapipe/
biometric_capture consent purpose, EN + RO000008 / 000009
Pose auth audience + RequireConsent(PurposePose) mounterservices/telemetry/internal/core/auth/ — written, mounted on nothing

A patient can opt in today and watch their own skeleton drawn over the video. Nothing leaves the device. That is the whole of the current posture, and it is what keeps the platform outside the boundary.

What is NOT built — the ingest half, which is the entire F10 pose pipeline. POST /v1/pose/frames (the route is commented out in server_test.go with "add when pose ingest ships"), POST /v1/sessions/{run_id}/end, the LandmarkCodec, the SessionBuffer + S3 replay blobs, the aggregation engine (rep count / ROM), pose_session_metrics + pose_rep_metrics, and every specialist-facing read of any of it. services/telemetry/internal/ holds auth core coreapi internalapi library mediathere is no pose package.

Why the line is drawn exactly there

Two thresholds, and preview-only clears both:

  • The moment landmarks leave the device, the platform processes GDPR Art. 9 special-category biometric data — its own lawful-basis analysis, its own retention, its own DSAR and erasure story.
  • A rep count or ROM figure is a measurement, and the registered device declares it has none. The CE label reads „fără funcție de măsurare" and the Technical Specification declares the platform does not interpret physiological data. Showing one to a specialist is outside the declared intended purpose.

The gate

F11.0.5 answered the old question. The device is registered Class I via Rule 13 — RestartiX MedCare v1.0, May 2026; see medical-device.md → Current Status. So the gate moved rather than lifted: pose ingest ships when the declared intended purpose covers measurement, which is the Class IIa step.

Building ahead of that is preparation and is expected — the Class IIa provenance columns and the telemetry swap-point interfaces exist for exactly this reason. Placing it in front of a patient under the current CE mark is a separate act that needs the declaration updated first, so it is a release decision: surface it, don't ship it quietly.

Do not build any part of the ingest pipeline before that answer. Landing it piecemeal is how a platform acquires MDR scope without deciding to — and the client-side preview is deliberately structured so that adding ingest is a new pipeline rather than the removal of a guard.

Open regardless of the trigger

  • Pose framing UX rework — tap-to-confirm forces the patient to step out of frame to acknowledge. Purely client-side, unaffected by the scope decision, still pending.

F10.1 Telemetry API setup

  • [ ] services/telemetry/cmd/ entry point.
  • [ ] Cat F service-account principal for callbacks to API.
  • [ ] Signed-session-token verifier (HS256). Issuer ships in API at exercise-session start.
  • [ ] Three typed ingest endpoints: POST /v1/pose/frames, POST /v1/media/events, POST /v1/sessions/{id}/end.
  • [ ] Health check endpoint.

F10.2 In-flight buffer + aggregator

  • [ ] SessionBuffer interface; default impl: per-batch S3 PUT under s3://restartix-telemetry/{org_id}/{session_id}/inflight/{batch_seq}.bin.gz, concatenate-and-finalize into the canonical replay blob at session_end (S3 multipart's 5 MB minimum part size is incompatible with 1-2 KB compressed batches; see telemetry/index.md → Storage / S3 (replay blobs)).
  • [ ] LandmarkCodec interface; default impl: binary float32 + gzip per batch.
  • [ ] Server-side aggregation at session_end: per Class I scope (rep count + ROM + session completion as informational signals; no form_score at Class I — see telemetry/index.md → Aggregation engine).
  • [ ] algorithm_version recorded on every pose_session_metrics row at session_end; never retroactively rewritten.
  • [ ] Backpressure: pose batches droppable, finalizer non-droppable.

F10.3 Aggregate persistence (Telemetry-owned)

  • [ ] PG migrations for pose_session_metrics, pose_rep_metrics (monthly partitioned per P41), media_session_metrics, media_buffering_events (monthly partitioned). Live in the Telemetry-owned dedicated DB on shared Aurora (Option B per [[project-telemetry-db-option-b]]).
  • [ ] No per-exercise rollup table on API — clinical per-exercise data lives on session_exercise_events (events the patient client writes directly). Cross-domain joining happens at read time via the API → Telemetry proxy described in programs-and-assignments.
  • [ ] Data classification entries (P39) for every new column + S3 blob class.

F10.4 Reads (API)

  • [ ] GET /v1/me/exercise-sessions (Patient Portal).
  • [ ] GET /v1/patients/{id}/exercise-sessions (Clinic app).
  • [ ] GET /v1/session-runs/{id}/replay — mints signed S3 URL.
  • [ ] Materialized views per cohort dashboard, refreshed nightly.
  • [ ] Telemetry API rejects ingest with 403 if analytics (media) or biometric (pose) consent flag is not active.
  • [ ] Withdrawal takes effect immediately.
  • [ ] Replay blob retention via S3 lifecycle (standard → IA → Glacier → expire).
  • [ ] CI guard cmd/check-telemetry-bounds rejects direct PG/S3 access from Telemetry handlers.

Exit criteria: Patient does a pose-tracked exercise session in Portal; landmarks ingest to Telemetry API; aggregates appear in PG via events.Bus; replay blob lives in S3; specialist views the session + replay in Clinic app.


F11. Compliance Hardening + Production Deploy

Scope: technical and regulatory hardening features. Consumes data from F1–F9. Operational launch-readiness items (Sentry org setup, Bunny CDN account, first-clinic onboarding runbook, support escalation, on-call rotation, legacy-data migration execution) live in production-launch-readiness.md — that's the operational gate, distinct from this feature set. F11 ships the features the production environment needs; production-launch-readiness.md is the checklist that flips the switch on real patients.

F11.0 IEC 62304 Preparation

Registered Class I (MDR Rule 13, May 2026; IEC 62304 Class A or B safety class), engineered for the Class IIa step if/when treatment decisions are driven from clinical pose measurements. See CLAUDE.md → Medical Device Readiness and medical-device.md → Current Status for the regulatory framing.

  • [ ] Risk table for all clinical features (hazard, severity, likelihood, existing controls). Class I scope means risk-management rigor proportional to "informational data, not clinical decisions"; revise upward if Class IIa is confirmed.
  • [ ] SOUP list update (started in 1A.13) — full inventory with risk assessment. MediaPipe + the in-house aggregation engine are the primary additions for F9 telerehab.
  • [ ] Reframe implementation plan as Software Development Plan (SDP) at IEC 62304 Class A/B rigor.
  • [ ] Verify requirement IDs on all clinical feature specs.
  • [ ] Verify traceability: each clinical requirement ID has at least one referencing test.
  • [ ] Document the regulatory boundary in architecture docs.

F11.0.5 Romanian compliance pass (ANSPDCP)

Pre-launch legal review by a Romanian data-protection lawyer. Required before any real patient interacts with the platform on production. Drives revisions to the privacy notice template seeds (1B.10), the consent purpose catalog (1B.9), and any telemedicine/biometric flows.

  • [ ] Engage data-protection counsel — Romanian-specialised firm with healthtech experience.
  • [ ] Law 190/2018 review — Romanian GDPR implementation; quirks on processing the national ID number (CNP) even with consent, and on employment relationships.
  • [ ] Age of digital consent — Romania at 16; below 16 requires parental consent. Verify portal sign-up + telemedicine flows respect this.
  • [ ] Telemedicine framework — Order 1.589/2020 + Order 1.488/2020 from the Romanian Health Ministry set technical and procedural requirements for telemedicine consultations; verify F5 (Daily.co integration) + F9 (telerehab) compliance.
  • [ ] Medical records retention — Law 95/2006 + supporting orders set retention periods (some up to 100 years). Takes precedence over GDPR Art. 17 erasure rights via Art. 17(3)(c) — codify in F11.1's erasure flow.
  • [ ] Biometric data — ANSPDCP guidance on biometric processing; relevant for tablet signature capture (1B.10's signature surface) and pose-estimation features (F9).
  • [ ] Cross-border transfer — RestartiX uses sub-processors in non-EU jurisdictions (Clerk in the US; Daily.co; possibly others). Annex SCCs to the DPA + transfer impact assessment in the privacy notice template's cross_border_transfer toggleable section.
  • [ ] Recent ANSPDCP enforcement actions — quick scan of the last 12 months of fines to identify regulator focus areas; adjust priorities.
  • [ ] Privacy notice template revision — apply the lawyer's findings to the seeded EN + RO templates (1B.10). Mark them as "production-ready" only after this pass.
  • [ ] DPA template revision — apply findings to the platform's standard DPA, including SCCs and the sub-processor list.
  • [ ] Document outcome — write findings + decisions to a follow-up entry in decisions.md.

F11.1 GDPR Implementation

  • [ ] Data subject access request (DSAR) endpoint — export all patient data.
  • [ ] Right to erasure endpoint — anonymise patient data (retain audit trail per Art. 17(3)(c)).
  • [ ] Granular consent UI built on top of F3.5 ledger.
  • [ ] Data processing records.
  • [ ] 72-hour breach notification procedure documented + alerting.
  • [ ] Cookie/tracking consent.

F11.2 Encryption (prod)

  • [ ] Production KMS key + rotation procedure tested at scale.
  • [ ] TLS enforcement on all connections (browser↔app, app↔DB, app↔external).

F11.3 Security Hardening

  • [ ] Rate limiting beyond auth + public (per-endpoint, per-user, per-org).
  • [ ] CORS configuration (prod allow-list).
  • [ ] Input validation + sanitisation audit.
  • [ ] Security headers (HSTS, CSP, X-Frame-Options) in prod.
  • [ ] WAF rules.

F11.4 Production Technical Hardening

The infrastructure topology is fully specified in aws-infrastructure.md, iac-layout.md, scaling-architecture.md, deployment.md, and backup-disaster-recovery.md. This sub-phase is the "provision the production environment + verify it under load" pass — the IaC apply happens here, not the design.

  • [ ] Provision infra/envs/production Terraform — Multi-AZ RDS db.t4g.medium, NAT Gateway, Multi-AZ pgbouncer, Multi-AZ Redis with replica, on-demand Fargate (no Spot), all per iac-layout.md.
  • [ ] Verify backup posture end-to-end — RDS PITR working, daily pg_dump to S3 with checksum, lifecycle to Glacier IA at 90d / Deep Archive at 365d, cross-region replication to a second EU region, restore drill executed (per backup-disaster-recovery.md → Testing & Validation).
  • [ ] Tune Fargate auto-scaling bounds against production-shape traffic — initial bounds per scaling-architecture.md → Auto-scaling parameters; revise after first load test.
  • [ ] Performance benchmarks (k6 load tests) against production environment with synthetic user shape; record baseline p50/p95/p99 latency, error rate, DB connection saturation.
  • [ ] Customer-managed KMS migration if F11.0.5 / regulatory counsel triggers it (otherwise Phase 1 AWS-managed KMS continues per aws-infrastructure.md → Customer-managed KMS migration path).

F11.5 Audit retention + access logging

audit_log answers one question: who CHANGED what, when, and from what to what. That is not a description of intent — it is mechanically enforced. cmd/check-audit-coverage runs in make check and fails the build if any handler registered via r.Post/Put/Patch/Delete has no reachable audit.Record in its package; GETs are out of scope by design. Current coverage is 296 call sites across 64 domains. Every property of the table follows from that one question: append-only (REVOKE on partitions + no UPDATE/DELETE policies), a changes JSONB holding the redacted before/after diff, ten indexes, a synchronous in-transaction write, monthly partitions, six-year retention. One row per mutation call — a staff member changing occupation + residence in one request produces ONE row whose diff carries both fields, never a row per field.

A read is not a change, so reads do not belong there — with one deliberate exception. audit.ActionRead exists for the narrow class where the disclosure IS the harm: today exactly one call site, the CNP endpoint (HandleGetNationalID), which fails closed and records the entity, never the value. The admission rule is written into the constant: would a supervisory authority ask who accessed this specific value? Adding a second call site means answering that question in the affirmative, in writing. This is an exception with a stated rule, not a general read log, and not a precedent.

What HIPAA §164.312(b) wants is a different thing, and it is a superset. It asks systems to record and examine "activity in information systems that contain or use ePHI" — activity, not reads. A mutation is also an access, arguably the more sensitive kind. So the access record fully contains audit_log rather than complementing it, which is what makes "one table or two" a real decision instead of an obvious one.

Settled: two tables, duplicating the FACT of contact, never the payload. Rejected alternatives, both for concrete reasons. One table: 50–150M attention rows/year into a structure built for 10–35M state transitions, and the write paths cannot diverge — reads cannot go async without dragging mutations with them. Two tables split reads-vs-writes: looks tidier, but the compliance question is literally "all activity by actor A on patient X", so every query becomes a UNION across two schemas — and the moment retention diverges (audit archiving to S3 at 12 months, access not) that union spans a live table and an object store. It optimises storage tidiness and makes the actual query unanswerable.

Under the settled shape, one staff edit writes two rows: access_log gets {actor A, patient X, update, T, request_id R} (~250 B, async, best-effort) and audit_log gets the same contact plus the diff (~1.5 KB, synchronous, fail-closed). Duplicated: the fact of contact. Never duplicated: the diff, which lives in exactly one place. "Everything A did to X" is a single index scan on access_log; the detail is one pivot away on request_id, a column audit_log has carried and indexed (idx_audit_request) since 000001. The join key is already paid for. Cost of the duplication: ~250 B × 10–35M mutations/yr ≈ 3–9 GB/year — cheap enough that arguing about it costs more than the disk.

The CNP read stays in audit_log when the access log ships. The access log is deliberately async and best-effort, which is correct for 100M+ rows of attention data — a degraded access log must never fail a patient page. But the CNP disclosure is the one read where losing the row is unacceptable, which is exactly why it is fail-closed today. Moving it into a best-effort pipeline would silently weaken the guarantee it exists to provide. The end state is not "reads move out"; it is audit_log = state transitions + the tiny set of reads where disclosure IS the event, and access_log = every contact with a patient record, mutations included.

F11.5.0 EnforceLimit never blocks — live gap, silent

middleware.EnforceLimit is mounted on real routes today and enforces nothing. It resolves a cap through principal.Subject.Limit(code), which reads Subject.Limits — a field whose own doc comment says "Layer 1 stub: nil; Limit returns an unlimited LimitState until the metering store (usage_counters) ships and the resolver activates." Nothing anywhere populates it. Every call therefore takes the limit.Unlimited() branch and passes.

Two mounts are live: max_specialists on specialist creation and max_webhook_subscriptions on 1C.4 subscription creation. The tables behind them are real (limit_definitions, tier_limits, organization_subscription_limits, all from 000004), the middleware is real, and limit_definitions.unit already admits 'bytes' — the design anticipated more than plan caps. Only the request-time resolver is missing.

Why this is worth stating rather than leaving as a TODO. The code reads as though plan caps are enforced. A commercial decision to sell tiered limits would be made on that assumption, and the failure is silent in the direction that costs money — an org on a 3-specialist plan can create thirty. Nothing breaks, nothing logs, and the first signal is a discrepancy someone notices in a billing review.

Not urgent while the caps are not being sold. It becomes load-bearing the moment a tier's limits appear on a price list. Scope: populate Subject.Limits in the org-scope resolver from organization_subscription_limits + tier_limits, plus the usage_counters store for anything whose period_kind is not lifetime. Per-request cost matters — it belongs with the P45 cache-aside work, not a query per call.

Do not cite plan-limit enforcement as implemented in any commercial or compliance artefact until this lands. Upload size limits deliberately did NOT use this framework for the same reason — they are fixed constants in internal/integration/s3/surfaces.go bounded by route middleware, chosen over the entitlement path precisely because the entitlement path does not run.

F11.5.1 audit_log archival + purge — live gap, not deferred

CLAUDE.md → Data Retention states the policy: hot 0–12 months in PostgreSQL, warm 12 months–6 years in S3 archives, then purge. The policy is designed, not built. cmd/ holds 24 commands and none of them archives or purges; api-partition-roll creates partitions going forward only, and internal/core/audit/partitions.go has no archive or drop path. audit_log currently grows without bound and there is no mechanism to satisfy either half of the policy. Harmless today at current volume; it becomes real at two points — when the ~20k legacy migration lands, and when the oldest partition turns six and purging is an obligation rather than a cleanup. Independent of HIPAA and of F11.5.2; this one is already load-bearing.

The WORM substrate exists already: the storage-backups module runs object_lock_enabled = true in COMPLIANCE mode with 7-year retention. Whatever gets archived has a home.

  • [ ] Archival job — detach partitions older than 12 months, export to the Object-Locked bucket under its own envelope key, verify checksum before detaching. Follow cmd/backup-runner's envelope + verification pattern rather than inventing a second one.
  • [ ] Purge job — drop archived partitions past the 6-year boundary. Never-deleted classes are exempt and must be excluded by predicate, not by convention: break-glass rows (break_glass_id IS NOT NULL), GDPR operation entries (action_context = 'gdpr_operation', 7yr), key-rotation events. Establish where exempt rows live once their partition is dropped — this is the design question, not the DDL.
  • [ ] Retrieval path — an archived-partition read for a DSAR or an investigation must be a documented procedure, not an improvisation under time pressure.
  • [ ] Restore-drill coverage — extend cmd/restore-drill to assert an archived partition is retrievable and checksum-valid. The first drill already proved prod could not restore from its own dump; assume nothing here either.
  • [ ] Cross-check retention against Law 95/2006 medical-records periods (F11.0.5) before the purge job can drop anything. Some periods run far longer than 6 years and take precedence via Art. 17(3)(c); a purge job that outruns counsel destroys records the clinic is legally required to hold.

F11.5.2 access_log — deferred, with an explicit trigger

Trigger condition: the first US-based clinic entering scope, at which point HIPAA §164.312(b) becomes a live obligation rather than a nice-to-have. Under GDPR alone this is correctly deferred — Art. 32 asks for measures proportionate to risk, and the instrument that specifically demands read recording is Law 190/2018 art. 4, which is about the national identifier and is already answered by ActionRead. There is no EU obligation to record every record view.

What is a genuine gap today, independently of the trigger: the question is not merely unlogged, it is unreconstructible even retroactively. middleware.Logging mounts at the root (server.go:312), above Authenticate; chi's inner middleware writes the principal into a new context on a new request object, so the outer logging closure never sees it. The request line carries method, path, status, duration, request_id and remote_addr — no actor, no org. If a clinic asks in March which staff opened patient X's record in January, the answer is not "we chose not to log that" but "we cannot tell you, and no forensic dig recovers it." Those are different conversations to have with a data controller who is legally on the hook. CloudWatch is not a mitigation: 90 days in prod / 30 in staging, no export task, no Firehose, no subscription filter anywhere in infra/ — a rolling buffer that ages out silently and is not tamper-evident. It is an operational window, not a record, and must never be described as one in a compliance artefact.

  • [ ] Actor on the request log — small, do this ahead of the trigger. Needs a thin access-log middleware mounted inside the auth chain; the root-mounted one structurally cannot see the principal. principal_id + org_id are UUIDs (pseudonymous, consistent with already logging remote_addr, which is genuinely PII under GDPR). Closes the blind spot and turns the eventual table into an upgrade rather than a rebuild. Does not make CloudWatch a compliance record.
  • [ ] access_log table — monthly-partitioned per P41 (it is event-shaped), organization_id NOT NULL + RLS, ~250 B/row: actor, entity type + id, verb, timestamp, request_id. No changes column. ~3 indexes, not ten.
  • [ ] Semantic record-access events, not HTTP GETs. This is the sizing decision: per-GET is ~30–50× mutation volume (one patient page fans out to dozens of API calls) and becomes unqueryable; per-record-access is ~3–5× and is what §164.312(b) actually asks for. Estimated 50–150M rows/year at ~50 clinics post-migration, ≈15–50 GB/yr all-in — comparable to audit_log's footprint despite ~4× the rows, because the row is lean. Storage is not the constraint; query shape and write semantics are.
  • [ ] Async batched writes. Never fail-closed — a degraded access log must not 500 a patient page. This is the inverse of the CNP endpoint's contract and the reason the two cannot share a write path.
  • [ ] Own retention period, stated explicitly and separately from audit_log's six years. Feeds F11.5.1's archival machinery rather than growing a second one.
  • [ ] "Record and examine" — §164.312(b) requires both, and a log nobody queries is not an audit control. Ships with a clinic-facing "who accessed this patient" view. This is also the piece that makes the feature commercially useful to clinics rather than pure compliance cost.
  • [ ] Confirm scope with counsel at F11.0.5 before building — HIPAA applies only if US clinics are genuinely in scope, and this is exactly the "don't let HIPAA block decisions" case CLAUDE.md warns about.

Exit criteria (for F11 as a feature set): GDPR DSAR + erasure work end-to-end. Production KMS rotation tested. Rate limiting active per route/user/org. Security scan passes. Production environment provisioned and load-tested. audit_log archival + purge operating end-to-end with a verified retrieval path (F11.5.1); access_log remains correctly deferred unless its trigger has fired. Note: "production environment can take real traffic" is the F11 exit; "production environment IS taking real traffic" is the production-launch gate, captured in production-launch-readiness.md.


F12. Billing & Invoicing (platform → clinic)

Engine that turns subscription state + usage data + AI cost line items into invoices charged to the clinic. Foundation declares the capability interfaces (1C.1's payment.Provider + invoicing.Provider); F12 ships the engine + concrete provider impls. Romania-aware via per-org provider resolution (1C.2). Patient → clinic billing stays in the clinic's domain via Option A (clinic-BYO payment provider; webhook integration via 1C.4); marketplace mediation (Option B) is a separate post-launch feature.

Trigger: when manual invoicing of paying clinics becomes operationally painful — likely after 5–10 paying clinics on the platform. Until then, clinics on payment_provider='manual' get hand-cut FGO invoices and the engine waits.

Why dedicated feature, not part of F11: F11 is operational hardening (compliance, GDPR, security, deploy). Billing is product surface (subscription state machine, invoices, dunning, billing UI) — different scope, different risk profile, different team competence. Mixing them would slow F11.

Locked decisions (from foundation design phase, see foundation.md → 1C.1, 1C.2 and glossary → Marketplace mediation):

  • Provider strategy via Cat A abstraction. payment.Provider and invoicing.Provider capability interfaces (declared at foundation 1C.1) are switchable per-org via platform_service_providers (1C.2 resolver). Default impls land in F12; per-org overrides for Romanian clinics or dedicated tier slot in the same way.
  • International default: Stripe (mature, Stripe Tax handles VAT, well-known UX). Stripe also works for many Romanian clinics.
  • Romanian invoicing default: FGO (already in use by the platform). Handles RO VAT + e-Factura submission to ANAF. Per-org override for clinics that prefer SmartBill or e-Factura direct.
  • Romanian payment provider: per-org choice. Stripe works; Netopia / Euplatesc available as override impls if a clinic specifically needs them.
  • Tax always delegated to invoicing provider. Platform NEVER computes tax rates itself. We pass line items + clinic's organization_billing.tax_id_encrypted + currency; invoicing provider returns assembled invoice with tax calculated.
  • Patient → clinic billing stays in clinic's domain. patient_subscriptions (1B.7) is informational; clinic uses their own payment infrastructure. Cat C webhooks (1C.4) sync subscription state from clinic's payment provider into our records when a clinic wants integration. Marketplace mediation (platform-mediated patient payments) is the deferred F-Marketplace feature, not F12.
  • AI cost passthrough with markup. Each AI call's usage_records.cost_cents (from 1C.8 pricing-history) is summed per period; F12 invoice generation applies the platform's markup (TBD in F12 design — likely 30%) and surfaces as a separate invoice line item.

What F12 ships:

  • Subscription state machineorganization_subscriptions.status transitions (pendingactivepast_duecanceled → ...). Driven by payment.Provider webhook events (Cat D) + explicit admin actions.
  • Invoice generation cron — at period end, query organization_subscriptions + usage_summaries (1C.7) + AI cost roll-ups (1C.8) → assemble invoice line items → call invoicing.Provider.IssueInvoice → store invoice metadata locally.
  • Dunning — failed payment → retry schedule → suspend subscription on chronic failure → notify clinic admin via 1A.18 notifications.
  • Refund flow — admin action triggers payment.Provider.Refund → state update → invoice credit note via invoicing.Provider.
  • Billing UI — clinic admin sees plan + usage + invoices; Console superadmin sees billing across orgs.
  • Inbound webhooks (Cat D, per 1C.6): Stripe / Netopia / Euplatesc payment event handlers (payment.succeeded, subscription.updated, etc.). Each provider impl ships its own verifier + handler under the codified Cat D convention.
  • Concrete impls: internal/core/billing/payment/stripe/, internal/core/billing/payment/netopia/ (when needed), internal/core/billing/invoicing/stripe/, internal/core/billing/invoicing/fgo/. Each follows 1C.1 capability convention with Fake doubles for tests.
  • Two invoice cadences per locked decision: monthly invoicing (SaaS norm) + annual-with-monthly-usage-true-up for clinics that prefer annual prepay.
  • Overage billing for soft-meter capabilities (1B.5 Limit semantics) — per-unit overage cost configured on tier_limits.overage_cents_per_unit (new column added in F12 migration); billing engine sums excess units × rate as line items. Hard-block capabilities get no overage; clinic must upgrade tier to continue.

Open design questions (settle when F12 design starts):

  • AI cost markup percentage — likely 30%, but pin against actual provider costs + competitive analysis at design time.
  • Romanian payment provider default — Stripe for everyone, or Netopia for RO clinics by default? Affects setup operations.
  • Trial periods — Free tier perpetual? Pro tier 30-day trial? Consult sales/marketing.
  • Annual invoice format — single big invoice or 12 monthly invoices issued upfront?
  • Billing UI placement — clinic admin app dedicated section vs. Console-only? Likely both with clinic-side restricted to read-own + initiate-card-update.
  • Per-tier pricing (shared vs. dedicated) — surface fees + AI markup + storage rates etc.

Dependencies:

  • F11 ships first (operational hardening; production deploy).
  • 1C.1 capability skeletons for payment.Provider + invoicing.Provider (foundation, ships before F1).
  • 1C.2 provider resolver (foundation).
  • 1C.7 metering + usage_summaries (foundation).
  • 1C.8 AI cost capture + ai_model_pricing_history (foundation).
  • 1A.18 notifications (foundation, shipped).
  • 1C.4 outbound webhooks (foundation, for clinic-side billing-event integration when clinic uses Option A).
  • 1C.6 inbound webhooks (foundation, for Stripe / Netopia provider webhooks).

Exit criteria: clinic onboards → automatic monthly invoice cycle works (subscription + usage + AI cost rolled up; invoice generated; clinic charged; receipt email sent); failed payment dunning + suspension works; refund admin action works; clinic-facing billing UI shows current plan + usage + invoice history; Romanian clinic with provider_name='fgo' override successfully receives e-Factura-submitted invoice.


F14. Commerce & Access Offers

External-shop commerce → platform access, plus free marketing campaigns. "One provisioning core, two triggers": an access_offers bundle (programs / sessions / a tier subscription, with duration or lifetime terms) is minted to a patient by either a paid shop order or a free campaign claim, through one shared provisioner. No new access-check path — the play gate already honors patient_content_grants + patient_subscriptions. First real consumer of Cat B (Connected Account, 1C.5) + Cat D (Inbound Webhook, 1C.6). Replaces the legacy Strapi shop→onboarding→subscription bridge; shops stay external (no e-commerce build). Specs: features/integrations/shop-commerce.md (connectivity) + features/access-offers/index.md (provisioning).

Numbered F14 — F13 is the post-launch Tenant Isolation slot below; F-numbers are identifiers, not section-contiguous. Status: built end-to-end, pushed + deployed to staging, live-tested green 2026-06-03 (on staging; NOT on master/prod per the never-deploy-to-prod rule) — both triggers + the refund lifecycle.

F14.1 Shop connectivity (Cat B + Cat D, first consumers)

  • [x] WooCommerce + MerchantPro integration_services catalog rows (000036); api_key auth, store_url config.
  • [x] Cat B connectors (connectors/{woocommerce,merchantpro}): ValidateConfig/Credentials, Basic-auth healthcheck, authoritative FetchOrder (verify-by-pull).
  • [x] Per-connection inbound endpoint — inbound_token + inbound_signing_secret_encrypted on organization_integrations (000017 edit); POST …/inbound-endpoint mints URL token + signing secret once.
  • [x] Cat D inbound (integration/{woocommerce,merchantpro}/inbound, P52): per-connection token resolve → HMAC verify → dedup → verify-by-pull → emit. Routes /webhooks/{provider}/{token}.

F14.2 Access-offers provisioning core

  • [x] access_offers + access_offer_items (content_grant | tier_subscription; duration | lifetime) + access_offer_fulfillments idempotency/forensic ledger (000035). offers.manage permission.
  • [x] ProvisionOffer shared provisioner — mints patient_content_grants / patient_subscriptions via the admin pool; idempotent on (org, offer, patient, trigger_ref). Clinic offer-authoring UI.

F14.3 Shop trigger (paid)

  • [x] access_offer_sku_bindings + access_offer_orders (000037); commerce.order_paid consumer matches line-item SKUs → stages an intent. Clinic SKU-bindings + orders-monitor UIs.
  • [x] Public order resolve (GET /v1/public/orders/{ref}?key=) + buyer-present fulfill (POST /v1/me/orders/{ref}/fulfill, order_key = ownership proof); Portal /orders/[ref] post-checkout onboarding (Clerk signup → portalonboarding → fulfill), pending-order cookie resume.

F14.4 Campaign trigger (free)

  • [x] access_offer_campaigns (000038) — clinic-authored public card bound to an offer; staff CRUD + public browse (GET /v1/public/campaigns) + claim by campaign_item id (offer_id never egresses; resolved server-side). Clinic Campaigns authoring UI; Portal /campaigns list + /campaigns/[id] claim hub, pending-claim cookie resume.

F14.5 Refund → revoke

  • [x] commerce.order_refunded (handlers fetch-before-dedup + state-aware dedup key); OrderRefundedConsumer lapses content grants precisely via patient_content_grants.fulfillment_id (000034); access_offer_fulfillments.revoked_at idempotency; staged intent → 'refunded'.
  • [ ] Auto tier-revoke on refund — deferred: reversing SetSubscription's replace-semantics is ambiguous; the refunded intent surfaces the order for the clinic to adjust via the existing subscription UI.

Deferred / follow-ups

  • [ ] Portal post-checkout: pre-provisioned Clerk invitation for one-click activation (vs standard signup) — UX enhancement on the proven spine.
  • [ ] MerchantPro inbound verify/parse confirmed against a live store (signature header name + body shape currently assumed).
  • [ ] i18n sweep of the clinic /integrations authoring screens (intentionally English today; the Portal buyer/visitor flows are i18n'd).
  • [ ] "Buy an appointment" — deferred until a booking domain exists (content-only today).

Dependencies: 1C.5 (Cat B Connected Accounts) + 1C.6 (Cat D Inbound Webhooks) + 1C.3 (internal events) frameworks; existing portalonboarding, patient_content_grants, patient_subscriptions, patient_tiers, and the play gate.

Exit criteria: clinic connects a shop + authors offers + binds SKUs → a paid order webhooks in, stages, and the buyer onboards + activates access; clinic publishes a campaign card → a visitor signs up + claims it free; a refunded order lapses the content access it granted. All verifications green (make check + pnpm check + access-offers integration suite).


F15. Advanced Filtering (Patients + Appointments)

STATUS (2026-08-08) — DOCUMENTED, NOT BUILT. IN SCOPE.

Ported from leo's pacienti/listare filter dialog, which is in daily staff use against 2,210 patients today. Nothing here exists on the platform: patient and appointment lists carry ?q= typeahead, fixed per-column filters and apiquery pagination, and nothing else.

This reverses an accepted trade. F8 Segments records it in its own words: "leo's filter-by-form-answer dialog is in daily staff use, so its absence is a visible regression when clinics migrate. That is a known, accepted trade." It is no longer accepted — clinics filter by what patients answered, and losing that at migration is losing a working clinical tool.

Relationship to F8 Segments — ONE rule language, and F15 owns it

F15 and F8 are the same engine with different persistence. A filter is a rule set evaluated live against a list query; a segment is the same rule set stored, materialised and auto-updated. F8.2's own scope line — "multi-source rules: forms.values + custom_field_values + appointments" — describes F15's evaluator exactly.

So the split is:

Owns
F15 (in scope)The rule language, its validation, and the SQL evaluator. Applied live, to a page of results. Nothing persisted.
F8 (still out of scope)segments / segment_members / segment_versions, materialisation, and event-bus auto-update — consuming F15's language unchanged.

Two rule languages would be the failure here. If F8 later invents its own, a clinic's saved segment and their ad-hoc filter answer the same question differently, and no one finds out until a cohort is wrong. F15 therefore designs the language as a persistable artifact from day one even though it persists nothing — the JSONB shape F8.1 would store is the shape F15 puts on the wire.

F15 is numbered separately rather than as F8.0 deliberately: F8 is flagged OUT OF SCOPE at its heading, and burying in-scope work inside an out-of-scope section is precisely how F10 pose became invisible.

leo's nine tabs are four sources here

leo's left rail lists Pacient, Consultație, Chestionare, Parametri vitali, Rapoarte, Rețete medicale, Recomandări, Evaluare mobilitate, Acorduri — nine sibling entities. Six of those nine are one thing on this platform: a form_template, distinguished by its document_categories row.

leo tabPlatform source
Pacientpatient_profiles (portable, scoped by the org's patients row) + patients (org row, incl. subscription state)
Consultațieappointments columns + custom_field_values where entity_type = 'appointment'
Chestionareforms.values, category_key = 'survey'
Parametri vitaliforms.values, category_key = 'parameters'
Rapoarteforms.values, category_key = 'report'
Rețete medicaleforms.values, category_key = 'medical_prescription'
Recomandăriforms.values, category_key = 'advice'
Evaluare mobilitateforms.values, category_key = 'analysis'
Acorduriforms.values, category_key = 'disclaimer' (+ the consent ledger, which is a different question — see below)

So the source taxonomy is four, not nine: patient profile, clinic record (custom fields), appointment, form answers. The UI may still present form answers grouped per template — that is what makes leo's rail legible — but it is one code path with a grouping key, not six.

This also means the clinic's own F3 custom fields are filterable from day one, which leo has no equivalent of. The field library was built for exactly this and currently has one reader (F6 documents) and one editor (the patient Record tab).

F15.1 Rule language

  • [ ] Predicate: {source, key, operator, value}. source ∈ profile | record | appointment | form; a form predicate additionally carries form_template_id and the answer key. Custom fields key by custom_fields.key, never by UUID — the key is immutable by design and stays legible inside a stored rule, the same reasoning F6's blocks settled on.
  • [ ] Boolean grouping: nested {op: AND|OR, children: [...]}. Bounded depth (proposal: 3) and bounded predicate count — an unbounded rule tree is an unbounded query planner cost against 20k+ patients.
  • [ ] Operator vocabulary is per field TYPE, not global. contains on a number is meaningless and > on a multiselect is worse. The type already exists: custom_fields.type (text | textarea | number | email | phone | date | select | multiselect | radio | checkbox | scale | file | signature | national_id) and the same catalog drives form questions, so the operator set derives from the field catalog rather than being re-declared. Proposal:
TypeOperators
text, textarea, email, phonecontains, not_contains, equals, not_equals, is_empty, is_not_empty
number, scale=, , <, , >, , between, is_empty
datebefore, after, between, is_empty (+ relative: last_n_days)
select, radiois_any_of, is_none_of, is_empty
multiselect, checkboxcontains_any, contains_all, contains_none
file, signatureis_empty, is_not_empty (presence only)
national_idsee the open decision below
  • [ ] The value editor is driven by the field definition too — leo's screenshot shows a VAS filter offering the literal values 010 as checkboxes, which is right: a scale field knows its own bounds and a select knows its own options. A free-text box where a closed set exists is how staff filter for values that cannot occur.
  • [ ] Validation refuses an operator the field type does not admit, and refuses a key that does not resolve in the caller's own org. Same bound F6 settled on: the worst a caller can name is one of their own clinic's fields.

F15.2 Patient filtering

  • [ ] Applies to the Clinic patients list. Server-side, paginated, apiquery-conventional — no client-side narrowing of a fetched page, and the result count comes from a COUNT(*) sharing the filter's WHERE clause.
  • [ ] Sources: profile, record, appointment (patient has an appointment matching …), form.
  • [ ] Filter chips reflect applied state with their source in the label (leo does this: "Durere cervicală (Rapoarte)", "Sex (Pacient)") — without the source, two identically-named fields from different templates are indistinguishable, and Alergii appears in both Chestionare and Rapoarte in leo's own screenshots.
  • [ ] URL-bound so a filtered list is shareable and back-navigable; chips resolve by id per the Production Scale picker rule.

F15.3 Appointment filtering

  • [ ] Same engine, smaller surface. leo offers Perioadă (date range), Specialiști, Tipuri consultații, Status — all first-class appointments columns, so these are ordinary indexed predicates, not JSONB.
  • [ ] Plus appointment-entity custom fields, which A3 gave a capture surface and which nothing currently queries.
  • [ ] ParseDateRange already exists in apiquery and is the date-range primitive; specialists and offerings follow the async-typeahead picker rule.

F15.4 UI

  • [ ] Source rail → field list → per-field condition + value editor, matching leo's proven shape. The UX is the port; preserve it.
  • [ ] AND/OR grouping is the piece leo never finished. It must not turn the dialog into a query builder for the front desk — proposal: flat AND across chips by default, with grouping as an explicit affordance, so the common case stays one click.
  • [ ] Strings through next-intl. leo's labels are Romanian clinical vocabulary and belong in catalogs, not hardcoded.

Hard constraints — these are not preferences

  • [x] profile_shared gates profile predicates, and the filter is an ORACLE if it does not. DISSOLVED 2026-08-20. This constraint existed because a profile field could be withheld from a clinic that nonetheless held the patient's record, which made a filter an oracle: matching "Ocupație contains X" would disclose a withheld field without rendering it, and staff could binary-search an exact value out of it. The profile_shared gate is gone (P8, retired), so within one clinic there is nothing withheld to leak, and a filter can no longer disclose more than the record page already does. What replaces it is the ordinary org scope: a profile predicate joins patients at the searching org, exactly as every profile read does. A predicate that queries patient_profiles without that join reads every clinic's patients and is the real bug this row now guards against. The CNP restriction is unchanged and independent — equality only, against national_id_hmac, never contains.
  • [ ] Private custom fields. custom_fields.is_private exists and F6 settled that private is private on every document type. Whether a private field is filterable, and by whom, is the same class of question and needs the same answer shape — a private field that is filterable is readable one query at a time.
  • [ ] Form ownership is checked through the org's own patients. F6 found this the hard way: the forms list folds in clinic-wide rows belonging to whichever patient filled them, staff may read every form in their org, and RLS does not stop a caller naming another patient's form. A filter joins forms → patients → organization, never forms alone.
  • [ ] Consent purposes are not form answers. Acorduri filtering splits: a signed disclaimer is a forms row and filters as one; a ledger purpose (telemedicine, marketing) lives in the consent ledger with withdrawal semantics and current-state-per-purpose. Treating a withdrawn consent as an unanswered form question would be a compliance-grade wrong answer.
  • [ ] Audit. Filtering is a read, and there is no general read log (F11.5). This does not become the second audit.ActionRead call site by default — but a bulk filter over clinical answers is exactly the "who accessed this" question F11.5.2's access_log exists for, so F15 is a named future consumer rather than a reason to build it now.

Scale — the part that will actually hurt

  • [ ] forms.values has GIN (values jsonb_path_ops) (000043:279). That index serves containment (@>) well and does nothing for ILIKE '%…%' or numeric range comparison. leo's default operator is Conține. Filtering free-text answers across multi-year form history for 20k+ patients is the query that falls over, and it is the most-used one.
  • [ ] custom_field_values.value has NO index at all, deliberately — 000042:217 says "No GIN index on value yet … adding GIN (immutable_unaccent(value) gin_trgm_ops) later is a pure addition." F15 is the reader that comment was waiting for. That index is a migration in this feature (new migrations start at 000049).
  • [ ] Romanian text predicates fold diacritics — unaccent(col) ILIKE unaccent('%' || $1 || '%') — or "Stefan" fails to match "Ștefan" and staff conclude the filter is broken.
  • [ ] Measure before choosing the shape. A live evaluator may be the wrong answer for the free-text case, and the alternative is F8's materialisation arriving earlier than planned rather than a cleverer index.

Open — do NOT invent answers

  • [ ] CNP filtering. leo's Pacient tab offers a CNP checkbox. On this platform the CNP is encrypted BYTEA plus a national_id_hmac blind index, so equality is possible and contains is structurally impossible — a prefix match would mean decrypting every row. Three candidate postures, and this needs a decision rather than a default: exact-match-only via the blind index; presence-only (has a CNP on file); or not filterable at all. Whichever it is, note that a match discloses that a named patient holds a queried CNP, which is the disclosure HandleGetNationalID audits fail-closed today.
  • [x] Which form instance does a predicate mean?SETTLED 2026-08-08: the LATEST instance. A patient accumulates many forms from the same template over years, and "VAS cervical = 4" could mean ever, most recent or within the period — three materially different cohorts. Latest is not the simplification it looks like: a filter predicate is the last point of the measure series F16 defines, so latest-instance is the correct point-query against the same model rather than a shortcut around it. Ever and in-period are the same series read differently, which is precisely why they can be added later without changing the model.
    • The rule stores the selector EXPLICITLY (instance: "latest") rather than leaving it implicit. any / in_period then become pure additions to a vocabulary, not a breaking change to every stored rule — and a filter whose semantics are recorded can be re-read years later, which one whose semantics were the default cannot.
    • The UI must state which instance it used. leo almost certainly answers "ever" by accident; a clinic that believes it filtered on the latest reading and got any reading has a wrong cohort, not a wide one.
  • [ ] Saved filters. Not in leo, and the obvious next request. A saved filter is a segment definition, so this is the seam where F15 grows into F8 — worth knowing before the wire shape is fixed, not after.
  • [ ] Export of a filtered set. leo's list footer reads "Exportă 2210 rezultate". A bulk export of filtered patient data is a P39 egress path with its own classification target and its own DSAR adjacency — it is not a free rider on the filter feature and should not ship silently with it.
  • [ ] Permission. patients.view gates the list today. Filtering by clinical form answers is a clinical read and may warrant its own code; the F6/A3 precedent is that a distinct audience deserves a distinct permission rather than an overloaded one.

Dependencies: F3 (custom fields, form templates, forms.values), F5 (appointments), F6's patientprofiles.Fields catalog — which is already the single source for patient-field identity and should drive the profile source list rather than a fourth hand-written copy.

Exit criteria: a staff member reproduces leo's working filters against real data — profile field, clinic custom field, form answer, appointment property — combined with AND/OR, server-side paginated, with a count that matches the filter, and with non-sharing patients correctly excluded from profile predicates.


F16. Longitudinal Measures

STATUS (2026-08-08) — DOCUMENTED, NOT BUILT. SCOPE SPLIT PROPOSED, AWAITING THE USER'S CALL.

F16.1 (per-patient series) is recommended IN scope after F15; it needs no migration and is useful on day one against data the platform already holds. F16.2 (cohort evolution) is recommended DEFERRED — it is the expensive half and it is entangled with F8. Neither is settled; the recommendation is written here so the reasoning survives the decision.

Why this exists as its own feature

Four capabilities kept collapsing into each other in planning — filtering, segments, "reports", and "see how a patient evolved over a year". They resolve cleanly once sorted by two questions instead of by name: what is the unit of the answer, and is it a point or a series?

Point in time (latest value)Over time (evolution)
One patientPatient detail / Record tab — ✅ builtPatient timeline — pain trend ✅ built; form answers ❌ F16.1
A set of identified patientsF15 filtering — find them nowCohort evolution — ❌ F16.2
Aggregate, no identity/analytics counts — ✅ built/analytics trends — partly built

F8 Segments does not occupy a cell — it is the arrow between two of them. A segment is a filter that was saved and kept fresh. That is why filtering and segments are two features and not one: filtering answers "who matches right now, so I can act on this list", a segment answers "keep telling me who matches". A segment's real payload is not the predicate — it is membership history, which is also what makes honest cohort evolution possible (see the survivorship trap below).

The primitive

A measure is a named, typed quantity observed about a patient at a point in time — (patient, measure_key, value, observed_at, source). A series is every point for one patient and one key, in order.

Everything above is a query over that. Notably F15's vas_cervical = 4 predicate is the LAST POINT of this exact series — which is why F15's latest-instance semantics are structurally correct rather than merely the cheapest option. One model, queried at one point or across all of them.

Where points come from — and the one source that cannot supply them

SourceSeries?
session_pain_events (VAS 0–10, reported_at, region, side, monthly-partitioned)✅ Already one. Read today by HandlePainSummary
session_runs.feedback_pain_level_now, RPE✅ One point per run
forms.values — every scale / number answerThe bulk of it. Frozen per instance, so history is free
custom_field_valuesStructurally cannot. One row per (org, entity, field) with value TEXT + updated_at, overwritten in place — there is no value history
capture_measurements (F5.5.8)A fourth source when it ships — computer-vision goniometry during a consultation. Documented, not built, and gated on F11.0.5 counsel. Named here so it is designed as a source from the start rather than retrofitted: measure_key must share the custom_fields.key space, or a measured angle and a form-asked one become two series that never meet
  • [ ] That last row is not a defect to fix. It is F3's three-layer model working as designed: custom_field_values is what is true now, forms.values is what was said then. A measure series is layer 3. F3's "copy, never live-bind" rule — written to stop a signed form mutating — is the same rule that makes this feature possible.
  • [ ] Consequence for clinics, and it must reach the UI: evolution comes from forms, not from the patient Record tab. A weight recorded on the patient record only ever yields the latest number; tracking weight over time means asking it on a form each visit. A clinic that records measurements on the Record tab and then expects a chart will get one point, and nothing today would tell them why.

Identity across templates — already in the data, no migration

Alergii appears under both Chestionare and Rapoarte in leo; VAS: Durere cervicală appears under Raport medical. One series or several?

The database already answers it. A form question binds to the field library via custom_field_id / profile_field_key, and that binding is materialised into forms.fields, the frozen snapshot — so every form instance ever signed already records which library field each question was.

  • [ ] Bound question → the measure key is custom_fields.key. The same clinical quantity asked on two templates is ONE series. (Same key-not-UUID reasoning F6's blocks settled on: custom_fields.key is immutable by design and stays legible inside a frozen snapshot.)
  • [ ] Unbound question → template-local key, its own series.
  • [ ] Therefore per-patient series needs no schema change. The identity is retroactively present in data the platform already holds — which is also the strongest argument for building F16.1 early: it is immediately useful, with no backfill.

Derived, not stored

  • [ ] No patient_measurements table. A resolver unions the three sources at read time.
  • [ ] The platform already made this call once: F9.4 reads "query-time aggregation against raw event tables — no rollup tables, no materialized views." F16 is the same shape over the same kind of data.
  • [ ] And a stored copy of a frozen form answer would create a second truth for a value whose entire purpose is immutability.
  • [ ] Materialise only if measurement shows F16.2 needs it. Not before, and not for F16.1 — a patient has tens of forms, not thousands.
  • [ ] GET /v1/patients/{id}/measures — catalog: which series this patient has, point count, date range.
  • [ ] GET /v1/patients/{id}/measures/{key}?from=&to= — the series itself.
  • [ ] Wire shape carries provenance per point — source, form_id, template name — and the UI clicks each point through to the form it came from. A number with no visit behind it is not something a clinician should act on, and the provenance is what separates this from a chart of anonymous digits.
  • [ ] Clinic UI: an Evoluție section on patient detail — pick a measure, line chart, points click through. Extends the existing /patients/[id]/stats surface rather than opening a new one.
  • [ ] P47 URL-scope guard. Form answers are the clinic's own clinical record rather than the portable profile, so the profile's org scoping is not implicated here — but private fields are, see below.
  • [ ] POST /v1/measures/{key}/cohort — takes an F15 rule as the cohort definition and returns an aggregated series. This is the composition point: F15 defines who, F16 defines what changed.
  • [ ] Genuinely expensive where F16.1 is cheap: thousands of patients × their full form history. This is where index work, and possibly the materialisation F16.1 avoids, actually land.
  • [ ] Entangled with F8 by the survivorship trap below — which may mean F8 comes back into scope rather than F16.2 shipping on ad-hoc filters.

Four traps, recorded before anyone builds it

  • [ ] Scale mixing. custom_fields carries field_type and options but no unit column. Two clinics' "pain" on 0–10 and 0–100, or weight in kg vs lb, would plot silently on one axis. A series must refuse to mix rather than average across incompatible scales — which likely means a unit/bounds declaration on the field, and that IS a migration.
  • [ ] Survivorship. Aggregating today's cohort backwards answers "how did people who match now used to look" — not "how did this group change". It is the classic cohort error and it produces confident, wrong clinical trends. The honest version needs membership history, which is exactly what segment_members is. This is the strongest argument for reopening F8, and F16.2 should not ship on ad-hoc filters while pretending otherwise.
  • [ ] MDR posture. A chart of patient-reported values is displaying data — the same Class I posture as the pain trend already shipped. A derived score, a composite index, or a "deteriorating" flag is closer to clinical decision support and moves the class. Keep F16 descriptive. Anything computed is an F11.0.5 question before it is an engineering one.
  • [ ] Private fields. custom_fields.is_private — F6 settled that private is private on every document type. A private field's series is the same disclosure one point at a time; F16 must inherit whatever F15 settles for filtering rather than answering it separately.
  • [ ] Legacy history is where this pays off most, and it is gated. The ~20k migration carries years of real answers — but F3's open item on historical form import fidelity decides whether imported forms arrive with a usable binding. Legacy forms have no snapshots; if they import without one, migrated patients arrive with a blank chart. Settle it in the migration design, not after.

Terminology — "report" is taken

Clinic-level reporting is analytics, and it has an existing home at /analytics (org funnel, runs, feedback points, org pain events in the stats domain). report on this platform means a medical document for a patientappointment_documents.category_key = 'report' and an offering-attached template in the report category, both live in F6. See glossary → Two senses of report.

Dependencies: F3 (forms.values + the field-library binding frozen into forms.fields), F5 (appointments as the visit anchor), F15 (the rule language, for F16.2), and the shipped session_pain_events / session_runs event tables.

Exit criteria (F16.1): a clinician opens a patient who has answered the same bound question on several forms across a year, sees the values as one ordered series with each point traceable to its form, and the same series' last point is what F15 filters on.


Post-launch (deferred until first paying dedicated-mode clinic contract)

F13. Tenant Isolation Mode (Dedicated Tier)

Trigger: first paying clinic contract that demands a per-tenant identity namespace + data-sovereignty story and can fund the operational setup. Not part of the regular feature track. Designed and documented now; built when a paying contract funds the work.

Status: settled (architectural commitment), deferred (build). Default platform mode is tenancy_mode = 'shared'; this is the premium tenancy_mode = 'dedicated' tier. Both modes target SMB clinics; hospital networks and dedicated-infrastructure tiers are permanently out of scope (see CLAUDE.md → Project Overview). The marketing label "White-Label" maps to dedicated mode in sales conversations but is decoupled from the architectural name — a shared-mode clinic with a custom domain is also visually white-labeled. See the full spec at features/platform/tenant-isolation.md and decisions.md → Why tenancy_mode is a single enum, not multi-axis.

Foundation-cheap pre-work (shipped in 1B + 1E):

  • [x] Add humans.provider_org_id TEXT NULL column to migrations/core/000002_tenancy_rbac.up.sql (no FK initially — FK target lands with the dedicated-mode runtime feature).
  • [x] Replace humans.email NOT NULL UNIQUE with UNIQUE (email, provider_org_id) NULLS NOT DISTINCT in the same migration. Functionally identical for shared-mode tenants today (all have NULL provider_org_id); future-proofs for dedicated mode where the same email can exist once per auth-provider tenant.
  • [x] Add organizations.tenancy_mode TEXT NOT NULL DEFAULT 'shared' CHECK (tenancy_mode IN ('shared', 'dedicated')) to the same migration. Single-enum discriminator; no creation flow accepts 'dedicated' yet.
  • [x] Add organizations.activated_at TIMESTAMPTZ NULL + idx_organizations_draft partial index. NULL = draft; every creation path today sets NOW() in the same transaction. Public-resolve and owner-welcome gate on activated_at IS NOT NULL.

These schema lines are the entirety of the now-work. Pre-prod cost: a handful of lines edited. Post-prod cost: real migrations touching every humans and organizations row plus deployment-coordinated downtime. Land now.

Deferred build (when first paying dedicated-mode contract closes):

  • Per-tenant Clerk org provisioner (auth-provider Backend API integration; writes the dedicated provider_org_id per tenant).
  • Re-introduced finalize-provisioning endpoint with proper preconditions (Clerk org exists, platform_service_providers overrides written) that flips activated_at = NOW() and queues the welcome email.
  • Addons via the entitlements catalog: own_s3_bucket (ships with the exit / portability tool) and own_cmk (ships with the documented crypto-shred runbook). Available on either tenancy mode; not coupled to tenancy_mode = 'dedicated'.
  • Terraform module for per-tenant infrastructure (S3 bucket + CMK + IAM bindings + Clerk org wiring).
  • Operational templating (DNS, ACM cert, SES sender, SMS sender ID, Daily.co domain) — bulk of the cost; the universal branding pieces work on shared mode too.
  • Dedicated-mode DPA template (legal counsel work, can run in parallel).

Pricing model: one-time setup fee + premium MRR uplift (industry comparable: 5–10x default tier) + termination service fee. See spec for breakdown.


Open decisions (features)

DecisionWhereWhen to resolve
humans has no name column — add humans.name TEXT NULL, or accept email-as-display-name for non-specialist staffF1.2Blocks 000040
Route topology — /team/{principalId} vs /team + /specialists/{id} (calendar-only specialists have no principal)F1Before the F1 UI wave
Does holding a specialty gate roster assignment — enforce with 422, or taxonomic-only?F1Before the roster UI
Timezone-change policy when weekly hours exist — block-with-migrate vs allow-with-previewF1 / F4Before 000044
Override scopingspecialist_schedule_overrides.calendar_id UUID NULL (NULL = all calendars) vs per-specialist global. leo's live schema scopes per-schedule with production rowsF4.1Blocks 000044
is_required enforcement point — per-field on save vs at pending → completedF3.3Before F3 ships
Audit granularity for autosave — one coalesced row per flush (with changed-key diff) vs one per fieldF3.3Before F3 ships
Cross-purpose withdrawal cascade for Tier B medical consents — independent vs cascadingF3.5Before F3.5 ships
Signature image format — PNG + SVG vs PNG onlyF3.5.3Before F3.5 ships
Sent-to-phone link revocation behaviourF3.5.3Before F3.5 ships
Terminal-status policy vs late arrival (noshow → inprogress) — silent adherence corruption if unresolvedF5.1Before 000046
Appointment-package tracking ("N sessions of Offering X remaining") — no platform equivalent; F2.2-adjacentF2 / F5.1Before 000046
Reschedule semantics — mutate in place with audit vs cancel + new rowF5.1Before 000046
Late-cancellation threshold — org-configurable vs platform constantF5.1Before 000046
Cross-org double-booking — accept the documented limitation, or add a human_id-keyed cross-tenant checkF5.1Before 000046
Patient self-booking at launch — ship the unauthenticated public hold+book path, or staff-side only?F5.4Before F5.4
PDF rendering engine + sync-vs-queued — chromedp / Gotenberg / Go-native; drives SOUP, ECS sizing, MDR postureF6.2Before F6.2
prescription naming collisionprotocols.kind='prescription' is live in production meaning an exercise programF6Before 000047
Calendar grid — FullCalendar (5 SOUP rows + a licensing question) vs hand-rolled; decides how much of leo's cockpit is reusable at allF4 UIBefore the cockpit build
CSV export as a first-class surface — in daily clinic use in leo, absent from openapi.yaml. If in, columns come from classification.AllowedFor(table, "bulk_export")F1–F6Scope question, not a slip-in
Mobile app approach — React Native, Flutter, responsive web only?F11+Before mobile work

Settled 2026-08-02 (do not re-open in a build task):

  • specialties are per-orgspecialties.organization_id NOT NULL. The "per-org or global" question in F1.1 is closed.
  • F2.1 offerings stand-in ships, named offerings from day one: catalog identity + offering_specialists + offering_forms. No pricing, no plans, no products, no purchase path. F2.2 / F2.3 deferred.
  • CNP is required on some forms and documents, not all → opt-in per template, pii_regulated encrypted BYTEA on patient_profiles, never in custom_field_values.value, explicit egress target for the PDF renderer, reads permissioned and audited on the reveal endpoint.
  • document_templates is rejected in favour of block-based pdf_templates — already recorded in data-model.md, restated in F6 because a leo migration doc recommends the losing design.
  • F7 / F8 / F10 pose ingest / F12 / F13 are out of scope.
  • New migrations start at 000040000039 is taken.

Resolved in earlier cycles:

  • Exercise video CDN — Bunny CDN (Bunny Stream library + their CDN) is the locked direction for exercise video hosting + delivery. Confirmed in the AWS-infrastructure consolidation chat. The video_provider flexibility column on exercises stays (preserves the swap-point if a clinic-specific override is ever needed), but the platform default is Bunny. Pre-launch wiring task: account setup + DPA review + admin upload workflow lights up with F9.
  • Email transport — AWS SES locked at foundation 1A.18 (production identity verification + suppression list close in 1E.3). Per-org override path via Cat A provider resolution preserved. SES is the only adapter the foundation ships; Twilio (SMS) and other channels slot in via the same Channel interface when their first consumer ships in F7.
  • Aggregation engine for telemetry — in-house heuristic (rep count + ROM + session completion as informational signals at Class I MDR posture). Vendor (Sency / Kemtai / Physitrack) rejected: competitors for parts of the platform; biometric data + algorithm leaves the trust boundary; per-user/month cost dominates at scale. Build path: a Go engineer + MediaPipe + per-exercise reference videos + heuristic rep segmentation. See telemetry/index.md → Aggregation engine and CLAUDE.md → Medical Device Readiness.
  • Payment + invoicing provider strategy — Cat A capability abstraction (payment.Provider + invoicing.Provider) declared at foundation 1C.1; per-org resolution via 1C.2's platform_service_providers table. Stripe international default; FGO + e-Factura Romanian default. White-label tier per-org overrides supported. Billing engine itself ships in F12, not F11. See foundation.md → 1C.1 / 1C.2 and F12 above.
  • Marketplace mediation (Option B — patient → clinic via platform with fee + payout) — strategic future feature, not foundation, not F12. Foundation accommodates via four Cat A capability skeletons (payment.Provider, invoicing.Provider, patient_payment.Provider, clinic_payout.Provider); no implementations yet. See foundation.md → Deferred Foundation Extensions → Marketplace Mediation.
  • Lawful-basis discriminator — yes, in schema. consent_purposes.legal_basis enum lands in 1B.9. See decisions.md → Why clinic is controller, platform is processor.
  • Withdrawal mechanics for SaaS-level consents — independent withdrawal per purpose; withdrawal of platform-scope platform_terms triggers the GDPR erasure flow (F11.1). Withdrawal of org_terms triggers per-org patients.deleted_at cascade. See foundation.md → 1B.9.
  • PII storage — humans.email, organizations.phone, patient_profiles.phone, patient_profiles.emergency_contact_phone, appointments.contact_email are all plaintext (pii_basic). Column-level encryption is reserved for auth_secret and pii_regulated (e.g., organization_billing.tax_id_encrypted). Mechanically enforced by cmd/check-classification. See decisions.md → Why most PII is plaintext (and what isn't).

Cross-feature concerns (ongoing)

Testing

  • Unit tests, table-driven, throughout every feature.
  • Integration tests for repos, handlers (RLS harness from 1A.2).
  • E2E tests for full flows (booking, consent → form → call, plan assignment → session execution, GDPR DSAR).
  • Performance tests in F11.

Frontend integration contract

  • API contract conventions in 1A.7.
  • OpenAPI spec-first (Go + frontend types regenerated on schema change).
  • Real-time integration (SSE) lands per-feature (F4 for slot availability).
  • File upload pattern (1A.8) used by F3 forms.

Parallel work streams

Foundation is closed and several F-tier items already shipped; the diagram below reflects current state rather than a pre-foundation plan.

Foundation:   [1A done] [1B done] [1C done] [1E.3 closed] [1D in flight]

Features shipped:   F9.1 Phase 1 (exercise-library substrate)
                    F9.2 (programs/assignments substrate)
                    F9.3/F9.4 partial (portal player, protocols, clinic analytics)
                    F10 (telemetry + media services, live in production)
                    F14 (commerce & access offers)

Features remaining: F1 → F2.1 → F3 → F4 → F5 → F6     (clinic-operations stack; strict order)
                    F11 (compliance hardening + operational leftovers)

Out of scope:       F7  F8  F12  F13  F10 pose-frame ingest

The F1 → F2.1 → F3 → F4 → F5 → F6 order is not negotiable, and it is a chain, not a set of parallel tracks:

  • F2.1 before F3offering_forms is the form-generation mechanism; forms are not independently useful without it.
  • F3 before F4 — calendars reference form_templates; building the reverse order means retrofitting the junction.
  • F5 after F1 + F4; F6 after F3 + F5.
  • Migration order follows: 000040 F1 → 000041 F2.1 → 000042/000043 F3 → 000044/000045 F4 → 000046 F5 → 000047/000048 F6.

Other notes:

  • F-tier work that is independent of 1D admin UI may proceed in parallel with 1D; F-tier items that depend on a specific 1D surface still wait for that surface (layer-discipline rule). Note apps/clinic has no team, staff or settings surface at all today — F1 ships the first one.
  • Genuinely parallelisable today, independent of the chain: the availability-engine test suite (differential against the restartix-intakes TypeScript original), extracting the Romanian label set + clinician-authored annex prose for F6, and the F11 operational leftovers.
  • Authoritative milestone status lives in foundation.md (foundation/1D) and the per-feature checkboxes above; this diagram is a high-level snapshot, not the source of truth.