Leo → RestartiX Platform: Consolidated Port Map (F1, F3, F4, F5, F6, F11)
Survey artefact, 2026-08-02. Produced by a 14-agent survey of the four live
restartix-leo-*/restartix-intakesrepos, mapped against the shipped platform substrate. Companion to platform-completion.md, which is the plan; this is the evidence behind it.Status: §8.1, §8.3 and §8.4 were ratified 2026-08-02 and are recorded as settled in platform-completion.md → Known Phase-0 reconciliations and CLAUDE.md → Settled 2026-08-02. In short: the F2.1
offeringsstand-in ships, namedofferingsfrom day one (catalog identity +offering_specialists+offering_forms; no pricing, no plans, no products, no purchase path);specialtiesare per-org (organization_id NOT NULL); CNP is opt-in per template, encrypted onpatient_profiles, never in the generic value store. The §8 entries below keep the original analysis and carry the outcome inline. Still open and not to be invented: §8.2 override scoping, §8.5–§8.13, and appointment-package tracking.Spot-verified against code before filing: the
apps/docs/features/scheduling/go/reference engine (6 files),adherence/cadence.go'sAppointmentCounterhook, the absence of anyforms.*/appointments.*/specialists.*permission seed,consents.source_form_id's reservation in000008, the seededmax_specialistslimit, migration000039being taken, andpii_contactnot being a valid classification class.
Verification basis: every claim below was checked against the live repos. Corrections to the input port maps are marked ⚠️. Source trees inspected: restartix-platform (target), restartix-leo-dashboard, restartix-leo-platform, restartix-leo-api, restartix-intakes.
0. Five verified corrections that change the plan before you read further
These invalidate specific instructions in the per-dimension port maps. Fix them first or the first migration fails.
0.1 ⚠️ The Intakes service source is available. Three port maps say it isn't.
/Users/ntropyspace/Work/restartix/restartix-intakes is a complete Next.js + Drizzle + Postgres + Redis service at commit 624a73e, with:
| Asset | Path | Size |
|---|---|---|
| Availability engine (TS original) | core/services/availability.ts | 976 lines |
| Full schema | core/db/drizzle/schema.ts | 313 lines |
| Double-booking exclusion constraint | core/db/drizzle/migrations/0000_intakes_overlap.sql | hand-written |
| Hold protocol | core/services/business/holds/holds.ts | — |
| Timeslot cache | core/services/timeslots/timeslots-cache.service.ts | 257 lines |
| Client cooldown | core/services/rate-limiting/client-rate-limiting.service.ts | 153 lines |
| Min-lead-time | core/services/lead-time/lead-time.service.ts | 42 lines |
| Assignment | core/services/business/opening-assignment.ts | 66 lines |
| Operational docs | docs/*.md | 23 files |
Consequences:
- The scheduling port map's "cooldown, min-lead-time and the overlap exclusion constraint are all re-implementation requirements" is wrong. They are ports with a reference implementation.
- The appointments port map's "the ~20k users have live bookings … inside an Intakes Postgres whose code we do not have" is wrong. The schema is
core/db/drizzle/schema.tsand the migration history is.drizzle/. The legacy extraction plan has a source of truth today. - The scheduling open question "does any real clinic use per-service overrides today?" is answered by the schema:
scheduleOpeningOverrides.scheduleIdisNOT NULLwithonDelete: cascade. Overrides are per-schedule in production, with live rows. See §8.2.
0.2 ⚠️ Migration 000039 is taken. Every port map allocates it.
services/api/migrations/core/000039_restore_safe_principal_is_human.{up,down}.sql exists. New migrations start at 000040.
0.3 ⚠️ apps/docs/architecture/appointments-substrate.md will not apply as written
Four defects, verified:
| Line | Doc says | Reality |
|---|---|---|
| 41, 181, 205, 208 | patient_persons(id), current_user_patient_person_ids() | patient_profiles / current_human_patient_profile_ids() (migration 000006) |
| 385 | class pii_contact | Not a class. internal/shared/classification/types.go:17-25 defines exactly nine: public, org_internal, pii_basic, pii_regulated, clinical, clinical_sensitive, auth_secret, audit_only, system_metadata. make check fails. |
| 43, 182, 221 | specialist_principal_id → principals(id) | Contradicts P9's specialists.human_id UNIQUE NULL — makes calendar-only specialists unbookable |
| 73 | patient_service_plan_id BIGINT | P26 is UUIDv7 |
0.4 ⚠️ The "copy-hours bulk-replace bug" does not exist
The specialists port map calls a per-day transactional replace "the concrete fix for the copy-hours bug the survey flagged." Verified at restartix-intakes/core/repositories/openings.repository.ts:200-234: bulkReplaceWeeklyHours already loops per dayOfWeek, deleting only that day's rows, inside db.transaction. The proposed fix is what leo already does. Port the semantics as-is; drop the phantom concern.
0.5 The reference Go engine is real but untested and uncompiled
apps/docs/features/scheduling/go/ is in no Go module (go.mod exists only at services/api, services/telemetry, services/media). I copied it to a scratch module and ran go vet:
availability.go+assignment.go+types.go→ vet clean, type-checks ✅holds.goneedsgithub.com/redis/go-redis/v9(already a platform dep) ✅types.gois not gofmt-clean ⚠️- Zero
_test.gofiles ⚠️
So it is a faithful, type-correct transcription that has never executed. Treat it as a high-quality draft requiring a test suite, not as shipped code. The TS original is now available to differential-test against.
1. Executive summary
What this port actually is
Three different things are being conflated under the word "port," and they have wildly different economics:
(a) Genuine code reuse — ~15% of the effort, concentrated in one place. Only one substantial artefact ports as code: the availability/assignment engine. apps/docs/features/scheduling/go/{availability,assignment,types}.go (~1,400 lines, vet-clean) is a faithful Go port of restartix-intakes/core/services/availability.ts (976 lines), and both now sit on disk for differential testing. This is the single largest de-risked item in the September scope, because slot math is where correctness bugs are subtle, expensive, and invisible until a patient shows up at the wrong hour. DST spring-forward gap probing, overnight-rule midnight splitting, and override replace-not-merge semantics are all encoded and derived from a system that has been running against real bookings.
Beyond that: the exclusion-constraint SQL ports verbatim, the hold key/TTL/heartbeat protocol ports structurally onto internal/core/locks, and roughly a dozen packages/ui-shaped React interaction surfaces port as UX transcription (structure and behaviour preserved, every line rewritten).
(b) Knowledge transfer — ~70% of the value, and the reason this exercise is worth doing. The clinical operating rules in §4 are the real payload. They are not in any spec, in leo or here. Things like: analysis forms deliberately do not pre-create value rows (FORM_TYPE_TO_SLOT.analysis.createValues: false); the exclusion constraint is partial on status='created' so cancelled bookings do not block a slot; report is offering-bound and auto-created while prescription is free-choice; a UTF-8 BOM on CSV export because Excel mangles Ștefan without it. Each is a small thing someone learned from a clinic complaining. Collectively they are years of operational learning, and they cost nothing to carry and a lot to rediscover.
(c) Anti-pattern inoculation — the negative space, and larger than it looks. The leo Strapi DB is genuinely bad in ways that are instructive: exactly one NOT NULL in 94 tables (and it is id), zero FK constraints, zero indexes in the dump, 52 of 94 tables are _links join tables, patient passwords stored in plaintext in a non-private column that reaches the browser and gets interpolated into rendered consent text. The platform's foundation already forecloses every one of these. The value here is knowing which leo behaviours are load-bearing product decisions versus scar tissue — and §7 draws that line.
Honest scope assessment
Porting does not meaningfully reduce the build. F1/F3/F4/F5/F6 remain a greenfield build against the platform's own authoritative specs (data-model.md Areas 2/4/5/7/11, appointments-substrate.md, patterns.md P9/P13/P14/P18/P19/P23/P24/P30/P33/P40/P47/P54). What porting changes:
- It de-risks the hardest algorithm (availability/slots/DST) with a running reference on both sides of a language boundary.
- It supplies a requirements oracle. When a spec is silent, leo's live behaviour answers — and now, with the Intakes source available, the answer is readable rather than inferred.
- It converts several "open questions" into observations. Override scoping, hold TTLs, cooldown keying, exclusion-constraint predicate, form-slot cardinality: all now empirically settled (§8).
- It surfaces four platform-internal doc bugs that would otherwise have been discovered by a failing migration (§0.3, §0.2).
Current state, verified: F1–F6 across apps/docs/implementation-plan/features.md shows 14 checked / 156 unchecked boxes. services/api/internal/core/domain/ has 43 domains, none of which are specialists, forms, appointments, scheduling, documents, or offerings. apps/clinic/app/(dashboard)/ has six routes — analytics, integrations, legal-documents, library, patient-tiers, patients — and no team, staff, settings, calendar, or appointments surface. grep for forms.*/documents.*/appointments.* permission seeds across all migrations returns zero rows.
This is a from-scratch build of the entire clinical-operations stack. The port makes it better-informed, not shorter.
What the substrate already gives you for free
This is genuinely substantial and the port maps get it right. Shipped and directly usable: RLS + current_app_has_permission + ConnFromContext + P47; audit_log (partitioned, append-only, cmd/check-audit-coverage-enforced); internal/integration/s3 with SurfaceSignatures/SurfaceDocuments/SurfaceFormsUpload/SurfaceAppointmentFiles already registered and unused — reserved for exactly these features; internal/core/locks (Redis, Lua-atomic, 120s/45s, P54); internal/core/sse (Redis pub/sub fan-out across ECS tasks); internal/core/ratelimit (Redis, IPKey/PrincipalKey); internal/core/events (registry + bus + scheduler); internal/core/notify (SES live); internal/core/impersonation; internal/shared/{apiquery,softdelete,classification}; immutable_unaccent + pg_trgm from 000001; consents ledger with source_form_id already reserved for F3; RequireCurrentConsents middleware already mounted; P18 versioning proven twice (patient_tier_versions, tier_versions); packages/ui with sortable-list, async-multi-select-filter, data-table, edit-lock-banner, calendar, require-permission.
2. Revised build order, and where F2 bites
2.1 The F2 constraint, stated precisely
Leo's appointment_template (restartix-leo-api/src/api/appointment-template/content-types/appointment-template/schema.json) is one entity doing three jobs:
- Catalog identity —
title,slug,description,cover,video,speciality,is_public,minicrm_title,attachments - Booking configuration —
intakes_schedule_id→ the Intakesschedulesrow carryingslotDurationMinutes,slotGapMinutes,slotsCooldownMinutes,minLeadTimeMinutes,slotsHorizonDays,slotsOpenAt/CloseAt - Form-slot binding + specialist roster —
disclaimers[],surveys[],parameters,report,advice,analysis,prescription,specialists[],use_even_distribution
The platform splits (2) into calendars (F4, in scope) and leaves (1) and (3) in offerings (F2, out of scope).
Six hard dependencies on the catalog entity, all inside in-scope features:
| # | Dependency | Feature | Severity |
|---|---|---|---|
| 1 | calendars.offering_id NOT NULL FK | F4.2 | Blocking — no FK target |
| 2 | appointments.offering_id NOT NULL FK | F5.1 | Blocking — no FK target |
| 3 | offering_forms junction — decides which forms auto-attach at appointment creation | F3.4 | Blocking — this is the form-generation mechanism |
| 4 | offering_specialists with priority — the roster the assignment engine walks | F4.3 | Blocking — assignment has nothing to iterate |
| 5 | Public booking browse (/francize/[slug] → bookable offerings) | F5.4 | Blocking — nothing to list |
| 6 | "Servicii efectuate" row on the generated report PDF | F6.2 | Cosmetic — degrades to blank |
Items 3 and 4 are the ones that matter most, and they 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.
2.2 Proposed minimum viable stand-in
Create offerings under the glossary-mandated name from day one, scoped to F2.1 only.
offerings
id, organization_id NOT NULL, title, slug, description,
specialty_id FK specialties(id) ON DELETE RESTRICT,
default_duration_minutes, cover_url, video_url,
minicrm_title, is_public, published, published_at,
deleted_at, created_at, updated_at
UNIQUE (slug, organization_id)
GIN (immutable_unaccent(title) gin_trgm_ops)
offering_specialists (offering_id, specialist_id, organization_id, priority INT NOT NULL DEFAULT 0)
PK (offering_id, specialist_id)
offering_forms (offering_id, form_template_id, organization_id, slot, sort_order)
slot ∈ disclaimer|survey|parameters|analysis|advice|report|medical_prescriptionWhy this is a stand-in and not F2 creeping back in. The deferred half of F2 is the commerce half — F2.2 Service Plans (packages, sessions-included, validity) and F2.3 Products. Those are the parts with pricing, purchase, and entitlement semantics, and they are genuinely independent. What is proposed here is the catalog identity only: roughly 15 columns and two junctions, no pricing, no plans, no products, no purchase path, no enrollment semantics. It is the FK target three in-scope features require, and nothing more.
Naming it offerings now is load-bearing. The glossary's deferral condition for services → offerings is "until that area is built." Building the stand-in meets it. Naming the stand-in services would mean a rename of five tables and every FK later, on a table that by then holds production rows under the forward-only freeze.
⚠️ New finding — the glossary's other rename target is already occupied. apps/docs/architecture/glossary.md:372 mandates service_plans → enrollments. But protocols.kind IN ('prescription','enrollment') is shipped and live in production (000023_sessions.up.sql:894), where enrollment means "patient self-enrolled in a guided program." When F2.2 ships it cannot be called enrollments. The glossary row needs a new target (offering_packages? service_packages?) — decided now, in the docs PR, while it costs nothing.
Explicit boundary for the build: F2's deferred surface stays deferred. No pricing column, no offering_plans, no purchase flow, no entitlement binding on offerings. If a task starts reaching for those, it has left scope.
2.3 Build order
STEP 0 Docs reconciliation PR (no schema) — BLOCKING
STEP 1 000040 specialties + specialists + specialist_specialties (F1) ✅ SHIPPED
STEP 2 000041 offerings + offering_specialists (F2 stand-in) ✅ SHIPPED
STEP 3 000042 custom_fields + custom_field_versions + custom_field_values (F3.1)
STEP 4 000043 form_templates + form_template_versions + forms
+ offering_forms (F3.2/3.3/3.4)
STEP 5 000044 btree_gist + specialist_weekly_hours
+ specialist_schedule_overrides (F4.1)
STEP 6 000045 calendars + calendar_specialists + calendar_forms
+ specialist_assignment_tracking (F4.2/4.3)
STEP 7 000046 appointments + appointment_files + appointment_reviews (F5.1/5.2/5.3)
STEP 8 000047 pdf_templates + versions + components (F6.1)
STEP 9 000048 appointment_documents + appointment_document_files (F6.3)
STEP 10 F11 compliance hardening + legacy migration rehearsalEach migration seeds its own permission rows, role-template grants, RLS policies, and data-classification.md entries in the same PR (make check enforces the last one).
Layer-discipline gate. F1–F6 sit above 1D admin surfaces, which CLAUDE.md records as "partially shipped; remainder in flight." Before Step 1, read the live checkboxes in apps/docs/implementation-plan/foundation.md. Notably, apps/clinic has no settings/team surface at all today — F1's roster UI is the first one. If a required 1D surface is open, surface it rather than proceeding.
3. Per-feature port plan
F1 — Specialists & Specialties
Schema (000040)
specialties — id, organization_id NOT NULL, title, slug, created_at, updated_at; UNIQUE (slug, organization_id); GIN (immutable_unaccent(title) gin_trgm_ops).
specialists — per data-model.md Area 2: id, organization_id NOT NULL, human_id UUID UNIQUE NULL REFERENCES humans(principal_id), name, title, description, slug, minicrm_name, signature_url, avatar_url, scheduling_timezone VARCHAR(64), scheduling_active BOOLEAN, deleted_at. 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.
RLS: staff SELECT via organization_memberships; patient SELECT via current_human_patient_profile_ids() joined to patients.organization_id (required for the portal booking picker — without it F4/F5 booking returns zero rows). Permissions: specialties.manage, specialists.view_org, specialists.manage.
⚠️ humans has no name column. Verified at 000002_tenancy_rbac.up.sql:292-322 — provider_subject_id, provider_org_id, email, confirmed, blocked, portal_credential_generation, last_activity, preferred_language, timezone. specialists.name covers specialists; admin/customer_support staff have nowhere to store a display name. See §8.5.
API
internal/core/domain/specialties/andinternal/core/domain/specialists/via/new-domain- Create wrapped in
middleware.EnforceLimit("max_specialists", 1)— the limit is already seeded (000004_tiers_subscriptions.up.sql:568,603-613: free 2 / pro 20 / dedicated unlimited) - Delete =
softdelete.SoftDelete; specialty delete = hard, but pre-checked → 409 with in-use counts ?q=typeahead +?ids=resolve from the first commit- Register
SurfaceAvatarsininternal/integration/s3/surfaces.go— verified absent (registry has Signatures, Documents, FormsUpload, Logos, AppointmentFiles, ExerciseAssets). Do not overloadSurfaceLogos(org branding) - Signature/avatar upload copies
organization.UploadBrandingAsset(branding.go:131-201) verbatim: sniff → content-addressed key → upload → DB write → rollback-delete new on DB failure → orphan-delete previous only on success internal/core/scheduling.ResolveSchedulingTimezone(ctx, locationID, specialistID, orgID)— P23's single chain; verified absent todayGET /v1/reference/timezones+ IANA validation on write
UI to port
| Source | Target |
|---|---|
restartix-leo-dashboard/app/(dashboard)/echipa/listing.tsx | Clinic /team roster |
.../echipa/[id]/{layout,sidebar,summary,summary-specialist}.tsx | /team/{id} detail shell |
.../echipa/[id]/specialitati/content.tsx | Specialties tab (leo's is a stub — build it) |
.../francize/[id]/specialitati/edit.tsx | Specialties admin |
.../echipa/[id]/disponibilitate/content.tsx | Availability page |
app/_shared/specialist-availability/* (7 files: form-weekly, weekly-day-item, availability-calendar, override-dialog, copy-hours-dialog, timezone-selector, form-availability) | The whole availability editor |
app/_shared/specialist-availability-{dialog,tabs}.tsx | Three-instantiation pattern (page / dialog+calendar / dialog−weekly) |
Do not port leo's echipa/add/form-user.tsx — it sets the new member's initial password directly (confirmed: true, no invite loop). The platform's invites domain + POST /v1/organizations/{id}/staff-invitations (Clerk magic-link) is shipped and strictly better.
New packages/ui primitives: TimeSelect (15-min increments, searchable), TimezoneSelect (IANA, sorted by live UTC offset). Both verified absent.
F3 — Forms, Custom Fields & Signatures
Schema (000042 + 000043)
custom_fields / custom_field_versions / custom_field_values — P19 + P24. UNIQUE (organization_id, entity_type, key) and UNIQUE (organization_id, system_key) — never global. (leo's meta_field.key is globally unique, which is why its cross-franchise template copy leaves templates pointing at another tenant's field definitions.)
form_templates / form_template_versions / forms. forms carries fields JSONB snapshotted at instance creation + template_version INT, values JSONB (GIN), files JSONB, status enum pending|in_progress|completed|signed, completed_at, signed_at, deleted_at, patient_profile_id, organization_id, appointment_id NULL, created_by_principal_id, signed_by_principal_id.
The snapshot is the single most important structural change. leo has none — editing a template retroactively rewrites how every historical form renders, which its own migration docs call the #1 reason to redesign.
offering_forms junction (see §2.2).
ALTER consents to light up source_form_id → forms(id) — already reserved in 000008 with the comment "FK lights up when F3 ships the forms table," plus CHECK ((source='form') = (source_form_id IS NOT NULL)).
API
- Template CRUD + publish/version/rollback modelled on
patienttiers.Repository.PublishVersion(repository.go:287,378) - Instance create-from-template: one INSERT, one transaction. leo does N+1 POSTs in a client-side
for awaitloop with a standing in-code TODO that navigating away mid-loop corrupts the form - Batched
PATCH /forms/{id}/values— one txn, one coalesced audit row with the changed-key diff - P14b: 409 Conflict on any mutation once
status='signed', at handler and service layers - Auto-fill resolver: at creation, copy from
patient_profiles(viaprofile_field_key) andcustom_field_values(viacustom_field_id) into the snapshot; on save, explicit audited write-back. This replaces leo's invisible meta-value redirect middleware - Server-side audience projection (
patient|staff|admin) driving what is sent, what is validated, and what renders into the patient PDF - File answers on
s3.SurfaceFormsUpload(already registered: pdf/png/jpeg/webp, 10 MB) - Cat E events:
form.created,form.completed,form.signed,form_template.published
UI to port
| Source | Target |
|---|---|
.../sabloane/[id]/campuri/{content,field,dialog-add}.tsx | Form builder |
.../francize/[id]/{dialog-meta,content-patients}.tsx | Field-library admin |
.../consultatii/[id]/{chestionare,acorduri,recomandari,evaluare-mobilitate} | Fill surfaces |
restartix-leo-platform/app/consultatii/[uid]/layout.tsx | Portal sequential form wall |
.../consultatii/[uid]/chestionare (SurveyFields) | Portal renderer |
Drop the five patient-identity meta bindings (patient_meta_birthdate/_residence/_occupation/_sex) — patient_profiles has date_of_birth, sex, occupation, residence, phone as native columns (verified 000006:34-62). Only CNP has no home (§8.3).
F4 — Scheduling, Availability & Calendars
Schema (000044 + 000045)
CREATE EXTENSION btree_gist — verified absent (000001 enables uuid-ossp, pgcrypto, unaccent, pg_trgm, vector, pg_stat_statements only). Must run on DATABASE_DIRECT_URL.
specialist_weekly_hours — organization_id NOT NULL (⚠️ data-model.md Area 4 omits it, violating a CLAUDE.md hard rule — fix the doc), specialist_id, day_of_week enum, start_time/end_time TIME (local wall-clock), location_id NULL (P40). UNIQUE (specialist_id, day_of_week, start_time, end_time) — direct port of leo's uq_opening_dow_start_end.
specialist_schedule_overrides — organization_id NOT NULL, start_date/end_date TIMESTAMPTZ (absolute UTC), availability BOOLEAN, location_id NULL, calendar_id UUID NULL (NULL = all calendars). Scope decision in §8.2.
calendars + calendar_specialists (priority INT) + calendar_forms + specialist_assignment_tracking. calendars carries offering_id NOT NULL, slot_duration_minutes, slot_gap_minutes, cooldown_minutes, min_lead_time_minutes, horizon_days, slots_open_at/close_at, assignment_strategy, is_public, published, slug, deleted_at — all ported from restartix-intakes/core/db/drizzle/schema.ts:116-147, plus a CHECK enforcing window-XOR-horizon (leo enforces it only in a client-side save handler).
Both availability tables are STATE — flat, never partitioned.
API
- Move
apps/docs/features/scheduling/go/{availability,assignment,types}.gointointernal/core/domain/scheduling/, apply theappointment_type → calendarrename, addResolveSchedulingTimezone - Write the test suite that does not exist. Differential-test against
restartix-intakes/core/services/availability.ts. Minimum cases: spring-forward gap (safeLocalToUTCprobing), fall-back ambiguity, overnight rule split at local midnight (BuildWeeklyWindowsUTC), override replaces-not-merges for a whole local day, 90-day window cap gofmttypes.gobefore it lands- Hold store: port
go/holds.goonto platform Redis withcache.OrgResource(orgID, ...)namespacing (leo's keyshold:{scheduleId}:{slot}:{openingId}carry no org dimension), Lua-atomic heartbeat frominternal/core/locks/store.go, publication ontointernal/core/sse - Server-derived booking client identity — signed HttpOnly cookie. leo's
clientIdis caller-supplied (body > query > cookie > generated), never validated, never persisted, and the dashboard regenerates it after every successful booking, defeating the 24h cooldown - Weekly-hours
PUTas per-day transactional replace (portopenings.repository.ts:200-234semantics as-is) - Availability read capped at 90 days, returning leo's
AvailabilityDayenvelope +dateRange {requestedStart, requestedEnd, effectiveStart, effectiveEnd}for out-of-window greying
UI to port
| Source | Target |
|---|---|
.../servicii/[id]/calendar/ (info / specialisti / tab-settings) | Calendar configuration |
.../evenimente/ + hooks/use-availability.ts | Scheduling cockpit |
restartix-leo-platform/app/programare/[slug]/components/* (25 UI-state components) | Portal booking page |
restartix-intakes hold-stream consumer | useHoldsStream + useHolds in packages/ui — build once; leo duplicates the reducer three times |
F5 — Appointments
Schema (000046) — per the corrected appointments-substrate.md (§0.3). 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. Two-phase identity: patient_profile_id set at booked with patient_id NULL; patient_id linked at onboarding. Both pair CHECKs, all five indexes (two partial), all three RLS policies. No deleted_at — the status enum covers every did-not-happen case.
Plus appointment_files and appointment_reviews.
API
- 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
donecan be dragged back toupcomingfrom a freeSelect - Cancel endpoint with attribution +
cancelled_latederived from an org threshold; reason ≤ 500 chars - Wire
AppointmentCounterinto adherence.internal/core/domain/adherence/cadence.goalready defines the interface and returnsErrSupervisedNotImplemented. Supervised protocols are shipped and waiting on exactly this - Bounded calendar-view endpoint bucketed by scheduling timezone, not
toISOString()(leo's near-midnight wrong-day bug), with month heat-map from SQLCOUNT GROUP BY - Public booking under
/v1/public/on AdminPool per P5, projection throughclassification.Filter - Auto-noshow sweep cron on
internal/core/events/scheduler.go— leo has none; nothing ever writesnoshow. Apply the silence-timeout lesson: only flip genuinely-unstarted appointments - Daily.co
video.Providercapability; extendplatform_service_providers'chk_psp_capability_providerwhitelist with('video','daily')
UI to port
| Source | Target |
|---|---|
.../consultatii/listare/{listing,listing.context}.tsx + components/{dialog-content-filters,filter-popover}.tsx | /appointments list |
.../consultatii/[id]/{layout,tabs,sidebar}.tsx | Detail shell (clone apps/clinic/app/(dashboard)/patients/[id]/layout.tsx) |
.../consultatii/creare/components/* | Booking wizard |
.../consultatii/_cards/* | KPI tiles → new tab on existing /analytics |
restartix-leo-platform/app/consultatii/* | Portal appointment list + detail |
F6 — Documents, Reports & Prescriptions
Schema (000047 + 000048) — pdf_templates + pdf_template_versions + pdf_template_components (block-based). appointment_documents with UNIQUE(appointment_id, type), type ∈ report|medical_prescription (never bare prescription — settled in glossary.md), pdf_template_version frozen at generation, generated_by_principal_id, published, document_url storing the S3 key, not a URL. Plus appointment_document_files.
⚠️ Reject leo's document_templates design. restartix-leo-api/docs/go-migration/08-document-generation.md specifies per-org HTML/CSS templates with margins, and the go-migration survey calls it "best starting point for the new F6 Documents spec." apps/docs/architecture/data-model.md:1402 records that both designs were evaluated, the block-based design won, and document_templates was deleted. features.md F6 says "Use pdf_templates (block-based) — drop the legacy document_templates design." Architecture docs beat foreign migration docs. Mine leo's doc for the funcmap, DocumentData struct, and error→HTTP mapping only.
API
pdf.Renderercapability ininternal/core/pdf/, registered viacapabilities.WrapInternal(the glossary's own worked example of a capability)- Generation: signed form + frozen template version →
DocumentData→ HTML → PDF →s3.SurfaceDocuments - Port
prepareReportPDFDataas ONE type-parameterised builder (restartix-leo-dashboard/core/domain/report/report.service.ts) — leo duplicates it across report and prescription. Algorithm: group flattening →is_privatepruning (a private group hides all children) → empty-value pruning → empty-group elision → age-at-appointment viadifferenceInYears(started_at, date_of_birth) - Signature embedded base64 (compliance rule: PDFs self-contained, no external URLs). Prescription generation refuses without a signature → typed 422 with a test
- Presigned reads (15 min) +
document.pdf_accessedaudit row
Non-code assets to extract now (independent of build order — these are the irreplaceable part):
restartix-leo-dashboard/core/utils/report-templates/default.tsx(361 lines): 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 (paddingTop 140/paddingBottom 65to clear the fixed header/footer)nutritional.tsx(550 lines) +nutritia-durerii.tsx(633 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-tierpdf_templates, with strings through next-intl
4. Preserved-knowledge appendix — business rules learned from running a clinic
This is the highest-value section. Every rule below is extracted from live code or schema and is absent from any spec on either side.
4.1 Scheduling & availability
| # | Rule | Source |
|---|---|---|
| S1 | Overrides fully REPLACE a day's weekly hours — they never merge. If any override exists for a local date, that date's weekly rules are skipped entirely, then the override's availability=true intervals are added. | availability.ts:268 applyOverridesAndIntakes |
| S2 | An override with zero intervals blocks the whole day. Encoded server-side as a single 00:00–23:59 availability=false row. This is how "block Tuesday" is expressed. | override dialog + availability.ts |
| S3 | Overnight weekly rules split at local midnight. Fri 20:00–Sat 02:00 becomes two windows. Without this, DST and day-grouping both break. | buildWeeklyWindowsUTC |
| S4 | Spring-forward gaps are handled by probing forward, not by erroring. A 02:30 slot on a day where 02:00–03:00 does not exist resolves to the next real instant. | safeLocalToUtc:178 |
| S5 | Weekly hours are per-specialist GLOBAL; overrides are per-schedule SCOPED. Deliberate: a specialist works 9–5 as a person, but blocks Tuesday afternoons for one offering only. The UI legend says "Afișare ore săptămânale globale". | schema.ts:91 vs :171 |
| S6 | The double-booking constraint is on the SPECIALIST, not the calendar, and works across calendars. Partial: WHERE (status='created' AND start_date IS NOT NULL AND end_date IS NOT NULL) — cancelled bookings do not block, and no-slot intakes are exempt. | 0000_intakes_overlap.sql:28-31 |
| S7 | chk_intakes_time_order: both dates NULL (no-slot booking) OR both set with end > start. Non-timeslot bookings are a first-class state. | same file |
| S8 | Slot lattice step = duration + gap, generated on a local-midnight grid — not a UTC grid. Off-grid slot starts are rejected by isAlignedToGrid. | availability.ts:503 |
| S9 | Booking window is horizon XOR explicit range. useHorizon=true nulls slotsOpenAt/CloseAt; useHorizon=false sets horizon to 0. Enforced only client-side in leo — needs a DB CHECK. | schedules + save handler |
| S10 | Defaults that encode real clinic behaviour: slotsCooldownMinutes = 1440 (24h anti-spam), minLeadTimeMinutes = 1440 (24h notice), slotsHorizonDays = 0. | schema.ts:130-140 |
| S11 | Min-lead-time returns a structured error, not a boolean: {message, minLeadTimeMinutes, slotStart, earliestBookableAt}, with human formatting ("2h 30m" / "3 hours"). The UI needs earliestBookableAt to say when booking opens. | lead-time.service.ts:27-42 |
| S12 | Cooldown is keyed client_limit:{clientId}:{scheduleId} — per-schedule, not global. A patient blocked from rebooking Physio can still book Nutrition. | client-rate-limiting.service.ts:8-12 |
| S13 | Rate limiting fails OPEN on Redis error — logged, then booking proceeds. Deliberate: a Redis outage must not stop a clinic taking bookings. | same, :51-56 |
| S14 | Hold client index shares the hold's TTL exactly, "to avoid blocking after expiry" — a stale index would lock a client out of rebooking. | holds.ts:90-92 |
| S15 | Staff and patient heartbeat budgets differ deliberately — staff ~16 min (they need time to find or create a patient mid-booking), patients ~100s. | dashboard vs platform hold hooks |
| S16 | Assignment tiebreaker is FNV-1a(calendarId:slotStart) — deterministic, so two concurrent callers computing candidates independently agree without shared state. | availability.ts:809 / assignment.go |
| S17 | evenDistribution=true is expressed as all priorities set to 0, not a separate mode. | orderCandidatesByPriorityThenDeterministic |
| S18 | Public projection attaches specialist identity and timezone only when the schedule has exactly one opening — pooled services do not leak which provider you will get. | schedules/[id]/details/route.ts |
| S19 | Timeslot responses carry a generated-at freshness stamp so a stale cached grid is detectable client-side. | timeslots-cache.service.ts |
4.2 Appointments
| # | Rule | Source |
|---|---|---|
| A1 | Reschedule preserves duration exactly — it is not re-derived from the offering, which may have changed since booking. | 11-appointment-lifecycle.md |
| A2 | Cannot reschedule into the past. | same |
| A3 | Auto-noshow grace is 30 min after scheduled start; the sweep job runs every 15 min. | same |
| A4 | Late arrival after auto-noshow must be recoverable. leo permits noshow → inprogress. Under a strict terminal-status rule a new row orphans the generated forms, the video room, and the protocol_id/session_id pair the adherence engine reads — and leaves the auto-noshow permanently in the adherence denominator, penalising a patient who actually attended. See §8.7. | same |
| A5 | Cancellation captures a free-text reason, capped at 500 chars, behind a promise-based confirm dialog. | appointment-management.js |
| A6 | Late cancellation is flagged at a 24h threshold. | 11-appointment-lifecycle.md |
| A7 | Unscheduled appointments are a first-class bucket — started_at IS NULL renders as "Consultație fără programare" with its own tab and summary card, and "Programare inițială" on the portal. Real rows exist. | consultatii/listare |
| A8 | Specialist overlap on the staff-created path is a WARNING, not a block — deliberately, because appointments can be created manually and because different calendars may share a specialist. | 11-appointment-lifecycle.md |
| A9 | Converted bookings are de-duplicated on the staff calendar so one booking does not render twice. | calendar endpoint |
| A10 | The actions dropdown shows the exact WhatsApp number and email in the confirmation modal before dispatch — staff verify the destination. | consultatii/[id] actions |
4.3 Forms & custom fields
| # | Rule | Source |
|---|---|---|
| F1 | Form-slot cardinality: disclaimer multiple, survey multiple, parameters single, analysis single, advice single. report and prescription are NOT in the table — report is generated via a separate skipReport path, prescription has no server-side attach route at all. | appointment-forms.js:18-24 |
| F2 | analysis sets createValues: false — analysis forms deliberately do not pre-create value rows. The mobility-evaluation flow writes them from measurements instead. | same, :22 |
| F3 | Detaching a form must never delete shared profile-level values. leo gets this right; it is easy to get wrong on the platform where auto-fill copies values in. | deleteAppointmentForm |
| F4 | Required + private is a dead zone. Private fields are omitted from the patient DOM, from FormData, and from the zod required-check — so a required private field can never block a patient submit. Catch it at publish time as a template-authoring error. | patient renderer |
| F5 | Autosave contract: 1s debounce + flush on blur + dirty ref (blur with no change is a no-op) + per-field spinner + per-field error + "+ add missing entry" affordance. | edit-in-place.tsx |
| F6 | Field keys are generated {type}_{4 alnum} and are immutable once assigned — PDFs and exports reference them. | generateFieldKey |
| F7 | Patient date input is three selects, not a date picker — deliberate, and correct for older patients on mobile. But the year range is hardcoded currentYear-100 … currentYear-10, making under-10s unrepresentable. Keep the control, drive the range from config. | patient renderer |
| F8 | Consent bodies interpolate {{patient.name}} / {{fields.<key>}}; unresolved paths render a neutral placeholder rather than erroring. | disclaimer.tsx |
| F9 | Global (appointment-null) forms gate every appointment: $or: [appointment.id = X, appointment.id IS NULL]. This is how clinic-wide disclaimers work. | getRequiredDisclaimers |
| F10 | The blocking gate renders one form at a time, disclaimers before surveys, and the subtree never renders until the queue empties. | consultatii/[uid]/layout.tsx |
4.4 Documents
| # | Rule | Source |
|---|---|---|
| D1 | Projection algorithm, in order: flatten groups → prune is_private (a private group hides all children) → prune empty values → elide now-empty groups. | report.service.ts |
| D2 | Age is computed at the appointment date, not today — differenceInYears(started_at, date_of_birth). | same |
is_private exists so a clinician has somewhere to note what the patient is not meant to read, and a promise one document type breaks is no promise. | 08-document-generation.md | |
| D4 | A4 geometry: paddingTop 140 / paddingBottom 65 to clear the fixed header and footer. Footer reads "Pagina n / total". | default.tsx |
| D5 | The prescription layout is the report layout minus "Servicii efectuate" and minus the support box. | template diff |
| D6 | Postural-analysis images live on the report entity, which forces a hard block: "Este necesar să existe întâi un raport de evaluare." Attach to the appointment instead and remove the dead-end. | analiza-posturala |
| D7 | Goniometer measurements are written as form values keyed <key>_left / <key>_right / <key>_file. | analysis flow |
| D8 | Generate/Regenerate wording differs, and the produced artifact downloads immediately. | report-actions.tsx |
| D9 | "Rapoarte în așteptare" = appointments where ended_at < now() AND no published report, self-scoped when the caller is a specialist. This is what actually drives report completion. | ReportsCard |
4.5 Team, exports, ops
| # | Rule | Source |
|---|---|---|
| T1 | minicrm_name overrides the display name in outbound CRM payloads: `specialist_name = minicrm_name | |
| T2 | Presence is throttled to 1 write / 5 min; it is a presence signal, not an access log. | user-last-activity.js |
| T3 | CSV export prepends a UTF-8 BOM so Excel renders Romanian diacritics — without it Ștefan shows as Å¢tefan. | 03-api-contracts.md |
| T4 | Export columns are caller-supplied with caller-supplied Romanian labels and a per-column type (date/status/text) so enums and timestamps render human-readably. Nested paths (specialist.name, user.patient.name) are inherent — exports are join-shaped. | same |
| T5 | Export caps: 10,000 rows, 1,000-row batches, 5 requests / 5 min. | export.js |
| T6 | Upload UX: full-surface click target + "N / M documente încărcate" progress + per-file failure surfacing. leo's completes the bar silently for failed files — fix that. | upload-files.tsx |
| T7 | Patient document policy copy: upload 12h before the appointment, "nu garantează analiza acestora". | portal documents |
| T8 | Cross-system propagation runs in setImmediate with per-operation try/catch that only logs. Strapi and the scheduler diverge silently; the repair is "save it again." This whole class disappears on a single-database platform. | Strapi lifecycles |
5. Foundation conflicts — consolidated
Deduplicated across all five dimensions. Sixteen distinct classes.
| # | leo assumption | Platform rule | Resolution |
|---|---|---|---|
| C1 | Everything hangs off a users row | No users table; users/user_id forbidden (glossary:369-370). principals + humans(principal_id) | Subject → patient_profile_id; actor → created_by_principal_id/signed_by_principal_id/generated_by_principal_id; signer → specialists.human_id. Three columns, not one |
| C2 | franchise is the tenant; scoping is app-layer middleware; 5 entities exempt; list-GETs only | organization_id NOT NULL + RLS on every tenant table + ConnFromContext + P47 | Rename; RLS on all new tables including junctions and both availability tables. ⚠️ data-model.md Area 4 omits organization_id on specialist_weekly_hours/specialist_schedule_overrides — a doc bug against a hard rule; fix the doc |
| C3 | Authorization compares role strings (role.type === 'admin'), role IDs from env vars, nav-only visibility | Per-org permission codes; RLS calls current_app_has_permission | Seed permissions per migration. ⚠️ Verified: zero forms.*/documents.*/appointments.*/specialists.* rows exist in any migration today — they live only in rbac-permissions.md |
| C4 | publishedAt is the lifecycle for templates, forms, documents, and consent signatures simultaneously | P33 explicit machines; P14b immutability; consent needs an auditable signature event | Four mechanisms: form_templates.published+version; forms.status enum; appointment_documents.published; consents ledger row. Default pending — fail-closed |
| C5 | Form instances carry no snapshot; template edits rewrite history | P18 + P14b | forms.fields JSONB + template_version at creation; appointment_documents.pdf_template_version at generation. The single most important structural fix in the port |
| C6 | A form-value middleware silently redirects answers into a shared user-scoped meta_value; answering in one form rewrites every other | Audit with field-level changes; P18 no retroactive mutation | Explicit two-step: auto-fill COPIES at creation; save performs a separately audited write-back. Historical instances never mutate |
| C7 | meta_field.key globally UNIQUE; template copy clones by id across tenants | RLS; clinical records never cross clinic boundaries | UNIQUE (organization_id, entity_type, key). Copy remaps by system_key → key within the target org; dangling cross-org reference fails the copy |
| C8 | Hard DELETE of forms, values, reports, patient uploads; no tombstone | Soft delete only; erasure = anonymisation; cmd/check-softdelete | deleted_at + no DELETE RLS policy on clinical tables. Config tables (specialties, calendars, overrides) may hard-delete but must emit audit |
| C9 | Nothing immutable — signed consents still PUT-able; sent reports regenerable in place | P14a/P14b; signed forms reject edits with 409 | 409 guard at handler and service; _versions tables get no UPDATE/DELETE policies; regeneration produces a new row, never an in-place file swap |
| C10 | Availability lives in a second system with fire-and-forget one-way sync | Single database; no silent-failure paths | Delete the sync concern entirely. specialist_weekly_hours + specialist_schedule_overrides are the source of truth. External calendar sync, if wanted later, is Cat B via organization_integrations |
| C11 | Bookability gated on a lazily-provisioned intakes_opening_id; specialists without one are silently undroppable from every roster | No silent-failure states; P23 scheduling_timezone NULL = unbookable | Derived + explicit: 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 a non-atomic two-write toggle (user.blocked + publishedAt=null) leaving the scheduler live | One column, one transaction | scheduling_active = false removes the specialist from availability computation by construction. Keep strictly separate from humans.blocked — revoking at Clinic A must not lock them out of Clinic B |
| C13 | Hold/cooldown/SSE keys carry no org dimension; clientId caller-supplied and regenerated after each booking | Cache scope must match data visibility (P42/P45); rate limits key off IPKey/PrincipalKey | cache.OrgResource(orgID, ...) on every hold/stream/cooldown key. Booking client id = server-signed HttpOnly cookie, persisted to appointments.booking_client_id. Cooldown keys on (org, calendar, patient_profile_id) or (org, calendar, hashed IP+email) |
| C14 | PDFs rendered client-side by @react-pdf/renderer in the staff browser, images by remote URL | pdf.Renderer capability; PDFs self-contained with base64-embedded signatures; P39 egress | Server-side capability registered via capabilities.WrapInternal; images fetched server-side and base64-inlined; SOUP row in the same PR |
| C15 | Lists cap at 50 with no pagination control; pickers load everything and filter client-side; pageSize:-1 on the calendar endpoint; sort on unindexed createdAt | Production Scale: server-side pagination (default ≤50, cap 500), COUNT(*)-derived counts, async typeahead, indexed filter columns | apiquery from the first commit; AsyncMultiSelectFilter; GIN trigram + immutable_unaccent on every searchable column. Non-negotiable at 20k users |
| C16 | Vocabulary: franchise, Opening, Schedule, Intake, appointment_template, speciality, meta_field, prescription | Glossary wins every naming dispute; PRs without glossary entries are incomplete | Full mapping in the docs PR (§2.3 Step 0). ⚠️ prescription genuinely collides — protocols.kind='prescription' is live in production meaning a specialist-assigned exercise program; leo's is a rețetă-medicală PDF. ⚠️ service_plans → enrollments is also blocked — enrollment is taken by protocols.kind='enrollment' |
6. New dependencies requiring SOUP rows
apps/docs/reference/soup.md currently has 97 rows; cmd/check-soup fails the build on any manifest dep without one.
Required
| Dep | Why | Risk tier | Notes |
|---|---|---|---|
| PDF engine — chromedp/headless-shell, Gotenberg, or Go-native | F6.2 pdf.Renderer | High | Drives ECS task shape, image size, memory profile, MDR posture. See §8.8 |
@dnd-kit/modifiers | Only if cross-container drag is needed (grouped form-builder fields) | Medium | Would be a second dnd-kit import site; packages/ui/src/components/sortable-list.tsx documents a one-import-site rule keeping the SOUP surface at 3 rows. Port into packages/ui to preserve it |
Conditional
| Dep | Condition | Notes |
|---|---|---|
libphonenumber-js | If E.164 validation is needed for the 20k migration | Real need, not convenience. restartix-leo-dashboard/core/utils/phone.ts |
| Calendar grid library (FullCalendar or similar) | If the cockpit is not hand-rolled | 5 packages in leo + a licensing question on some views. Alternative: react-day-picker (already SOUP'd) + CSS grid. Decides how much cockpit code is reusable at all — see §8.9 |
cmdk | Only if leo's combobox.tsx is ported rather than extending AsyncMultiSelectFilter | Prefer extending the existing component |
Already inventoried — no new rows: swr, @dnd-kit/{core,sortable,utilities}, react-day-picker, date-fns, recharts, zod, radix-ui, go-redis/v9.
Explicitly NOT to add (each would be a regression): axios (bypasses the P43 undici Agent), ioredis (Go API owns Redis per P45), qs (Strapi filter syntax only), jotai, jose (Clerk owns JWT), slate ×4 + is-hotkey, @react-pdf/renderer, @strapi/blocks-react-renderer, lodash.merge/lodash.debounce, date-fns-tz, nanoid (use crypto.randomUUID()), react-confirm, vaul, filesize, short-unique-id, @sindresorhus/slugify.
Latent hazard: leo's package.json is not a reliable statement of its dependency surface. nanoid and is-hotkey are imported but undeclared (yarn-hoisted phantoms); drizzle-orm and @tanstack/react-table are declared but never imported. Verify every import before assuming a dep exists.
7. Anti-pattern guardrails — what the port must not carry
Grouped by severity. The DB catalogue is drawn from strapi_db.sql (94 tables, pre-data section) and verified by direct grep.
Class 1 — Structural defects the platform already forecloses
| # | leo | Guardrail |
|---|---|---|
| G1 | Exactly one NOT NULL in 94 tables, and it is id. required: true in schema.json is app-layer only, bypassed by strapi.db.query, admin import, and direct DB access | Every semantically-required column is NOT NULL in DDL; enums get CHECK or a Postgres enum |
| G2 | Zero FK constraints. Zero indexes. Zero uniques. Verified: grep -c "REFERENCES|CONSTRAINT|CREATE INDEX|pkey" = 0 | FKs with explicit ON DELETE; indexes on every filtered/sorted column |
| G3 | 52 of 94 tables are _links join tables. appointments alone has 11 satellites. Every one is a 1:1 or N:1 that should be an FK column | FK column for 1:1/N:1; join table only for true M:N, with composite PK |
| G4 | files_related_morphs (related_id, related_type) — untyped polymorphic attachment carrying patient medical documents. Orphans structurally undetectable; ordering is double precision | Typed FK + organization_id on a real table |
| G5 | deleteAppointmentForm deletes N value rows in a for loop, then the report, then the form — no transaction | One transaction per multi-row mutation |
| G6 | Zero deleted_at anywhere; hard-deleted clinical records and patient uploads | deleted_at + no DELETE RLS policy |
| G7 | timestamp without time zone everywhere except two columns; timezone carried as free-text varchar | TIMESTAMPTZ + IANA validated against tzdata |
| G8 | EAV clinical data across three competing field stores (form_values, meta_values, components_form_fields); reading one questionnaire is a 10-table join; multi-select packed as ` | a |
| G9 | franchises.patient_meta_* varchar columns whose values are string keys into the EAV store — unvalidated per-tenant pointers | Native patient_profiles columns + the platform custom-fields design |
Class 2 — Security defects
| # | leo | Guardrail |
|---|---|---|
| G10 | patients.password stores the plaintext generated password, has no private flag, serialises in /api/users/me?populate=*, reaches the browser, and is interpolated into rendered consent text as {{patient.password}} | No recoverable credential column ever. The importer discards it — not into a column, a log, or an export. The variable namespace becomes a server-side allow-list from the classification registry |
| G11 | Custom routes ship policies: [] — cancel, reschedule, attach-forms, from-template, and POST /patients/:id/impersonate are all unguarded. Exactly one route in the API has a policy | RequirePermission + RLS + P47 on every route. There is no exempt list |
| G12 | Tenant filter applies to list GETs only; findOne and all writes are unscoped. Five entities — meta_value, form_value, patient, report, specialist — explicitly exempted "filtered by other means." With sequential integer PKs, any authenticated patient can walk /api/forms/1..N across every clinic | RLS at the database layer. Exemption is not expressible |
| G13 | /api/video-proxy?url=<anything> — open SSRF proxy with Access-Control-Allow-Origin: *, buffering whole videos via arrayBuffer() | Never. Signed, scoped, expiring media access |
| G14 | /videocall/[uid] is unauthenticated; createToken(roomName, "0", "Guest") mints a Daily token for whatever room name is in the URL. The appointment uid is a non-expiring capability URL to a live medical consultation | Room names derived from a server-side secret, never URL-visible. Authenticated principal binding |
| G15 | Raw unsigned, never-expiring media URLs for uploaded medical documents | s3.PresignRead (15 min) + ValidateOrgScope; Block Public Access |
| G16 | Cross-tenant template copy/move authorised by role.type !== 'superadmin'; move re-parents historical clinical documents across a tenant boundary | P49 platform-tier + clone. Never move. Break-glass for genuine cross-org reads |
| G17 | ENCRYPTION_KEY in an env var; encryption via a single ORM lifecycle hook on a varchar, which double-encrypts on echo-back | KMS-held keys; BYTEA via internal/core/crypto; cmd/check-classification enforced |
Class 3 — Audit & compliance
| # | leo | Guardrail |
|---|---|---|
| G18 | There is no audit log. audit-service.js (400+ lines) is never registered in config/middlewares.js — and could not work if it were: it queries audit_organization_id/audit_organization_api_key on franchise, neither of which exists in the schema, so it silently skips | audit_log in-transaction, append-only, monthly-partitioned, cmd/check-audit-coverage-enforced |
| G19 | The one live path is fire-and-forget HTTP to an external service, 3 retries then give up. A comment reads // Could write to dead letter queue here — it doesn't | Local synchronous first. An audit path that can silently no-op is not an audit path |
| G20 | user_activities has updated_at/updated_by_id — mutable, not append-only; data is unstructured text; no actor type, no diff, no status code | audit_log is the transition history. No bespoke event table |
| G21 | A patient's consent signature is publishedAt = now() set by an ordinary PUT — no signer, no IP, no UA, no version pin, no hash — and the same PUT can unset it | consents ledger: purpose_version, granted_by_principal_id, granted_via_ip, source, withdrawal columns, partial-unique active grant |
| G22 | CNP stored as plaintext meta_value.value and printed on every report and prescription | CLOSED 2026-08-07. pii_regulated → encrypted BYTEA on patient_profiles + a blind index for search; both plaintext routes refused by trigger; egress target patient_document. No per-template opt-in — the field's presence is the declaration |
| G23 | Report prints full demographics unconditionally for any patient at any franchise | Resolve through the renderer's own org-scoped query — the portable profile is readable only through a patients row at the printing org — not as a template-authoring convention |
| G24 | No rule that production data never reaches staging. A 20k-patient prod snapshot restored into staging to debug the migration turns a processor into a breach reporter | Add a hard rule to production-launch-readiness.md + a seeded synthetic generator (@example.com per RFC 2606, +40700000XXX Romanian test range) |
Class 4 — Frontend
| # | leo | Guardrail |
|---|---|---|
| G25 | refreshInterval: 60000 as a global SWR default — 6 cards = 6 req/min/tab forever | Violates the platform's no-polling rule. revalidateOnFocus/onReconnect + the SSE hub |
| G26 | cache: "no-cache" hardcoded on every server fetch | P42 tagged unstable_cache + P43 dispatcher + P45 cache-aside |
| G27 | 52 bare mutate() calls; zero optimistic updates; loading derived as !error && !data (wrong with keepPreviousData) | useOptimistic with auto-revert; validate before optimistic apply; SWR's own isLoading |
| G28 | useState(field.value) seeded from props throughout | P48 — use the prop, useOptimistic, or useServerSyncedState |
| G29 | 269 of 642 files carry hardcoded Romanian; error copy embedded in the data layer (patient.repository.ts error maps) and in Error subclass defaults; support codes baked into message bodies ("(PR-0006)") | next-intl catalogs; packages/ui stays locale-free (strings as props); codes as a separate prop. pnpm check-i18n enforces |
| G30 | The hold reducer is duplicated three times | Build useHoldsStream + useHolds once in packages/ui |
| G31 | Filters transported as base64(uriencoded(JSON)) and evaluated client-side | Plain readable query params; apiquery allow-lists; server-side evaluation |
8. Open decisions, ranked by downstream blocking
8.1 — Does the F2 stand-in ship, under what name, and with what boundary? — SETTLED 2026-08-02: option (a)
Three in-scope features have NOT NULL FKs into a catalog entity that is out of scope (§2.1). Options: (a) ship the F2.1-only offerings stand-in as proposed; (b) make calendars.offering_id/appointments.offering_id nullable with tracked debt; (c) pull F2.1 formally into scope.
Settled 2026-08-02 — option (a), named offerings from day one: catalog identity + offering_specialists + offering_forms, with no pricing, no plans, no products, no purchase path. F2.2 / F2.3 stay deferred. This unblocks migrations 000041–000048 (F3.4, F4.2, F5.1, F5.4).
Sub-decision, also settled: service_plans → enrollments was blocked — enrollment is taken by the shipped protocols.kind='enrollment' and the protocols.enroll permission. The reserved name is now offering_packages (glossary.md → Forbidden terms). The name is reserved; the concept stays deferred with F2.2.
8.2 — Override scoping — SETTLED 2026-08-05: calendar_id UUID NULL
The three platform sources disagreed (Area 4 has no scope column and sketches a JSONB on the junction; the specialists feature spec says appointment_type_id, a table in no architecture doc). The live system settles what clinics actually do: scheduleOpeningOverrides.scheduleId is NOT NULL (restartix-intakes/core/db/drizzle/schema.ts:171-196), and the predecessor openingOverrides table was explicitly removed in favour of it — a deliberate migration toward per-schedule scoping, with production rows.
Settled 2026-08-05 — specialist_schedule_overrides.calendar_id UUID NULL, NULL = all calendars. Preserves both capabilities: "I'm on vacation" is one row, and "block Physio next Tuesday but keep Nutrition open" is still expressible. leo's NOT NULL was the alternative and would have made a vacation N rows that can drift apart. The override_weekly_hours JSONB is struck. Requires correcting data-model.md Area 4 and the specialists feature spec.
8.3 — Is CNP required on generated documents? — SETTLED 2026-08-02, BUILT 2026-08-07
The single most expensive field in the port. "Yes" forces an encrypted patient_profiles.national_id_encrypted BYTEA, a new pii_regulated egress target for the renderer, per-template opt-in, and a regulated-PII surface the platform does not have today. "No" removes that surface entirely.
leo prints it on every report and prescription, so someone presumably relies on it — but rehabilitation reports are not obviously a CNP context.
Settled 2026-08-02 — yes. BUILT 2026-08-07, and the per-template opt-in flags were dropped along the way: a national_id question's presence, or the cnp field's selection on a block, already declares the intent. One home: pii_regulated → encrypted patient_profiles.national_id_encrypted BYTEA via internal/core/crypto, stored once on the patient-owned profile, never duplicated per-org. Never in the generic value store — a custom_field_values.value TEXT column can never legally hold a CNP, so field_type='national_id' routes to the dedicated encrypted column or is rejected outright (this is what 000042 needed settled). Egress is explicit: a data-classification.md entry with an egress target for the PDF renderer, which calls classification.AllowedFor rather than hand-building the field list (P39). Reads are permissioned and audited on the reveal endpoint.
8.4 — Are specialties per-org or platform-global? — SETTLED 2026-08-02: per-org
features.md F1.1 said verbatim "per-org or global — decide before migration." leo, data-model.md Area 2, and the specialties feature spec all say per-org. Global would have enabled cross-org anonymised analytics and a platform-seeded starter catalog.
Settled 2026-08-02 — per-org. specialties.organization_id UUID NOT NULL, UNIQUE (slug, organization_id). Not worth diverging from the live system the data migrates out of. 000040 is unblocked.
8.5 — humans has no name column. BLOCKS the F1 roster UI
Verified (000002:292-322). specialists.name covers specialists; admin and customer_support staff have no display name. Options: add humans.name TEXT NULL in 000040 with a classification entry, or accept email-as-display-name on the roster. A genuine foundation gap surfaced by the port, not a feature preference.
8.6 — Route topology: /team/{principalId} or /team + /specialists/{id}?
Calendar-only specialists (human_id NULL — an explicitly designed state) have no principal to route on, which argues for the split. leo's single-page shape argues for fewer surfaces. Blocks: the F1 UI wave and every deep link into it.
8.7 — Terminal-status policy vs. late arrival. Silent adherence corruption if unresolved
The substrate makes noshow terminal; leo permits noshow → inprogress. The cron marks noshow at +30 min; the patient walks in at +35. Under a strict terminal rule the specialist must create a new row, orphaning the generated forms, the video room, and the protocol_id/session_id pair the adherence engine reads — and leaving the auto-noshow permanently in the adherence denominator.
Recommendation: permit noshow → inprogress only when the noshow was set by the system actor and within a bounded window, audited as a correction. This undoes a machine's guess, not a human's decision. Related: make the grace period organization_settings.noshow_grace_minutes (leo already parameterises it).
8.8 — PDF engine + sync-vs-queued. BLOCKS F6.2, SOUP, and ECS sizing
chromedp/headless-shell sidecar (~150 MB image, full CSS fidelity, matches the browser preview) vs Gotenberg (cleaner isolation) vs Go-native (no Chrome, but the block editor's fidelity promise collapses). Separately: sync matches leo's UX; queued reuses the proven exercise_renders claim/attempts/backoff/dead-letter shape.
Profile CPU on Fargate explicitly — the media service was already burned once by ffmpeg reading host cores instead of the cgroup.
8.9 — Calendar grid — SETTLED 2026-08-05: hand-rolled, no drag in v1
Decides how much of leo's cockpit is reusable at all — its capacity-lane CSS only works against FullCalendar's DOM.
Three things measured before deciding, two of which changed the shape of the question:
- The licensing objection is void. All five plugins leo uses —
core,daygrid,timegrid,interaction,react— are MIT. The licence question applies only to the premium resource/timeline plugins, which leo does not use. - It is two surfaces, not one. Only three files in leo touch FullCalendar.
availability-calendar.tsx(351 lines) is a month grid with click and drag-select — andpackages/uialready shipsreact-day-picker@9, which doesmode="range"and day modifiers natively, so hand-rolling it is less code.evenimente/calendar.tsx(431 lines) is the real question: a time-grid withslotDuration,businessHours,nowIndicatorandeditabledrag-to-move. - FullCalendar cannot render a named timezone unaided.
namedTimeZonedImpldefaults tonullin core — out of the box it does'local'or'UTC'only, and IANA zones need@fullcalendar/luxon3plusluxon. The platform computes in a resolved scheduling timezone (P23) that is deliberately not the browser's, so this is not optional: it is 7 SOUP rows, not 5.
Settled — hand-roll both. The availability editor rides the already-shipped react-day-picker; the cockpit time-grid is hand-rolled at roughly 400 lines (layout ~200, overlap packing ~100, availability shading ~50, now indicator ~20).
The decisive point is the shading. businessHours takes a recurring weekly shape and has no concept of a date override that replaces a day (rule S1) — which is precisely why leo carries capacity-lane CSS hacking FullCalendar's DOM. Adopting the library would mean fighting it on the single most important visual in the cockpit while inheriting a workaround this document already flags as non-portable.
Drag-to-move is deliberately NOT in v1, and the reason is not effort (~250 lines). §8.13's reschedule semantics are undecided — mutate scheduled_at in place versus cancel + new row — and building drag-to-reschedule against an unsettled contract means building it twice. leo's own drag has no confirmation step. v1 is click-to-select and click-to-open; drag lands once the semantics do.
8.10 — Patient self-booking at launch? — SETTLED 2026-08-05: not in F4
The substrate treats it as an org capability with UI gating, but shipping the public path exposes unauthenticated hold + book endpoints and their abuse surface at launch. leo's real creation surface is staff-side; patient bookings arrive from the external Intakes public UI.
Settled 2026-08-05 — F4 is org-scoped only. Availability and holds ship behind auth, driving the clinic cockpit. The unauthenticated /v1/public/ path lands with F5.4, where appointments exist to be created and the booking-client cookie + cooldown machinery has something to attach to. Holds are still built in F4 — staff booking needs them.
8.11 — Legacy migration: source of truth and fidelity
Now tractable given §0.1. Concretely: (a) Do future bookings and specialist availability migrate from the Intakes Postgres (schema readable at core/db/drizzle/schema.ts), or do clinics re-enter availability? (b) Preserve leo's random-nanoid slugs verbatim to keep live public URLs ({speciality.slug}/{template.slug}) working? (c) Historical forms have no snapshots — import with the template's current state (accepting history was already rewritten) or as read-only "legacy answers" with a synthesized snapshot and a visible provenance marker? The GDPR/medical-record posture differs sharply. (d) Who signs off that a repaired pipe-delimited answer is still the patient's answer?
8.12 — Cross-org double-booking
specialists.organization_id NOT NULL gives a specialist at two clinics two independent profiles and schedules, so P40's "cannot be in two places at once" holds only within an org. leo's constraint is on opening_id, which is org-scoped too — so leo has the same limitation. Accept as documented, or add a cross-org conflict check keyed on specialists.human_id (a cross-tenant read needing anonymised/break-glass treatment).
8.13 — Lower-blocking, but decide before the relevant migration
- Reschedule semantics — mutate
scheduled_atin place with audit (leo, preserves duration) vs cancel + new row (forensically cleaner, consistent with the terminal-cancel rule) - Late-cancellation threshold — org-configurable
organization_settings.late_cancellation_hours(default 24h) or a platform constant - Legacy
cancelled→ which of three buckets? leo carries no attribution signal beyond free text. Mapping all tocancelled_by_clinicavoids retroactively penalising 5k+ migrated patients but overstates clinic-caused cancellations in fill-rate reporting - Does holding a specialty GATE roster assignment? leo built the enforcing component (
select-specialist.tsx) and abandoned it unimported. Either enforce with a 422, or declare specialty membership taxonomic-only in the glossary - Timezone-change policy when weekly hours exist — block-with-migrate (the spec) or allow-with-preview. leo silently shifts real availability, which is the one behaviour that is definitely wrong
prescriptionnaming collision — rename the document type or qualify it in the glossary. Must be settled before the F6 migration- Audit granularity for autosave — one coalesced row per batched flush (with the changed-key diff) vs one per field. At 20 fields × 20k patients this is the difference between a usable and an unusable audit table
is_requiredenforcement point — per-field on save (breaks autosave) or at thepending→completedtransition (allows indefinitein_progresswith gaps, which matters for the patient wall's "is this done" query)- Break-glass review artifact —
apps/docs/features/audit/compliance.md:47asserts "review within 24 hours, two approvers" but000011_break_glass.up.sqlhas noreviewed_at/review_notes/opened_from_ip. An asserted control with no mechanism. Note:000011is applied to prod, so this needs the catch-up-DDL pattern (infra/scripts/000023-skip-note-prod.sql) - CSV export as a first-class surface —
grep -n "export" apps/docs/openapi.yamlreturns nothing. It is in daily clinic use in leo. Treat as a scope question, not a slip-in; if in, columns come fromclassification.AllowedFor(table, "bulk_export"), never a hand-rolled list - Locations at launch — build the
location_id NULLcolumns per P40 (yes), but does the September UI expose a location picker? - Is F8 segments in September scope? The filter-by-form-answer dialog is in daily staff use; removing it is a visible regression at migration. But segments is a whole subsystem
9. Immediate next actions
Docs reconciliation PR, no code. Fix
appointments-substrate.md(§0.3, four defects — one of which failsmake check). Addorganization_id NOT NULLto both availability tables indata-model.mdArea 4. Renameappointment_types→calendarsacrossapps/docs/features/scheduling/api.mdand all sixgo/*.goreference files before anyone copies the losing name into a migration. Add glossary entries: Specialist, Specialty, Offering, Calendar, Hold, Weekly Hours, Schedule Override, Booking Client ID, two-phase booking identity, Form Template, Form Instance, Custom Field, System Field, Form Slot. AddOpening→Specialist,Intake→Appointment,Schedule→Calendar,franchise→organizationto the Forbidden-terms table. Resolve the two naming collisions (prescription,enrollments).Answer §8.1 and §8.4 — nothing can be written before these.
Extract the non-code assets now — independent of build order and the genuinely irreplaceable part: the Romanian label set and A4 geometry from
default.tsx, and the two clinician-authored annex documents (nutritional.tsx,nutritia-durerii.tsx, 1,183 lines of clinical prose).Snapshot the Intakes production schema and data shape while the service is live. §0.1 makes this tractable today; it will not stay that way. This is the only part of the dimension with irreplaceable production data.
Write the availability-engine test suite — differential against
restartix-intakes/core/services/availability.ts. This can start immediately, in parallel with the docs PR, and is the highest-leverage de-risking available.