Skip to content

Platform Completion Plan

ACTIVE. This is the live plan. It supersedes june-launch.md, which framed work around a September 2026 launch date and treated the ~20k legacy migration as a near-term deliverable. Both framings are retired — see Premise.

Plan window: 2026-08-02 → feature-complete. No target date. The platform is done when the feature set below is built to foundation standard, not when a calendar says so.

Premise

The migration is the last step, not a driver. There is nowhere to migrate the ~20k legacy patients into until the platform can actually run a clinic. Planning backwards from a launch date produced pressure to cut scope — a minimum clinic-ops slice, a "good enough for beta" appointments table — which is precisely the failure mode CLAUDE.md → Foundation Discipline exists to prevent.

The corrected order is:

  1. Build every remaining feature, properly, in dependency order.
  2. Harden what's built.
  3. Then plan the migration against a complete, real target schema.

Time is not the constraint; correctness is. Development runs with AI agents in parallel, so "this will take a long time" is not an argument for a lesser solution. There is no date in this document by design.

No shortcuts on the way. Every feature below carries the full foundation checklist. A feature that ships without RLS, without permission seeding, without audit coverage, or without a data-classification entry is not shipped — it is debt that every subsequent feature pays for.

Port, don't reinvent

F1–F6 are not greenfield. A live, working clinical-operations system already runs this functionality today, and its UI and operational behaviour are considered good:

RepoWhat it is
restartix-leo-dashboardStaff UI — Next.js 15 / React 19 / SWR / zod / TanStack Table / FullCalendar / react-sketch-canvas. The UX is proven; preserve it.
restartix-leo-platformPatient-facing Next.js app
restartix-leo-apiStrapi/Node API. Its database is known-bad and is explicitly not carried across.
restartix-intakesThe booking/scheduling service — Next.js + Drizzle + Postgres + Redis. Holds the 976-line availability engine, the double-booking exclusion constraint, the hold protocol, timeslot cache, cooldown and lead-time services. Source is available.

The full survey lives in leo-port-map.md — per-feature port plans, a consolidated foundation-conflict table, an anti-pattern guardrail catalogue, and the preserved-knowledge appendix.

The dashboard already ships the surfaces this plan calls F1–F6: echipa (specialists, specialties, availability), sabloane/[id]/campuri (form + custom-field builder), consultatii (the full appointment lifecycle with consents, questionnaires, documents, reports, prescriptions), rapoarte, retete-medicale. restartix-leo-api/docs/go-migration/ additionally holds 11 design documents from a prior effort to rebuild the same system in Go.

The job is re-homing proven functionality onto a proper data model — not redesigning it. That reframes the risk: the danger is no longer "did we design this right," it is "did we carry the legacy data model across with the UI." The stack generations are close enough (both Next.js + React 19 + SWR + zod) that much of the UI ports directly.

What is carried: UI, UX, component code, and above all the business rules — status lifecycles, transition guards, validation, and edge cases learned from actually running a clinic. No spec captures those.

What is not carried: the Strapi schema, framework-generated naming, its tenancy model, and every assumption that violates the platform's foundation (principals-not-users, RLS on every tenant table, per-org permission codes, soft delete on medical records, monthly-partitioned event tables).

Scope (locked 2026-08-02)

In scopeOut of scope (deliberate)
F1 Specialists & SpecialtiesF8.1 / F8.2 segment persistencesegments, materialisation, auto-update. The rule language is NOT deferred; it moved to F15
F2.1 Offerings stand-in (settled 2026-08-02 — see below)F2.2 Offering packages / F2.3 Products
F3 Forms & Consents (Tier B)F7 Automations
F4 SchedulingF12 Billing engine — clinics invoice by hand until it ships
F5 AppointmentsF10 pose ingest — client-side skeleton preview stays. Gate: the Class IIa step (the device is registered Class I with no declared measuring function; F11.0.5 answered it). See below
F6 DocumentsF13 Dedicated tenancy mode
F15 Advanced Filtering (patients + appointments) — added 2026-08-08Hospital networks, dedicated infrastructure (permanent, per CLAUDE.md)
F16.1 Longitudinal Measures, per-patient — added 2026-08-08, scope call pendingF16.2 cohort evolution — entangled with F8 by the survivorship trap
F11 Compliance hardening
The 1D admin surfaces each feature requires

Pose tracking — out of scope, and the one exclusion that is half built

Every other out-of-scope row above names something that does not exist. This one does not: the pose authoring stack shipped with F9.1 Phase 2 (migrations 000028 + 000029 — engine and landmark catalogs, per-exercise config with history and invalidation, four permission codes, a Console editor), and the patient-facing client-side skeleton preview is live in the Portal (MediaPipe WASM vendored under apps/portal/public/mediapipe/, gated on the seeded biometric_capture consent purpose).

A patient can opt in today and watch their own skeleton drawn over the video. Nothing leaves the device, and that is the entire posture.

What is excluded is the ingest halfPOST /v1/pose/frames, the landmark codec, S3 replay blobs, the aggregation engine (rep count / ROM), pose_session_metrics / pose_rep_metrics, and every specialist-facing read. services/telemetry/internal/ has no pose package; the route sits commented out in the server test with "add when pose ingest ships".

Why the line is 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, and a rep count or ROM figure is a measurement — which the registered device declares it does not perform.

The old trigger — F11.0.5 counsel confirming device class — is answered. The platform is registered Class I via Rule 13 (RestartiX MedCare v1.0, May 2026); the CE label reads „fără funcție de măsurare" and the Technical Specification declares the device does not interpret physiological data. See medical-device.md → Current Status.

So the gate moved rather than lifted: pose ingest ships when the declared intended purpose covers measurement — the Class IIa step. Building ahead of that is preparation and is expected; placing it in front of a patient under the current CE mark is a separate act and needs the declaration updated first. It is a release decision, so surface it rather than shipping it quietly.

Full inventory of built-versus-not in features.md → F10 status banner.

F2 Offerings — settled 2026-08-02: the F2.1 stand-in ships

Offering ≠ access-offer. An Offering is a clinical service the clinic sells patients ("Initial Assessment", "Follow-up Consultation") — leo calls it serviciu. The platform's shipped access_offers (F14 commerce: shop and campaign access grants) is a different concept that happens to share a word. Conflating the two is what put F2 on the out-of-scope list in the first place.

Evidence from leo suggests the Offering is not a catalog but the configuration spine of the clinical-ops stack. restartix-leo-dashboard/app/(dashboard)/servicii/[id]/ carries tabs for acorduri (required consent forms), chestionare (attached questionnaires), parametri (measured parameters), evaluare-mobilitate, raport (report template), recomandari, calendar, and atasamente. If that holds, the Offering decides which forms attach (F3), which calendar applies (F4), what an appointment is created from (F5), and which document is generated (F6).

This also explains two loose ends in our own specs: features.md's "service_forms junction defers to F3 with form_templates," and F4's calendars referencing form templates. Both are really offering → forms.

Outcome (settled 2026-08-02, reconciliation #3 below). The survey confirmed it: F2.1 ships, named offerings from day one — catalog identity + offering_specialists + offering_forms, with no pricing, no plans, no products, no purchase path. F2.2 and F2.3 stay deferred. The analysis below is the reasoning that produced that call, kept for the record.

The three parts of the old F2 spec have different fates:

PartExpectation (to be confirmed)
F2.1 offerings, offering_specialists, offering_formsRequired — confirmed. The configuration spine; blocks F3/F4/F5/F6. (offering_attachments — downloadable files — is deferred; nothing in F3–F6 depends on it.)
F2.2 service_plans / patient_service_plans / patient_tier_inclusionsLikely redundant against the shipped patient_tiers / patient_subscriptions / access_offers / patient_content_grants chain; two competing access models would be a foundation problem
F2.3 productsReference catalog; nothing appears to depend on it

Open sub-question: protocols.kind='enrollment' covers program enrolment only. It does not cover "this patient has N sessions of Offering X remaining," decremented as appointments are consumed. If leo relies on that, it is a genuine gap with no platform equivalent.

"Enrollment" is already shipped — settled 2026-08-02

The glossary reserves enrollments as the rename target for service_plans. That reservation is stale. The concept it names — a patient's instance of a guided program — already exists in production as a kind on the shipped protocols table (000023_sessions.up.sql:894):

sql
kind TEXT NOT NULL CHECK (kind IN ('prescription', 'enrollment')),
  • prescription — a specialist assigns a program to a patient. Requires cadence_kind.
  • enrollment — a patient self-enrols in a guided program. Forbids cadence_kind (course progress, no adherence denominator).

Both paired by chk_protocols_cadence_kind_pair; patient self-enrolment is served by POST /v1/me/protocols. No enrollments table will be built. Phase 0 corrects the glossary's forbidden-terms row, which currently reserves a name for a concept that shipped under a better one.

Verified state (2026-08-02)

Checked against code and schema, not against plan docs. The authoritative inventory is the Phase 0 deliverable; this is the headline.

Shipped and live in production: the F9 telerehab slice end-to-end (programs → sessions → protocols → session_runs, portal player, instruments, stats), the F10 media/engagement telemetry half, the render pipeline, the TV/kiosk companion, F14 commerce/access-offers, paid subscriptions + expiry sweep, patient tiers, custom domains (portal path), the legacy→handoff bridge, and clinic analytics. Schema: production is at 000040. 000039 was verified against the live database on 2026-08-03 (schema_migrations = 39, not dirty; principal_is_human resolves under an empty search_path, so the restore path works), and 000040 (F1) shipped in the deploy later that day. New migrations start at 000041.

Build status — every F-tier feature in scope is now BUILT, all local only.

⚠️ This table was the 2026-08-02 "not started" snapshot and every row of it has since flipped. features.md is authoritative for checkbox-level status; this is the headline.

FeatureReality
F1 Specialists✅ Shipped 2026-08-03 (000040)
F2.1 Offerings✅ Shipped 2026-08-03 (000041)
F3 Forms✅ Shipped 2026-08-04 (000042000044), Cat E events included. Open inside it: org-creation template seed
F4 Scheduling✅ Shipped (000045); availability engine differential-tested against the legacy one
F5 Appointments✅ Shipped (000046) — clinic, public and patient surfaces. Reviews deferred, Daily.co split out
F6 Documents✅ Shipped (000047000048) — builder, renderer, generation, patient surface, preview, history. Open: A3 appointment-entity custom fields, B4, C2, D1, D2

None of it is on staging or production: prod runs 000039, staging 000038. 000040000048 are committed and unpromoted, which is also why they remain editable in place — with a from-scratch rebuild diff whenever one is edited after a database has already passed it.

⚠️ june-launch.md claims F5's substrate columns "already landed in the cadence redesign." This is false. The cadence redesign designed those columns against a table that was never created. Building against that claim would produce a broken migration. This is the clearest example of why Phase 0 comes first.

Foundation leftovers: ~98 open checkboxes in foundation.md, ~60 of them 1D admin surfaces. 1B.14 Locations is complete except for items explicitly deferred to "when F1/F4/F5 ships" — those are the schema contract this plan must honour.


Phase 0 — Audit & cleanup

No feature code starts until this closes. The docs are currently load-bearing and wrong in places; building on them propagates the errors into schema that real patient data will sit on.

Deliverable 1 — platform-inventory.md

The single authoritative answer to "what exists." Every claim verified against code, schema, and deployed state:

  • Have — shipped, live, and where it lives
  • Don't have — specced but absent, with the doc that wrongly implies otherwise
  • Leftovers — half-built surfaces, placeholder pages, deferred-then-forgotten items, dead flags, orphaned specs
  • Prod vs. staging drift — what's deployed where, what's pending an apply

Deliverable 2 — corrected docs

Full rewrite; dead trees retired (git keeps history).

  • CLAUDE.md — replace the September-gate framing with this plan's premise. Correct the three-gates section.
  • Architecture docs — reconcile data-model.md Areas 2–7 and 11 against the shipped substrate and the glossary. Area 3 is still titled "Service Catalog"; Area 10 is marked SUPERSEDED and should be retired outright.
  • F1–F6 feature specs — reconcile each against data-model.md, patterns.md, and the glossary before its build starts. These specs predate the holistic schema audit and are stale in known places.
  • Retire apps/docs/gaps/ (27 files of doc-debt organized around a launch framing that no longer exists) and features/treatment-plans/ (superseded by features/programs-and-assignments/).
  • Archive june-demo.md and june-launch.md behind explicit historical banners.
  • Owner briefs (8 + README: consents, infra, media, notify, programs, telemetry, translations, tv) — re-verify against current code.

Deliverable 3 — corrected memories

68 project memories, many carrying June-demo / September framing or "pending prod deploy" state since resolved. Prune the historical, correct the stale, keep the durable.

Known Phase-0 reconciliations

#ItemStatus
1enrollments maps to what?Settled — already shipped as protocols.kind='enrollment'. Correct the glossary.
2F8 SegmentsSettled — out of scope; later patient-data-segmentation feature.
3F2 Offerings split (port map §8.1)Settled 2026-08-02 — YES. Ship the F2.1-only offerings stand-in: catalog identity + offering_specialists + offering_forms. No pricing, no plans, no products, no purchase path. Named offerings from day one. F2.2 / F2.3 stay deferred.
4specialties: per-org or global? (§8.4)Settled 2026-08-02 — per-org. Matches leo, data-model.md Area 2, and the specialists feature spec. specialties.organization_id NOT NULL.
5CNP on generated documents? (§8.3)Settled 2026-08-02 — yes, but opt-in per form and per document. See below.
6Override scoping (§8.2)Settled 2026-08-02 — per-calendar, with an all-calendars option. See below.
7Appointment-package tracking ("N sessions remaining")Open — no platform equivalent; F2.2-adjacent, likely deferred with it.

Override scoping — settled 2026-08-02

Availability differs per calendar; a booked slot is booked everywhere. Those are two separate mechanisms and must not be conflated:

  • specialist_schedule_overrides.calendar_id UUID NULL — non-NULL scopes the override to one calendar (block Tuesday afternoons for Physio only); NULL means all of that specialist's calendars (on holiday — block everything). One nullable column covers both cases; no second table, no mode flag.
  • The double-booking exclusion constraint stays keyed on specialist_id, never on calendar_id — so a slot booked through any calendar blocks that specialist across all of them. This is leo rule S6, already proven in production: the constraint is partial on status = 'created', so cancelled bookings correctly stop blocking.

The distinction: overrides shape offered availability per calendar; bookings consume the specialist's real time globally. A specialist cannot be in two places at once (P40), regardless of which calendar sold the slot.

This also settles the three-way disagreement between our own sources — data-model.md Area 4 had no scope column, and the specialists feature spec named an appointment_type_id table that exists in no architecture doc. Leo's production schema scopes per-schedule (scheduleOpeningOverrides.scheduleId NOT NULL) and deliberately migrated toward that from a global table; making the column nullable preserves that capability while adding the all-calendars case leo lacks.

CNP handling — settled 2026-08-02

CNP is required on some forms and documents, not all. That makes opt-in the design, and it has hard consequences:

  • One home. CNP is pii_regulatedencrypted BYTEA via internal/core/crypto per CLAUDE.md, stored once on the patient-owned patient_profiles, as national_id_encrypted (cmd/check-classification enforces the suffix and type). Never duplicated per-org. organization_billing.tax_id_encrypted is the shipped precedent to copy.
  • Written by the patient only — settled 2026-08-06. The patient enters it in their own Portal profile; clinics read it through a permissioned, audited reveal endpoint. No staff write path and no form-field capture route. Accepted consequence: a front-desk-booked patient who has never logged in has no CNP, so an opted-in document must fail with a typed error naming the missing field rather than printing a blank. Detail in features.md → CNP handling.
  • Never in the generic value store. A custom_field_values.value TEXT column can never legally hold a CNP. A custom field of type national_id routes to the dedicated encrypted column or is rejected outright — it must not fall through to the EAV path (leo stores CNP as plaintext meta_value.value; that is G22).
  • Opt-in per template, both for forms and for pdf_templates. 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).
  • Reads are permissioned and auditedpatients.view_national_id on the reveal endpoint, plus the audit.ActionRead row it writes.

Retire on sight during the doc pass: the services / service_plans naming rows in the glossary's forbidden-terms table, data-model.md Area 3 ("Service Catalog") and Area 10 (already marked SUPERSEDED), and F2's treatment_plan_assignments_total reference — which points at a model superseded by programs/protocols.


Phase 1 — Feature build

Dependency order is not negotiable: F3 Forms precedes F4 because calendars reference form_templates; F5 depends on F1 + F4; F6 depends on F3 + F5.

F1 Specialists → [F2.1 Offerings?] → F3 Forms → F4 Scheduling → F5 Appointments → F6 Documents

The F2.1 slot is settled (2026-08-02): it ships, between F1 and F3. The Offering is the configuration spine, so every downstream feature references it — calendars.offering_id (F4), appointments.offering_id (F5), offering_forms (F3.4), offering_specialists (F4.3). The build order is therefore F1 → F2.1 → F3 → F4 → F5 → F6, and it is not negotiable. Retrofitting an offering_id onto forms, calendars, appointments, and documents after the fact is precisely the "patch the schema later" failure this plan exists to avoid.

Per-feature definition of done

Every feature carries all of it. Run /new-domain and /new-migration — they encode the checklist.

  • Schema with organization_id NOT NULL + RLS policies calling current_app_has_permission(resource, action)
  • Permission seeding + system role-template grants
  • Route gating via RequirePermission; RequireURLOrgMatchesScope("id") on every per-org route group (P47)
  • Audit coverage on every state-changing mutation
  • Data-classification registry entry for every new column — same PR (CI-enforced)
  • SOUP row for every new direct dependency — same PR (CI-enforced)
  • Server-side pagination, async-typeahead pickers, GIN trigram + unaccent on queryable text (Production Scale rules)
  • Events partitioned monthly from day one; state tables flat (P41)
  • The 1D admin surface this feature needs, delivered with it
  • Glossary entry for any new vocabulary; feature spec updated to match what shipped

F1 — Specialists & Specialties ✅ SHIPPED 2026-08-03

Migration 000040specialties, specialists, specialist_specialties. Ten commits, b8d0a0d6a51147.

Delivered: both Go domains, per-org routes, the /v1/me/specialist-profile self-service surface, signature + avatar assets on a new SurfaceAvatars, the OpenAPI contract with regenerated Go/TS types and client wrappers, and the Clinic UI (roster, detail with Profile + Specialties tabs, create form) plus the Locations page — 1D.2, delivered here because F4's specialist_locations FKs a table that had no UI.

Decisions worth carrying into F3–F6:

  • Bookability is derived, never storedEvaluateBookability is a pure function on the model returning a verdict and a machine-readable reason, surfaced in both the roster and the detail hero. Replaces leo's lazily-provisioned intakes_opening_id, where a provider sat silently unbookable with nothing saying which precondition was missing. Until F4 ships specialist_weekly_hours the availability input is a tri-state reporting availability_not_yet_implemented — "cannot be determined", never an optimistic true.
  • The roster is clinic-authored — REVISED 2026-08-08. F1 originally shipped self-edit as ownership: a specialist changed their own name, title and biography through PATCH /me/specialist-profile, admitted by a human_id = current_app_principal_id() branch on the RLS UPDATE policy. Both were removed. A roster entry is how the clinic presents a provider to patients on a public booking page, under a name and title it stands behind, so the person named by it is not its author. specialists.manage is now the only way in. Reading is not authoring: GET /me/specialist-profile and the SELECT policy's ownership branch both stay. What a person owns and may change is their ACCOUNT — humans.name, timezone, locale — via PATCH /v1/me.
  • Permission codes follow the shipped {resource}.view_org / .manage convention, not the per-verb matrix rbac-permissions.md sketched. That doc is reconciled and explains the divergence.
  • The two assets serialise asymmetrically (changed 2026-08-08; folded into 000040 rather than shipped as its own migration, since F1 is on no environment). avatar_url is a public Bunny CDN URL on the wire — a clinician's photo is professional identity, rendered to unauthenticated visitors on the booking page. has_signature is a presence flag and the key never leaves: a signature is what makes an F6 prescription binding and is forgeable if leaked, so it stays private S3, presigned per read.
  • default: on an OpenAPI request property is a trap — openapi-typescript emits it as required, forcing callers to send the value the server already picks. Describe defaults in prose.

Deliberately not built: /team (staff memberships — 1D.2, unrelated to F1); leo's five other detail tabs, each of which needs F2.1/F4/F5; and leo's add-member form, which sets an initial password with no invite loop (the platform's Clerk magic-link staff invitations replace it — a specialist created here is calendar-only until an admin links an account).

F2.1 — Offerings ✅ SHIPPED 2026-08-03

Migration 000041offerings, offering_specialists. The configuration spine: three in-scope features hold NOT NULL FKs into this table, and offering_forms (F3.4) — the mechanism that decides which forms an appointment is created with — now has an FK target.

Delivered: the Go domain, per-org routes gated on offerings.view_org / offerings.manage, a dedicated s3.SurfaceOfferingCovers with presign-only reads, the OpenAPI contract with regenerated Go/TS types and client wrappers, the Clinic UI (catalog with search + specialty + status filters, detail with Details and Specialists tabs, create form, cover upload), RO/EN catalogs, and an RLS integration suite.

Scope held. No pricing, no plans, no products, no purchase path — F2.2 (reserved as offering_packages) and F2.3 stay deferred. offering_attachments is deferred too; nothing in F3–F6 depends on it.

Decisions worth carrying into F3–F6:

  • Two orthogonal visibility flags, not one enum. published = configured and usable; is_public = advertised on the self-service booking page. Published-but-not-public is a real state a clinic uses — it books the service on the patient's behalf without advertising it — so collapsing them would delete a capability. No CHECK ties them: effective patient visibility is published AND is_public, enforced in the RLS policy so a query that forgets the predicate still cannot leak a draft onto a booking page. This is stricter than F1's specialists policy, which has no publish concept, and the difference is the whole reason the patient branch was worth writing separately.
  • published_at stamps the first publish only and survives an unpublish, so it keeps meaning "first went live at".
  • The vendor column was dropped, not deferred. minicrm_title follows F1's minicrm_name: a vendor-named column for an unconfirmed integration goes in metadata until a real integration earns a typed external_* column. Two features have now made the same call, which makes it the convention rather than a one-off.
  • cover_url is a public CDN URL on the wire (changed 2026-08-08; folded into 000041, same reasoning) — artwork shown to unauthenticated booking visitors gains nothing from a presigned link and loses the ability to be server-rendered, since a signed URL in a cached page outlives its own signature. The cover keeps its own surface rather than borrowing SurfaceAvatars or SurfaceLogos: all three are public raster images now, but a person's photograph, org branding printed into PDFs, and artwork of nobody are different questions, and merging them would blur exactly what a security review goes looking for.
  • A new FK direction needs a new pre-check. offerings.specialty_id is ON DELETE RESTRICT, so specialties.CountInUse had to grow an offerings count — otherwise deleting a category dies at the constraint and surfaces as a 500 instead of a 409. Worth remembering at F4/F5: every RESTRICT FK added to an existing parent owes that parent's delete path a pre-check.
  • Roster priority is list position. The editor derives priority from the array index rather than exposing a number field. The schema still permits ties, and F4's even-distribution strategy is the intended way to say "no preference".

Deliberately not built: leo's six other servicii detail tabs (acorduri, chestionare, parametri, evaluare-mobilitate, raport, recomandari) — every one of them is an offering_forms slot arriving with F3.4 — plus the calendar tab (F4) and atasamente (deferred). Scaffolding them empty would advertise working features that aren't.

F3 — Forms & Consents

Versioning + immutability are core (P14, P18, P19). Must precede F4.

  • custom_fields + versioning, form_templates, forms (instances), form file uploads
  • Junctions: offering_forms, calendar_forms
  • Signed forms are immutable — reject edits with 409 Conflict
  • Template-version snapshot on instantiation
  • Profile-field sync
  • F3.5 Medical Consent Ledger (Tier B) — Tier B purposes register in the catalog; form-as-canonical-content; multi-modal signature capture; real consumers of RequireConsent. Integrates with the shipped consents domain — the per-clinic boundary and append-on-grant ledger are non-negotiable
  • 1D surfaces: Clinic form builder; patient Forms tab (replaces today's placeholder); portal form-fill + signature

F4 — Scheduling

  • specialist_weekly_hours, specialist_schedule_overrides, calendars
  • location_id UUID NULL on all three (NULL = remote/telerehab), per the 1B.14 contract
  • Single-true-availability invariant — DB-level EXCLUDE USING gist on (specialist_id, day_of_week, time-range) on both hours and overrides, regardless of location_id. A specialist cannot be in two places at once; locations label availability, never partition it
  • Slot-hold system in Redis (P44 forbids session-mode Postgres features — Redis is the correct home)
  • Availability engine
  • 1D surfaces: Clinic scheduling/availability UI

F5 — Appointments

STATUS: built on the local database only (migration 000046), 2026-08-06. This is the scope list, not the tracker — features.md § F5 carries the checkboxes, and per the layer-discipline rule those are what to read. Two items below did not ship as scoped: appointment_reviews is deferred (leo has no review concept anywhere — a spec table with no writer and no reader), and Daily.co is split out as its own feature, gated on a signed BAA rather than on appointment plumbing.

  • appointmentscreated here for the first time, with location_id UUID NULL, channel, protocol_id, session_id, and the patient/clinic status split the cadence redesign designed
  • appointment_files, appointment_reviews (deferred — premise unverified)
  • Public booking API
  • Daily.co video integration — split out; Cat A curated provider, BAA-gated
  • Consent gating in the booking flow
  • The patient's own surface (F5.7) — list, detail, self-cancel, self-reschedule, authenticated booking, all on the shared slot picker + hold protocol
  • Reconcile with session_runs' supervised-cadence comments in 000023_sessions.up.sql, which name the appointments table as the future source of truth for the adherence denominator
  • 1D surfaces: Clinic appointments calendar; portal booking flow

F6 — Documents

Three gates settled 2026-08-06, none of them still open: the rendering engine (measured, below), the prescription naming collision (the glossary had already settled it — bare word stays with the shipped exercise-program sense, the document type is always medical_prescription), and the CNP write path (patient-only, above). Full detail and the measurement table in features.md § F6.

  • Rendering: @react-pdf/renderer v4 server-side in Node, synchronous, in apps/clinic. No headless browser. The option the old framing missed — leo already renders with this library in the staff browser, and it runs in Node via renderToBuffer(), so leo's template components port near-verbatim. Measured against the alternatives on one representative A4 report: 41 ms p50 / 51 ms p95 and 31 MB of node_modules, versus 193 ms and a 518 MB image for headless-shell (the "~150 MB" figure in the old open item was wrong by 3.5×) and 410 ms and 2.45 GB for Gotenberg. Synchronous because queueing a 41 ms render would attach the whole exercise_renders retry apparatus to hide a sub-50 ms wait, and D8 requires immediate download. In apps/clinic because generation is staff-only and a fourth Node deployable is a service to size, alarm and pay for — prod's media at desired_count = 0 is what that costs.
  • Romanian diacritics are dropped silently by defaultș and ț vanish with no error while â survives. A Romanian-capable font must be registered from a real file path (leo registers by browser URL, which resolves to nothing server-side), and a round-trip test must assert it.
  • pdf_templates, documents, appointment_documents
  • pdf.Renderer capability (internal Go interface, per the glossary) — the Go side owns the contract and stays the only writer of appointment_documents; it does not rasterise
  • PDFs are self-contained — signatures embedded as base64, no external URLs
  • Specialist signature REQUIRED for prescriptions — generation fails without it
  • Form-to-PDF wiring (depends on F3)
  • 1D surfaces: Clinic template editor + document list; portal document access (read-only — the Portal never generates)

Treatment journey — per-phase cadence + phase kinds — BUILT 2026-08-24, local only

A journey is ONE program whose phases are its ordered stages, not a chain of prescriptions. The database had already settled half of it: uq_one_active_prescription_per_patient (000023) permits a patient exactly one active prescription, "the responsible model is one coherent, deliberately dosed daily plan (multi-concern → compose ONE program)". What was missing is that a stage could not carry its own dose, so a program was a flat list of sessions at one frequency for its whole life — while real rehab steps up: two weeks at 3×/week, reassess, then five at 5×/week. Full spec in cadence-and-supervision.md → Treatment-as-journey.

  • Schema — folded into 000025 (both environments are due a rebuild; the fold was verified by a from-scratch build diffed against the running local database, zero drift in columns and constraints). program_phases gains kind (exercise | reassessment, with milestone / goal reserved on the SAME spine), nullable cadence_kind / cadence_config / supervision_mode, and entered_at.
  • NULL means inherit. That one rule buys undosed templates (the prescribe dialog asks, so one program is dosed differently per patient), dosed templates (prescribe prefills, the clinician overrides onto the instance — never back to the template), and bit-for-bit unchanged behaviour for every program that predates the column.
  • entered_at is stored, not derived, and this is the load-bearing design point. Cadence is calendar-anchored; stages are completion-anchored. Stage 2 begins whenever the patient finishes stage 1 (or the specialist unlocks it), and the adherence walk cannot segment a lifetime by stage without knowing the calendar day each segment opened. Deriving it from the last completed run was rejected: a deleted or corrected run would retroactively move a boundary and silently rewrite historical adherence. Progression (done/current/locked) stays derived, as it always was.
  • Adherence composes rather than changes. adherence.BuildSegments + ExpectedOccurrencesSegmented slice the lifetime into one interval per entered stage and run the EXISTING pure walk once per interval. BuildSegments returns nil when no stage overrides, which puts the ordinary protocol back on the exact code path it was on before. A leading gap (a stage stamped, an earlier one not) is filled with the protocol's own cadence rather than dropped from the denominator.
  • The play gate resolves the stage, not the session list. A reassessment stage holds no sessions, so reading "the phase of the next un-played session" would step straight over the checkpoint into the stage it gates. The gate now walks the phase spine, and GetProgramPhaseProgress enumerates phases and left-joins sessions for the same reason.
  • The transition week resets at stage entry — a Wednesday step-up to 5×/week starts a fresh week that Wednesday. That is what a specialist means by "from now on"; the alternative penalises the patient for the calendar position of a clinical decision.
  • Clinic UI: the compose sheet opens on a JOURNEY canvas (stages in order, rename / reorder / gate / remove, four add buttons) instead of a program grid; "add stage" is the existing two-step composer with the stage's own name, gate and optional cadence; "add reassessment" is a one-field checkpoint. One submit still creates program + stages + copies + protocol in a single transaction. Milestones and goals are present as disabled buttons — they are their own domains, and goals wait on F16 measure identity rather than inventing a private metric enum.
  • Appending mid-journey works and is the common case — "two weeks, then we reassess and decide". The protocol's Content tab already had unpublish → add → republish (a content_edit pause); the added stage can now declare its kind and its own frequency, which is what makes the appended block clinically different from the opening one.
  • Not built, deliberately: the reassessment ↔ appointments link and auto-unlock on completion (no longer blocked on the appointments substrate — that shipped with F5 — but auto-unlock wants the care_team discussion for whose reassessment counts); milestones; goals.

Phase 2 — Harden (F11 + operational leftovers)

Pulled earlier only where a feature requires it — F3's consent work touches the ledger, F5's Daily.co integration needs a signed BAA.

⚠️ Next.js cache invalidation is broken in production — OWN SESSION, pick up cold

Found and reproduced 2026-08-06; remediated in the repo the same day but NOT yet deployed — production still runs the broken version.

The P42 tag cache is per-process with no TTL, so updateTag() only invalidates the instance that ran it:

  • No cacheHandler in any of apps/{clinic,portal,console}/next.config.* → tags live in per-instance memory.
  • app_replicas = 2 in production → a mutation on instance A leaves instance B serving the old page. The user sees it as "sometimes it doesn't save".
  • revalidate: false on 41 of 42 tagged reads → stale forever, with no TTL to heal it.

Cross-app is the same root cause: a patient rescheduling in the Portal leaves the Clinic stale until a hard reload. That is how it was found.

Decision (user, 2026-08-06): stop caching mutable data. Drop cacheTags from reads that change — appointments, availability, forms, patients — keeping them only on static catalogs (vocabulary, taxonomy, exercises). Correct by construction, no new infrastructure. Reads fall through to the Go API, which has P45's Redis layer where it is warranted.

The alternative, deliberately deferred rather than rejected: a shared Next.js cacheHandler on the existing Redis, which fixes cross-app and cross-instance together and keeps the performance win — at the cost of a dependency, config in three apps, and Redis becoming a cache-availability dependency. Revisit if measurement shows the uncached latency actually hurts.

Full inventory and per-tag verdict: P42 Cache Remediation — all 42 tagged reads traced to their consuming and invalidating app. It corrects three things assumed above: app_replicas = 2 describes the portal (clinic is 1 and autoscales to 8, console is pinned at 1), the portal has zero updateTag calls so the portal→clinic path was never wired rather than broken by a process boundary, and one call site already overrides revalidate with a 300s TTL. Verdicts: 23 drop, 12 keep-with-TTL, 7 keep. Pattern context at patterns.md → P42.

F11 Compliance hardening

  • DSAR export end-to-end — no route exists today; only the gdpr/anonymize.go helper
  • Erasure end-to-end — anonymise per Art. 17(3)(c), audit trail preserved
  • Retention automation — hot (0–12mo PG) → warm (12mo–6yr S3) → purge; never-delete classes preserved
  • Prod KMS rotation tested
  • Security scan passing

Family plans — BUILT 2026-08-15 (plan_members), local only

A lifetime family plan was sold to 500 customers, up to 10 profiles each. The schema could not express it: a subscription belonged to ONE person's enrolment, so a family sharing a login got one patients row each and only the subscriber had entitlements. Switching to a dependent returned an empty set and every paid prescription answered subscription_required.

Built end to end and running locally. Nothing is on staging or production; that branch needs make migrate-up before it deploys.

The model

A family plan is a TIER WITH A SEAT COUNT. Not a new kind of subscription and not an entity of its own: max_plan_members on the tier, counting the subscriber, so "up to 10 profiles" means ten people of whom the payer is one. A tier carrying no seat limit is an ordinary individual plan. Same table, same catalog, same frozen snapshots, same lifecycle.

The roster hangs off the SUBSCRIBER, not off the subscription row.plan_members(organization_id, owner_patient_profile_id, member_patient_profile_id, joined_at, left_at). That is the load-bearing choice: SetSubscription SUPERSEDES — a tier change cancels the row and inserts a new one — so a roster stored on the subscription would be emptied by exactly the operation a growing family performs, and again by any cancel-and-repurchase. Keyed on the owner, none of that reaches it.

Both ends key on patient_profiles — the PERSON. Not patients, which is one clinic processing chapter and is restarted when somebody withdraws and returns; a place on a plan sold "for life" must not vanish with the old row. Not humans either, since P7 lets a dependent claim their own login.

The shape is the caregiver graph's — edges around a person — and the MEANING is the opposite axis. patient_caregivers: I may act for you, and see your record. plan_members: my subscription pays for you, and I see nothing. Merged, adding a grandparent to help with booking would consume a paid place, and revoking their access would cancel somebody's content. Both surfaces say so in words.

Coverage is the UNION of every live subscription reaching a patient: their own, and the plan they are on. Ranking them would take something away from somebody who was entitled to it.

What was tried first, and why it was reverted

A households entity was built (org-scoped tables, a second anchor on patient_subscriptions, four rewritten RLS policies) and reverted in 7ed1064a after the owner tested it. The defect only showed in use: it made a clinic create a household and THEN attach a plan, and the natural failure of that flow is a household with no plan — indistinguishable from something being broken. The owner did exactly that, put the subscription on the person instead, and the second member was correctly not covered.

Two arguments made for it were wrong and are recorded so they are not made again:

  • "The clinic must attach the plan because a patient cannot pay." There is no payment path for ANY subscription (payment_provider = 'external'; staff set tiers by hand). A platform-wide limitation dressed up as a household-specific rule.
  • "Supersede forces a separate entity." It forces the roster off the SUBSCRIPTION. Keying on the owner solves it with no entity at all.

Accepted trade-offs, deliberately: if the subscriber withdraws from the clinic, the cascade cancels their plan and members lose coverage with it; and a payer who is not a patient needs a patient record to hold a subscription.

The rules, as built

Seat countmax_plan_members, counting the subscriber. Override-first, frozen snapshot second — the same precedence everywhere it is read.
Per-customer exceptionAn ordinary patient_subscription_overrides row. This is the mechanism for the 500-customer cohort: the tier stays at its published number and each subscription carries a cap_value = 10 override with a reason.
Adding with no subscriptionRefused (no_live_plan). A roster that silently covers nobody is the household failure again.
Downgrade below the rosterRefused, naming how many must come off. Resolves the cap that will ACTUALLY apply — a carried override first — so an exception does not get refused by the tier's published number.
Revoking a seat overrideRefused the same way. It is a downgrade by another route and was the unguarded one.
Granting a second exception for one codeSupersedes the first. Two active overrides both resolve, so the older one hid behind the newer and revoking moved nothing.
Tier changeActive overrides are COPIED to the replacement. Without it a tier change silently revoked the places a customer paid for.
CancelEnds every place on the plan. The plan is over, so the places are.
ExpiryDoes NOT. Coverage already stops through the period gate, and renewing restores everybody.
Churn boundAt most cap + 3 DIFFERENT people on one plan per rolling 12 months, counted per person so a re-add counts once. The seat cap does not bound this at all — a plan can sit full forever while dozens pass through it. Refused on the patient's own path; staff are told the number (people_on_this_plan_this_year), because only the clinic can tell a large family from a scheme.
Rejoin cooldown90 days before joining a DIFFERENT person's plan; returning to the same one is always allowed. Enforced on the patient's own path, waived for staff — applied to a front desk it blocked corrections to its own typing for ninety days while stopping nothing, so staff are told instead (recently_left_another_plan_at).
One plan per person per clinicNot waivable by anybody. A structural invariant, not a policy.

Surfaces

  • Clinic — "People on this plan" inside the patient's Abonament tab, on the same SWR key as the subscription because the two are coupled. Seat overrides live with the other overrides, with a kind selector. The tab says when a relative's plan covers this patient.
  • Patient tiers — a "People covered" field. patient_tier_limits had existed since 000005 and the publish snapshot always read it, but nothing had ever written a row, so no tier on the platform carried any cap and a family plan could not be created at all.
  • Portal — "Your plan" directly under "people on this account", so the two axes are visible together. A member is told WHOSE plan covers them, by name. The owner adds people from their own account only.

Traps worth knowing before touching this

  • patient_profiles RLS hides people you neither own nor care for, so joining it to resolve a member's name silently dropped everybody the subscriber merely pays for — the membership row visible, the person behind it not. The clinic saw three people, the subscriber saw two, and the person just added was told they had no plan while their content played. Fixed with plan_peer_name, which returns a NAME and nothing else; an RLS policy would have handed over date of birth, phone, allergies and the encrypted national id. The whole suite missed this while passing, because every roster read in the tests went through a staff transaction where the other branch applies.
  • A scalar SELECT over two subqueries always returns a row, so "no seat limit" and "a limit that says unlimited" both arrive as NULL. They mean opposite things.
  • The rlstest harness commits at cleanup, so a service call inside its transaction is invisible to the admin-pool play gate during the test.

Still open

  • A report of plans with unusual churn. The bound refuses and warns per add; nothing shows a clinic which of its plans have been churning. All the data is there and it is the natural follow-up.
  • A member who is not a patient here. They can hold a place and the card flags it; nothing helps the clinic take them on.
  • A seated person with no login can never see their own coverage. Staff can seat account-less profiles, which is correct, and nothing surfaces that consequence.
  • Consent by age is designed, not built. BUILT 2026-08-15, local only — see "Consent by age" below.
  • The cohort backfill — 500 subscriptions, each with a cap_value = 10 override. Migration-phase work.

The gate never asked about the right person.current_required_consent_versions built its profile set as self ∪ everyone this login is a caregiver for, then passed if any of them had accepted. So a parent's own org_terms satisfied the gate for a child who had consented to nothing: the child's records were created and used while the ledger, whose subject is the patient_profile_id, correctly showed no consent for them at all. Nothing errored — the question was simply never asked.

The grant side already worked. ResolveActingProfileID resolves the switcher's selection and granted_by_principal_id records the caregiver, so a consent given on a dependent's behalf was already recorded honestly. Only the gate was wrong.

What changed.

The subjectcurrent_required_consent_versions takes a third argument naming the ONE person being asked about. The principal still supplies authorization; the subject only NARROWS that set. Carried by TWO scripts, because CREATE OR REPLACE with a new signature overloads rather than replaces: 000008-consent-subject.sql adds the new form before the deploy and 000008b-drop-old-consent-subject.sql removes the old one after it. Combining them would error every consent gate from the DDL until a reviewer approved — the runbook's own additions-before / removals-after rule. Ordered sequence in production-apply-runbook.md → step 5.
Fail directionAn unreachable subject leaves the set empty, which reports every required purpose as unaccepted and blocks. The function's empty RESULT means "proceed", so a resolution failure had to land on the blocking side.
Who may answerA caregiver may consent for a person under 18 and not for an adult. AgeOfMajority = 18 because these consents are contract basis — capacity to be bound, which is majority. Deliberately NOT GDPR Art. 8's 16, which governs consent-basis processing; two thresholds, two legal bases.
Unknown ageCounts as an adult. Refusing is recoverable; wrongly handing somebody authority over an adult's record is not — and leaving a field blank must not be how that authority is acquired.
Date of birthRequired by AddFamilyMember, and by the Portal form. It decides what the caller may afterwards do, so optional would only move the guess out of sight. A future date is rejected separately: it makes somebody a minor for a century, which is the direction that GRANTS authority.
StaffUntouched. The rule fires only where the grantor reaches the subject through a patient_caregivers link; a clinic recording a paper consent has none.

The accepted cost, decided by the owner after it was raised: an adult added by a relative — the elderly-parent case the feature was built for — cannot be acted for through the family account until they claim their own profile and accept for themselves. The clinic can still treat and book them from the staff side, which does not pass through this path.

Covered by internal/test/rlstest/consent_by_age_test.go (the union regression, the fail-closed direction, child-yes/adult-no, unknown-age-as-adult, and both date refusals) plus patientprofiles/age_test.go for the birthday and leap-day boundaries.

The Portal state, because the alternative was the worst screen in the app. The reconsent modal is undismissable by design — a patient with outstanding consents accepts or signs out — and rendering it for an adult dependent produced an Accept button the API refuses, a generic "something went wrong", and no way back, with the stuck person not even being the one asked. AdultMustClaimNotice replaces it: it names the adult, offers to send them a login, and always offers the way back to the caller's own record. Nobody should sign out of their own account to escape a screen about somebody else.

The age rule is not re-implemented in TypeScript. ManagedProfile gained may_consent_for, computed server-side by the same helper the grant path calls, so the UI cannot offer something the API would refuse — the drift that produced the dead end in the first place.

Found on the first run against a real family account: the gate was hiding its own way out. /v1/me/managed-profiles sat INSIDE RequireCurrentConsents, so the Portal asked who it was acting for, got 412, rendered an empty switcher, and fell straight through to the accept modal — which then 403'd. Moved to the ungated sibling group alongside /me, /me/required-consents and /me/clinics, together with the claim invitation, which for an adult dependent is the ONLY remedy and so cannot require the consents it exists to obtain. The two writes (add, remove) stay gated: they are not an escape from the block.

The structural gap this exposed is still open. h.API(t) supplies middleware only — every test registers its own routes — so no test in the suite observes where a handler is actually mounted, which is why a mount-location bug shipped through a green check-all. The behaviour is now locked by consent_gate_escape_test.go, but that mirrors the sibling-group layout rather than reading it. Closing it properly needs a seam for the auth chain so the production router can be built in a test; server.New gets all the way to rate-limit validation with a loaded config, so the remaining obstacle is only the Clerk verifier.

An adult with no email — the case that nearly broke family accounts (2026-08-15)

The age rule left a hole the owner found by using it: an 80-year-old on their daughter's account cannot hold a login, so "they accept for themselves online" is not a remedy, and the daughter may not accept for them because capacity is theirs and relationship is a word picked from a dropdown. Between those two the person was permanently blocked — which would have made profiles-inside-an-account useless for exactly the case it was built for.

The premise that had to go was "an account is what makes a consent valid." An account is only a way to authenticate a click. A form signed at reception and entered by a named staff member is stronger evidence than a click from an inbox somebody else created — and the clinic is the controller, the party that verified who it was talking to.

Four changes, and the first is what makes the rest sufficient:

Platform purposes no longer apply to a profile with no loginplatform_terms and platform_privacy_notice are the contract between the platform and somebody who USES it; a dependent never does. Requiring them left the person blocked with nobody able to clear it — staff may record org-scope consents and deliberately may NOT accept platform terms for anyone. The obligation attaches when the profile is claimed and human_id is stamped.
The clinic records itconsents_insert_staff and source = 'staff_action' already existed; the endpoint had no caller. Now the consents tab shows what is outstanding and records it, and GET …/consents/required returns only what a clinic can actually clear.
The clinic attests a representativepatient_caregivers gained is_legal_representative + the basis + who attested it when, set only by staff holding consents.manage. For the standing case, where somebody answers repeatedly and coming in each time is the wrong ask.
may_consent_for accounts for itSame three facts in the same order as the grant path — own profile, minor, attested — so the Portal cannot offer what the API refuses.

Why the attestation is not the self-declaration that was rejected earlier. The distinguishing fact is not what the relationship is called; it is that a named staff member, at a named time, wrote down what document they were shown. The platform does not adjudicate guardianship — it exists in national law and is produced on paper — it records that the controller saw it. A basis is required to set the flag and cleared when it is withdrawn, so a stale reference never sits on file behind a false flag.

The rule stayed per-person, not per-caregiver: vouching for somebody's standing over one patient does not let them act for another, which has its own test.

The 000006 edit carries a fifth catch-up script — see the runbook's step-5 table. The Portal notice now leads with ask the clinic rather than "send them a login", because the login is the remedy that fails for precisely the person most likely to be reading it.

Consents & agreements — model corrected, DECIDED NOT BUILT (2026-08-16)

Reviewing the adult-with-no-email work surfaced that the consent catalog models two tiers where the product has three, and that org_service_disclaimer is a composite of three artifacts that already exist elsewhere. Decisions are settled and written up in consents-and-agreements.md: an enforcement column (blocks_access / gates_feature / preference) replacing the legal_basis overload, org_service_disclaimer retired in favour of a telerehab feature-gate consent, signability decoupled from legal basis so a document can be signed AND block AND be re-accepted on a version bump, and form triggers made first-class so a consent can be asked for at program start.

One live defect found on the way and worth fixing first, independently: two safety texts give opposite instructions. The service disclaimer says stop and seek medical advice on acute pain; apps/portal/content/safety/v1.md, shown before every session, says reduce the range and move to the next exercise. Both are shown to the same patient.

⛔ Patients receive NO email, ever — launch-blocking (found 2026-08-13)

SUPERSEDED 2026-08-25 by notifications.md, which is now the single plan for all notification and email work. Two facts below have drifted: there are eight categories, not five, and three of them do reach a patient (form_session_link, email_change_confirm, email_change_notice). The substance holds — nothing clinical or appointment-related sends anything — but read the plan, not this section, for scope and build order.

A clinic cannot operate without this, and it is not partially built: it is absent.

internal/core/notify has exactly five categories — owner welcome, break-glass opened, render failed, webhook-subscription paused — and the template directory holds those five pairs and nothing else. Grepping the whole appointments domain for notify. returns zero hits.

So today a person who books, publicly or from the Portal, receives no confirmation, no reminder, no ICS, and no notice of a cancellation or reschedule. Their only signal that anything happened is the JSON the booking page rendered.

Contrast the one path that DOES send. Patient invitations (POST /v1/organizations/{id}/patient-invitations) are built server-side and Clerk delivers the magic link — but createPatientInvitation has zero callers in any app. There is no invite-a-patient button anywhere, and nothing connects it to the create-the-patient flow. Staff invitations, by contrast, are wired.

What it needs, smallest first:

  • A booking confirmation category + templates (ro/en), sent AFTER the transaction commits — a send inside withAdminTx fires on a rolled-back booking. Recipient comes from appointments.contact_email, because patient_profiles has no email column and a guest booking has no humans row at all.
  • Reminders, which need a scheduled sweep rather than a request-path send.
  • Cancellation / reschedule notices — the appointment lifecycle already has the transitions; nothing listens.
  • An invite-a-patient button in the clinic app, wired to the endpoint that already exists, so a patient the front desk registered can be given a login.
  • SES bounce/complaint suppression is listed below and becomes a prerequisite the moment real volume starts, not an afterthought.

The docs already assert some of this as intent — features/appointments/index.md says contact_email is "stored for confirmation notification" and the upcoming → confirmed transition says "Notify specialist". Both are DESIGNED-NOT-BUILT; do not read them as shipped.

Left open by the patient-identity session (2026-08-13/14)

Family accounts, staff intake, the guest re-point and profile claiming all shipped in 65c1dcef, a2ff8564, fdc63c0c. What follows was found in the same pass and deliberately NOT built.

Re-onboarding orphans four clinical tables — the one that corrupts a relationship rather than omitting a feature. A patient who withdraws (org_terms withdrawn → the 000008 cascade soft-deletes their patients row) and later returns gets a brand-new patients row by design (decisions.md: each onboarding is a fresh processing chapter). But protocols, programs, sessions and session_runs all key on the OLD row, and every resolver filters deleted_at IS NULL — so their prescriptions and exercise history become unreachable. Nothing is deleted; soft delete means CASCADE never fires. Meanwhile appointments, forms and consents survive intact because they key on the profile. The platform therefore holds two different answers to "what is this patient's history".

SETTLED 2026-08-14, BUILT 2026-08-15, local only. The plan seat survives a withdraw-and-return because plan_members keys on the person; exercise history and purchased content now do too.

No migration of its own. A standalone migration was written, then removed: the columns are not a change of mind about these tables, they are what the tables always meant, so they were folded into 000023_sessions and 000034_patient_content_grants where the tables are defined. Both are applied everywhere, so the edit carries a hand-applied catch-up — infra/scripts/000023-000034-history-follows-the-person.sql, idempotent, applied to local and still owed by staging and production. The edit was verified the way 000048 established: a from-scratch rebuild diffed against the running database, differing in nothing but the monthly partitions the cron had already rolled.

The columns. session_runs.patient_profile_id and patient_content_grants.patient_profile_id — BESIDE patient_id, not instead of it, following appointments (000046). Both facts are true and neither implies the other: a run happened during a particular chapter at a particular clinic, AND it is a person's own history. Nullable with ON DELETE SET NULL like the column beside them, because that nullability is what lets GDPR anonymisation detach an identity while the run survives for aggregate paths — a NOT NULL profile column would defeat the erasure the nullable one permits. Backfilled from the chapter.

Deriving it is a DATABASE invariant, not a caller's duty. A BEFORE INSERT trigger (trigger_derive_patient_profile_id, SECURITY DEFINER, search_path pinned empty) fills the column from the row's own patient_id on both tables, and the Go writers no longer name it. That replaced per-writer derivation for two reasons the test suite demonstrated rather than argued: a raw INSERT — a fixture, an admin-pool backfill, the ~20k legacy import — writes NULL and the history is invisible from the day it lands; and a Go subquery running on the RLS pool writes NULL too whenever the caller cannot SELECT the patients row it is reading. Neither errors.

The reads that moved, and the ones that deliberately did not. Moved: the patient's own run history and the pain/effort series over it, the activity log, the per-exercise and pain summaries, and the content-grant resolvers (play gate, staff list, browse). Stayed on the chapter: protocols, programs, sessions, the org onboarding funnel, and adherence against a live prescription.

Three RLS self-SELECT policies moved too, and nothing above them would have worked otherwise. session_runs_select_self, session_pain_events_select_self and session_exercise_events_select_self each resolved the caller through a LIVE patients row — precisely the row withdrawal removes — so the patient's own reads were gated on the chapter no matter what SQL sat above them. SELECT moved to the person; INSERT and UPDATE stayed on the live chapter, because a run can only be started or completed inside a relationship that is currently open.

Trap, caught by the new test rather than by review. Swapping patient_id for a profile widens what the SQL itself selects: a patients id belongs to one clinic, a profile spans every clinic the person has attended. ListActivity had no organization predicate — it never needed one — and became cross-tenant the moment it keyed on the person. Every profile-keyed read now derives the org from the same patients row as the person, and internal/test/rlstest/history_follows_person_test.go covers both halves: history and purchases survive a withdraw-and-return, and neither crosses to another clinic.

The decision itself is unchanged and recorded here: session_runs (+ events) and patient_content_grants re-key to patient_profile_id because they are the person's own history and the person's own purchases; protocols / programs / sessions stay on the clinic relationship, because a months-old prescription silently resuming after somebody left and came back is a clinical event rather than a restored convenience — and that consequence gets written into decisions.md, which currently argues for the fresh row without mentioning what detaches. Gets more expensive after the legacy migration lands, because then it is history the platform did not create.

The claim accept-and-bind is untested end to end. The Go side is covered and mutation-verified (TestClaimProfile_DependentTakesOverTheirRecord), but accepting the provider's magic link is a browser round-trip through Clerk that no test exercises. A claim that silently mints a second profile is precisely the failure the session removed everywhere else.

The switcher disables cross-clinic dependents, which blocks the phase-one booking built for exactly that case: somebody added at clinic A is unknown at clinic B, and booking them there is what puts them in B's callback queue. Needs an interstitial ("not registered here yet — book their first appointment") so no /me/* read is attempted for them.

packages/api-client has no test setup at all — no vitest config, no tests. That is how a real bug reached the browser this session: request() special-cased 204 only, so the first endpoint answering 202 with an empty body threw on success and reported "could not send the invitation" for an invitation that had been sent.

TestPatientSubscriptions_OverrideGrantRevoke is flaky under make check-all's parallel load (resolved set missing content.premium). Passes in isolation and passes the package run; not caused by this session's changes, but it makes a green full check less trustworthy than it looks.

Redis has noeviction and no alarm — verified open 2026-08-14

The parameter group sets maxmemory-policy = noeviction, and that is the right call: Redis holds auth SESSIONS and booking HOLDS, not just cache. The redis7 default (volatile-lru) would evict TTL-bearing keys under memory pressure — silently logging patients out, and freeing a booking hold while the patient is still looking at the slot, so two people confirm the same time.

The cost of that choice is that memory exhaustion refuses WRITES rather than degrading, and every load-bearing Redis path is a write: session creation (logins fail), hold acquisition (bookings fail), rate-limit counters, locks, JTI reservations, cache-aside fills, and the caregiver grant codes added 2026-08-14. So it is not a gradual slowdown visible in latency graphs; it is a fairly abrupt platform-wide write failure.

And nothing watches it. infra/modules/observability alarms on ALB 5xx, RDS (CPU / connections / free storage), ECS (CPU / memory / no-running-tasks) and NAT. There is no ElastiCache alarm of any kind — not memory, not evictions, not connections.

Smallest useful fix: a DatabaseMemoryUsagePercentage alarm on the AWS/ElastiCache namespace with a threshold well below full, since the point is to have room to act rather than to be told at the moment writes start failing. An Evictions > 0 alarm is worth adding beside it as a canary — under noeviction it should be flat zero forever, so any non-zero value means the policy was changed or is not what the parameter group says.

Operational leftovers (verified open)

  • Sentry in clinic, console, api, telemetry, media — portal is the only wired surface today
  • SES bounce/complaint handler + notification_suppression — SNS topic, signature-verified webhook, EmailChannel precheck
  • Backup crondone 2026-08-02. /bin/backup-runner and /bin/restore-drill are both built into the api image (services/api/deploy/Dockerfile.api), enable_backup_cron = true in infra/envs/production/terraform.tfvars (51a0492), and a real restore drill ran green (0b5670c) after the first attempt failed and produced migration 000039. 000039 is on production — verified against the live DB 2026-08-03. Nothing remaining.
  • iOS playback — no Bunny token-auth signed-URL code exists; confirm against current prod telemetry whether the CoreMedia 403 is still live before building
  • Media autoscaling — prod media sits at desired_count = 0; CPU target-tracking cannot scale from zero
  • WAF decision for clinic custom hostnames — Cloudflare WAF-for-SaaS add-on vs. AWS WAF on ALB. Sharpened 2026-08-09: there is no Cloudflare WAF or rate-limiting ruleset in Terraform for any zone — only TLS/redirect/header settings — so the add-on option presumes zone rules that don't exist. Good news from the same audit: the production ALB is already locked to Cloudflare IP ranges, which is the control that makes any edge protection binding. Staging's ALB is open (0.0.0.0/0) by documented choice.
  • Rate-limit sizing + request amplification (found 2026-08-09 — ordinary browsing of the clinic calendar returned 429). Ordered, do not reorder:
    1. Raise RATELIMIT_AUTH_VERIFY_LIMIT 120 → 600. Config default only, no new code; Terraform doesn't override it, so one change covers every env. 120 was sized as if a browser talked straight to the API — this platform is server-first, so one click is 3–14 calls.
    2. Fix the amplification per P59: the read-implemented-as-server-action and the redundant refresh() in apps/clinic/app/(dashboard)/calendars/[id]/specialists/actions.ts. Then sweep for the same shape elsewhere. This is the only item here with no trade-off — it lowers the number every limiter, pgbouncer transaction, and auth-chain call has to be sized against.
    3. Attach org + principal to 429s and surface "which orgs are hitting the limit" in Console. Today a 429 is a WRN line with a path, which can't tell a looping browser tab from a clinic hitting a wall.
    4. Only then decide the per-principal split (P60). It is the right eventual design and is deliberately deferred — it costs a permanent second Redis round-trip per request, raises what one stolen token can spend 5×, and buys nothing if clinics share a login. Step 3 is what answers that.
    5. Fix the 429 failure UX — a 429 on getMe in the clinic dashboard layout wipes the whole app and offers only Deconectare, which is the worst available action: signing out cannot reset a per-IP window and burns more requests. Branch on 429 and surface the Retry-After the API already sends.
  • Legacy import toolingservices/migration-tools/ is referenced by the deployment runbook and does not exist
  • Cross-region backups (still open — storage-backups provisions one primary bucket in eu-central-1 with no replication rule). The restore drill itself shipped (cmd/restore-drill + infra/scripts/restore-drill.sh, ran green 2026-08-02) but is wired to no schedule. Also open: status page, on-call rotation, support@restartix.pro mailbox, k6 load gate, external synthetic monitor

Phase 3 — Migration

Planned only after Phase 1 closes, against the real completed schema. Everything here is deliberately unplanned today; scoping it now would be guessing at a target that doesn't exist yet.

Standing constraints that survive into that phase:

  • Counsel sign-off on v2 legal text gates the migration. Real patients cannot be migrated under un-reviewed templates — once migrated, the consent shape is permanent, not temporary. The 2–3 week counsel lead should start well before Phase 1 closes.
  • The forward-only migration freeze binds at the migration gate. Until then, migrations not yet applied to any environment stay editable; anything already on prod requires a catch-up DDL script.
  • Production has been live and serving real patients since 2026-06-05. It is never reset.

Locked decisions

  • No target date. Correctness gates progress, not the calendar.
  • Migration last. It is Phase 3 and cannot be pulled forward.
  • Full scope, no minimum slices. A half-built appointments table is worse than none.
  • Glossary names win — except where the glossary itself is stale. New code never uses forbidden terms; the service_plansenrollments reservation is corrected rather than honoured, because the concept already shipped as protocols.kind.
  • Port over rebuild. F1–F6 re-home proven leo functionality onto a proper data model. The UI and business rules come across; the schema does not.
  • Architecture docs beat feature specs. Where they disagree, reconcile in Phase 0 before the build starts.
  • 1D per-feature. Each feature delivers the admin surface it needs. Purely-administrative 1D items (AI models registry, break-glass console, plans catalog) close in a separate sweep.
  • Production promotion stays the user's. Never merge stagingmaster; never dispatch deploy-production.yml.

What this rules out

  • "Ship F5 with a thin appointments table and enrich it later." Real patient rows make ALTER TABLE heavy; the substrate is paid for once.
  • "Start F4 before F3." Calendars reference form_templates. Building the reverse order means retrofitting the junction.
  • "Redesign F1–F6 from the specs." A working implementation of all six already runs in production at leo. Redesigning from stale specs discards operational knowledge that was paid for in real clinic hours.
  • "Port the leo schema too, it's faster." The database is the one part explicitly rejected. Carrying it across would import framework-generated naming, absent RLS, and a tenancy model incompatible with the foundation.
  • "Start F3 before the F2.1 Offerings question is settled." If the Offering is the spine, forms, calendars, appointments, and documents all reference it. Discovering that after F3 ships means an ALTER TABLE across four features.
  • "Assume Offering means access-offer." They are different concepts sharing a word. That conflation already cost one wrong scope decision in this document's history.
  • "Skip Phase 0, the docs are close enough." They assert an appointments table that does not exist. Close enough is how that claim survived six weeks.
  • "Plan the migration in parallel to stay on schedule." There is no schedule, and no schema to map onto until Phase 1 closes.

Salvaged documentation debt

apps/docs/gaps/ (27 files) and apps/docs/features/treatment-plans/ (5 files) were retired on 2026-08-02. Git keeps both.

Almost all of it was either aspirational ("we should write a testing guide") or overtaken by shipped work — internal/core/ratelimit is wired across routes.go, deployment.md covers CI/CD, P42–P48 cover caching, the Production Scale rules in CLAUDE.md cover search/filtering/pagination, and the treatment-plans tree was already banner-marked superseded by programs-and-assignments, whose storage-layer split is documented in telemetry and decisions.md. Four items were real, decided-or-open, and recorded nowhere else. They survive here.

1. Per-table retention windows — decided 2026-06-02, engine still unbuilt

gdpr-compliance.md records the regulatory floors (audit 6 yr, medical records 6 yr, consents 6 yr). It does not record the windows decided for the operational event tables. Every table name below was verified against migrations:

Table(s)Hot (Postgres)ThenDriver
audit_log, audit_ai_provenance12 moS3 warm → purge at 6 yr (per-org override may extend)CLAUDE.md hard rule; legal/forensic
session_pain_events, session_exercise_eventsindefiniteanonymise on erasure; no automated droppatient clinical data; clinic is controller
notifications, notification_deliveries18 modroprendered-PII forensics + GDPR Art. 15 access window
usage_records (metering)24 modropbilling-dispute / reconciliation window
media_buffering_events, media_library_views (telemetry service)18 modroppseudonymised QoS/engagement trend analysis
outbound_webhook_deliveries90 daysdropdelivery debugging only
inbound_webhook_dedup30 daysdropdedup window is minutes–hours
notification_idempotency_keys30 daysdropflat table; periodic DELETE, not a partition drop
session_tv_liveness30 daysdropTV heartbeat; pure liveness signal

State, verified 2026-08-02:

  • No aging engine exists anywhere. internal/core/partitionroll/ creates future monthly partitions; nothing drops, detaches, or archives one. grep -r "DROP PARTITION" services/ returns nothing. Every partitioned table grows monotonically today.
  • audit_log is the only non-trivial case. Break-glass, GDPR-operation, and key-rotation entries are never deleted (CLAUDE.md), and organization_settings.audit_retention_months (000003, CHECK >= 72) is a per-org override that fights partition-level drop because one monthly partition holds many orgs. Archive-to-S3-then-drop is the only correct shape. s3.AuditArchiveKey() is the pre-positioned builder — currently referenced only by its own test.
  • usage_records at 24 mo assumes the durable fiscal record is the invoice, not the raw metering row. Revisit when F12 billing is built; if usage_records ends up the only billing artifact, the window must match Romanian fiscal retention instead.

This is the detail behind Phase 2's one-line "Retention automation" item.

2. Product fulfillment / e-commerce — out of scope, and there is no substrate

No products table exists in any migration. The shipped F14 chain (access_offerspatient_content_grants) grants content access, not physical goods. An earlier patient_product_orders design — order lifecycle, fulfillment queue, courier integration, RLS policies — was removed from the specs and never built.

The standing decision: this is a clinical platform, not an e-commerce one. If fulfillment is ever needed, the likely path is a minimal staff-marked "patient received the item" record, not a storefront. F2.3 Products stays deferred alongside F2.2.

3. Accessibility (WCAG) — no standard documented anywhere

grep -r WCAG apps/docs/ returned only the retired gap file. There is no accessibility requirement, no target conformance level, no testing procedure — for a patient-facing healthcare product in the EU. Open and unowned. Not scoped by this plan; flagged so it is not rediscovered as a surprise.

4. Security incident response beyond breach notification

gdpr-compliance.md and security/gdpr.md cover the GDPR 72-hour breach notification. Nothing covers severity classification, escalation paths, or the technical playbooks an on-call engineer would need — credential-compromise rotation, force-logout-all, audit-log forensics. Sits alongside Phase 2's on-call-rotation and status-page items.