Status: Draft v0.1 Last updated: 2026-10-17
Contribution rule: Any PR that changes a data model, a service boundary, or the tech stack must update this file in the same PR, and reflect the essentials in
ARCHITECTURE_ESSENTIALS.md. Also updateROADMAP.mdstatus if the change closes/opens roadmap work.
See PRD.md for product scope and ARCHITECTURE_ESSENTIALS.md for the quick-reference version.
- Modular monolith first, not microservices. One deployable backend, internally organized into bounded modules (auth, doctors, appointments, referrals, consultations, orders, facilities, notifications). Split into services only when a concrete scaling or team-ownership problem demands it — not preemptively (see PRD §8.3).
- Orders are append-only. Prescriptions and lab orders are never mutated after issuance. Corrections create a new versioned record referencing the original. This is a hard architectural constraint, not a style preference (clinical/legal safety).
- The referral carries state, not a pointer to state. A referral snapshots the intake + consultation notes it's built on, so later edits elsewhere can't silently rewrite what a specialist already saw.
- Boring tech, correct concurrency. Availability booking is a correctness problem before it's a scaling problem — solve double-booking with real DB constraints/transactions, not application-level checks.
- Build vs. buy for anything not core IP. Video/chat infra, SMS/email delivery, file storage — use managed providers behind an internal interface so they're swappable.
| Layer | Choice | Why |
|---|---|---|
| Backend language/runtime | Node.js (TypeScript) | Single language across stack, large ecosystem, easy hiring. |
| Backend framework | Express 5 — decided 2026-09-14, resolving the "(or Fastify)" either/or | Minimal, unopinionated, matches "modular monolith" — avoid a heavy framework that fights the module boundaries. Express 5's native async-handler error forwarding (rejected promises reach the error middleware without a manual wrapper) was a real factor, not just familiarity. |
| Request validation | Zod | Not originally listed here — added alongside Phase 1's real request bodies. Schemas live per-module (modules/*/validation.ts), thrown ZodErrors are caught centrally by middleware/error-handler.ts. |
| Password hashing | bcryptjs | Pure-JS, not a native bcrypt binding — one less native build dependency for a repo with no other native deps yet. |
| Database | PostgreSQL | Relational integrity is critical (bookings, orders, referrals all have strict FK/state constraints); JSONB available for the flexible parts (intake payload). |
| ORM / schema | Prisma 7, with the @prisma/adapter-pg (node-postgres) driver adapter — upgraded 2026-10-16 to clear a high-severity advisory; see docs/adr/0006 |
Schema-as-code, migrations, keeps backend/prisma/schema.prisma as the single source of truth for data models (mirrors §4 below). Two v7 facts that constrain where code goes: (a) there is no Rust query engine any more, so the client must be constructed with a driver adapter (src/db/prisma-client.ts) — bare new PrismaClient() throws; (b) the CLI's datasource URL lives in backend/prisma.config.ts, not in the schema's datasource block. The prisma-client-js generator is retained deliberately, so the client still generates into node_modules/@prisma/client and this backend stays CommonJS (see §3 note on @docforum/escrow-sdk being ESM-only). Requires Node >= 20.19. |
| Auth | JWT access + refresh tokens, role claims (patient | doctor | facility | admin) |
Simple, stateless for API scaling; refresh rotation for security. |
| Frontend | React + TypeScript, Vite | Fast dev loop, standard choice, no framework lock-in beyond React. |
| Frontend state/data | React Query (server state) + lightweight store (Zustand) for client-only UI state | Avoids over-centralizing server data into a global store. |
| Realtime (messaging/notifications) | WebSocket gateway (or managed pub/sub, e.g. Pusher/Ably) behind an internal notifications interface |
Swappable; not custom infra from day one. |
| Video/voice (if/when needed) | Third-party embed (Twilio/Daily/Agora) behind an interface, not built in-house | PRD §8.3.1 — explicitly flagged as overengineering risk if built custom. |
| File/result storage | Object storage (S3-compatible) for lab result attachments — decided 2026-10-24 (issue #16, docs/adr/0014): Amazon S3, a private SSE-encrypted bucket per environment, via @aws-sdk/client-s3 behind backend/src/utils/object-storage.ts |
Never store clinical files directly in Postgres rows. Files are never addressed by URL (public or presigned): the row keeps an opaque key and the API serves the bytes after an access check on every read. |
| Email / SMS delivery | Postmark (transactional email) and Twilio (SMS), both behind the provider-agnostic notifications dispatch interface and the email-dispatcher.ts seam. Decided per docs/adr/ (Phase 6, issue #17). |
Transactional-only providers with no marketing-list coupling; swappable because business logic only ever calls the internal Notification dispatch interface, never a provider SDK. |
| Field-level encryption | AES-256-GCM envelope encryption (backend/src/utils/field-encryption.ts, Phase 8 issue #46) for clinical free text (Consultation.notes, Referral.snapshotNotes) |
Limits blast radius of a leaked DB dump / over-broad read replica — encryption at rest doesn't protect against those. Versioned key-id prefix enables rotation without rewriting every row. Constraint: encrypted fields cannot be searched or indexed — they're write-once/read-decrypt blobs. |
| Background jobs | Simple queue (BullMQ on Redis) for reminders, order-expiry checks, notification delivery | Needed for FR-15/16 and the "orphaned order" hard question (PRD §8.1.3). |
| Infra | Docker Compose for local dev; containerized deploy target TBD (not decided — see Open Questions in ROADMAP) | Keep infra decisions deferred until a real deploy target is chosen; don't overbuild CI/CD for a repo with no code yet. |
Each module under backend/src/modules/<name>/ owns its own routes/, controllers/, services/, repositories/. Cross-module access happens through a module's exported service interface only — never reach into another module's repository directly. This is the internal seam that would let us extract a module into its own service later without a rewrite.
auth— signup/login, tokens, role checks.users— shared user profile fields common to all roles, plus the admin account lifecycle (invites, soft deactivation — issue #39).authcalls this module's services to create an invited admin;usersnever calls back intoauth.patients— patient-specific profile data.doctors— doctor profile, specialties, verification status.facilities— lab/pharmacy accounts, capability tags (what order types they can fulfill).availability— slot generation and holds (the module responsible for preventing double-booking).appointments— booking lifecycle, links patient + doctor + slot + intake.consultations— the record of what happened in a booked appointment (notes, outcome). Minimal slice is real (create against one's own completed appointment, read for that doctor + the patient, spawn referrals); sign-off/addenda stay issue #4.referrals— referral creation, lifecycle, snapshot of context, loop flags, the appointment-status cascade, and the admin queue/override operations. This is the most cross-wired module: it creates appointments throughappointments' service, andappointmentscalls back intoreferrals' service (applyAppointmentOutcome) from inside its status-transition transaction. That mutual service-to-service import is deliberate and runtime-safe under ESM live bindings (every cross-call happens in an async function, after both modules have loaded) — the alternative, either module reaching into the other's repository, is what hard rule 4 exists to prevent.prescriptions— prescription issuance and versioning.lab-orders— lab order issuance and versioning.pharmacy-orders— facility fulfillment for pharmacy orders (reference number required, optional attachment viaStorageProvider, audit event logged).orders— patient order confirmation and dispute handling (POST /orders/:id/confirm-receipt,POST /orders/:id/dispute), derivedconfirmedSignalcalculation, unconfirmed order expiry job, and admin dispute queue (GET /admin/orders/disputes,POST /admin/orders/disputes/:id/resolve).ordersalso owns lab result attachment (issue #16,docs/adr/0014):POST /orders/lab-orders/:labOrderId/result(the order's own facility only, create-once) writes the file to object storage and aLabResultrow;GETon the same path serves it to the patient, the ordering doctor and the fulfilling facility only (PRD §7).services/lab-results.ts.notifications— outbound notification dispatch, provider-agnostic.payments—PaymentIntent, the facility payeeWalletLink, and the patient payerPatientWalletLink(§4.13). Relational metadata only; every Stellar call goes through@docforum/escrow-sdkviaservices/escrow-sdk-loader.ts. Assets are allowlisted per environment (issue #79), and the prepare half of ADR 0005 (issue #80) builds the unsigned patient transaction without moving funds.audit— the append-onlyAuditEventtrail (Phase 8, issue #43). Owns one write helper,auditService.record(tx, actor, event), that every module calls inside the transaction of the state change it describes (it refuses the root client), and the admin-onlyGET /admin/audit-events. Controllers build the actor withauditActor(req)(src/middleware/request-id.ts: user id, role,X-Request-Id). Wired today: appointment booking (both the direct and the referral-origin path), consultation closing (Phase 2, issue #4), doctor verification, payment fund/release/refund, the patient's referral accept/decline, and the referral admin actions (Phase 3, issue #68 —referral.reassign/referral.cancel, recorded asentityType: 'Referral'rows; there is no second, referral-specific audit table). Phase 4 transitions (fulfillment) must callrecord()in the same transaction when they're built — prescriptions and lab orders already do. Also owns the PHI read-access log (Phase 8, issue #45, §4.11):auditService.recordAccess()(fire-and-forget off the read path, fail-open but counted), adminGET /admin/access-logs(+/user,/stats) and the patient-facingGET /patients/me/access-log, whose response strips all viewer PII to a role.
Right of access (POST /patients/me/export) is coordinated by patients,
but each module contributes its own section through its own services/
interface — patients never reads another module's tables to build an
export. The pieces:
modules/patients/services/export-registry.ts— thePatientExportSectioncontract (name,version,pages(subject, pageSize)yielding pages of plain JSON items),registerExportSection, and apaginatekeyset helper.src/export-sections.ts— the one explicit list of registered sections, called fromcreateApp(). It's wiring, outsidemodules/, sopatientsdoesn't have to import every module that already importspatients.- Current sections:
account(auth: email/phone/role, never the password hash),profile(patients),appointments(appointments, with slot times fromavailabilityand the doctor's name fromdoctors, both via their services),payments(version 2: amount in the asset's smallest unit, asset code, status, on-chain tx hash / escrow id / contract id; the platform's signing secrets never live in a row and can't reach an export).
Adding a module with patient data (referrals, prescriptions, lab orders,
…): export a PatientExportSection from that module's services/ and add
it to src/export-sections.ts. Rules for a section: an explicit field
allowlist (never spread a Prisma row — a column added later must not leak by
default); page through your own tables with paginate (at most pageSize
rows in memory); batch any cross-module lookups per page through the other
module's service; JSON-safe values only (BigInt → string). Bump version
when the item shape changes. A section that throws fails the whole export —
there is no "partial" bundle.
Generation runs in src/jobs/patient-exports.ts (plain intervals, woken
immediately by a new request). Sections run one at a time and the bundle is
flushed in bounded chunks to PatientExportChunk (§4.11), so memory is
bounded by one page of rows plus one chunk regardless of history length.
DELETE /patients/me is orchestrated by patients and anonymises in one
transaction: the User row through auth's service (anonymizeAccount,
which takes the caller's transaction client) and the PatientProfile
through patients' own repository. Nothing is cascaded; see
docs/adr/0010 for the per-model retention table.
The scheduled purge (src/jobs/account-purge.ts) may only hard-delete a
tombstone: an anonymised patient that no module still holds records for.
"Holds records" is answered by each owning module through its own service,
registered in src/retention-holds.ts (wiring, outside modules/, because
patients can't import modules that already import it). The RESTRICT
foreign keys are the backstop. A new module with patient-linked records
must do both: reference PatientProfile with onDelete: Restrict, and add
a hold.
Canonical source of truth: backend/prisma/schema.prisma. This section is the human-readable mirror — if they drift, the schema file wins and this doc should be corrected in the same PR.
Phase 1 models are real (User, PatientProfile, DoctorProfile, Specialty, DoctorSpecialty, FacilityProfile, FacilityCapability, AvailabilitySlot, Appointment) — migrated via backend/prisma/migrations/20260914110638_init/. Phase 3 added the real Referral and the Consultation↔appointment link — migrated via backend/prisma/migrations/20261016214929_referral_phase3/; §4.3 and §4.4 describe what actually shipped, not a sketch. The audit trail (AuditEvent, §4.10) is the Phase 8 one (issue #43, 20261017212335_add_audit_event) — the referral admin actions live in it rather than in a second table. Everything else below (§4.5 onward) is still the Phase 0 structural placeholder.
Three intentional deviations from the original sketches, all flagged in the schema file itself: Appointment.intakeId is deferred (nullable-by-omission, not yet a column) since IntakeForm doesn't exist until Phase 2; PatientProfile.guardianUserId was a plain string with no FK relation while the minor/dependent consent model (PRD §8.2.5) was open — replaced in Phase 8 (issue #42, docs/adr/0011) by the Guardianship relation (§4.1); and Referral.snapshotIntakeId likewise has no column yet (there is no IntakeForm to snapshot) — the intake half of the snapshot rule cannot be violated while there is no intake data at all, and the column must be added (as a copy) when issue #1 lands.
User
id, email, phone, password_hash, role (enum: patient|doctor|facility|admin)
email_verified_at (nullable — Phase 8 issue #34; null = unverified.
Backfilled to created_at for pre-existing rows in the
add_email_verification migration, so accounts from before this phase
are treated as verified)
locale (string, default `en` — Phase 9 issue #133; selected from
Accept-Language at signup, unsupported tags fall back to `en`)
locale (string, default `en` — Phase 9 issue #133; supported language
selected from Accept-Language at signup, unsupported tags fall back to `en`)
deleted_at (nullable — Phase 8 issue #41, docs/adr/0010; set once,
irreversibly, when the account is deleted. The row is anonymised in
place: email -> deleted+<id>@deleted.invalid, phone null, password
unusable, email_verified_at null. Login and refresh refuse it.)
created_at, updated_at
EmailVerificationToken (Phase 8 issue #34)
id, user_id (FK -> User), token_hash (unique, SHA-256 — raw token never stored)
expires_at, consumed_at (nullable), created_at
# Append-only-ish: issuing a new token marks any outstanding one consumed
("latest link wins") rather than deleting it. Redemption is a conditional
UPDATE (consumed_at IS NULL), same single-use shape as §5.1's booking
guarantee, so two concurrent redemptions can't both win.
PatientProfile (1:1 User, or 0:1 for a dependent)
id, user_id (nullable since issue #42 — null = a guardian-owned dependent
with no login; still unique. FK -> User, ON DELETE RESTRICT since issue
#41 — was CASCADE), full_name, date_of_birth
anonymized_at (nullable — issue #41; set with full_name -> 'Deleted patient',
date_of_birth -> null. The row stays so Appointment and
PaymentIntent keep their FK; both of those reference it ON DELETE RESTRICT.)
# CHECK (user_id IS NOT NULL OR date_of_birth IS NOT NULL): a dependent
always has a DOB, because guardian access ends at the age of majority.
# guardian_user_id (unenforced string) was dropped in issue #42; the
migration refuses to run if any row still had a value.
Guardianship (Phase 8, issue #42 — docs/adr/0011)
id, guardian_user_id (FK -> User, RESTRICT), dependent_profile_id (FK -> PatientProfile, RESTRICT)
created_at, revoked_at (nullable)
# unique (guardian_user_id, dependent_profile_id) — no duplicate pairs.
# Created only together with a new dependent profile (POST
/patients/me/dependents); there is no way to link an existing profile.
# Active = revoked_at IS NULL AND the dependent is under
AGE_OF_MAJORITY_YEARS (UTC calendar date, checked at request time).
Never deleted — ending access sets revoked_at, or happens by age.
# All dependent-scoped access goes through
patients/services resolveActingProfile (booking, appointment listing).
DoctorProfile (1:1 User)
id, user_id, full_name, verification_status (enum: pending|verified|rejected)
time_zone (IANA identifier used when interpreting the doctor's availability)
specialties: DoctorSpecialty[]
Specialty (controlled vocabulary, admin-managed)
id, name, slug
DoctorSpecialty (join table)
id, doctor_profile_id, specialty_id
FacilityProfile (1:1 User)
id, user_id, name, type (enum: lab|pharmacy), version (int, default 1 — issue #49, §5.4)
capabilities: FacilityCapability[]
FacilityCapability
id, facility_profile_id, capability_type (enum: lab_test|medication_dispense), detail (e.g. test category)
version (int, default 1 — issue #49, §5.4)
version columns (Phase 8, issue #49): added by
backend/prisma/migrations/20261019090000_add_optimistic_concurrency/ as
INTEGER NOT NULL DEFAULT 1 on exactly the mutable records above plus
Appointment (§4.2) and PaymentIntent. They are the storage half of the
ETag/If-Match convention in §5.4 and carry no meaning on their own —
nothing may treat a higher version as "more correct", and no code may
compare-then-write instead of guarding the UPDATE itself. Tables that are
append-only (Prescription, LabOrder) or immutable
(Specialty, WalletLink, AuditEvent, the export tables) deliberately do
not have one; the integration test asserts that set so a later change
can't quietly widen it.
AvailabilitySlot
id, doctor_profile_id, start_time, end_time, status (enum: open|held|booked)
# Uniqueness/exclusion constraint at DB level on (doctor_profile_id, start_time, end_time)
# to make double-booking a constraint violation, not an app-logic bug.
# No `version`: a slot is only ever moved by the conditional claim in §5.1,
not edited.
Appointment
id, patient_profile_id, doctor_profile_id, availability_slot_id (FK, unique, NULLABLE)
intake_id (FK -> IntakeForm — still not a column, Phase 2 issue #1/#2)
status (enum: scheduled|completed|cancelled|no_show)
origin (enum: direct_booking|referral), referral_id (nullable FK -> Referral)
version (int, default 1 — issue #49, §5.4; no If-Match-guarded route exists
for appointments yet, but every future status write must move it)
created_at, updated_at
# availability_slot_id is nullable (Phase 3): a cancelled appointment
# releases its slot back to `open` and drops the unique link, so the
# freed slot can be re-booked. The booking guarantee itself is
# unchanged — it lives in claimOpenSlot's conditional UPDATE, not in
# this column. History rows keep their referral_id, so the lineage of a
# referral that produced several appointments survives the link being
# cleared (Referral.appointments holds the full list).
WaitlistEntry
id, patient_profile_id (required), doctor_profile_id (nullable),
specialty_id (nullable), facility_capability_id (nullable),
availability_slot_id (nullable), status (pending|on_hold|accepted|expired),
earliest_acceptable, latest_acceptable, hold_duration_minutes,
created_at, updated_at
# A patient's waitlist request may target a doctor, specialty, or
# facility capability. A time-boxed offer may reference an availability
# slot; deleting its target profile/capability clears that optional link.
WindowTime
id, start, end (unique pair)
IntakeForm
id, patient_profile_id, symptoms (structured: array of {symptom, duration, severity}), free_text_notes
created_at
Consultation
id, appointment_id (FK, unique — one consultation per appointment;
a re-visit is a new appointment, not a second row)
notes [ENCRYPTED — Phase 8 issue #46, see §2 field-level encryption row]
outcome (enum: resolved|referred|follow_up_needed, nullable in the schema —
the close routes require it, so a closed record has one; see below)
created_at, updated_at
referrals: Referral[] # children — the lineage loop detection walks
Implemented 2026-10-20 (Phase 2, issue #4): appointment_id UNIQUE is what makes a consultation closable once — a second concurrent close loses at the INSERT, not at an app-level check (see docs/adr/0009). There is no amendment/edit path in v1. notes is encrypted on write via encryptField() (requires FIELD_ENCRYPTION_KEY_V1), so the first writer to a clinical free-text column honours the rule below rather than starting a plaintext precedent; reads decrypt in the consultations service.
The row has exactly one writer — consultationsService.createConsultation — reachable from three addresses so that no path can bypass the uniqueness, encryption or audit rules: POST /consultations (appointment in the body; Phase 3's referral slice, outcome optional so a record can exist before sign-off), POST /appointments/:id/consultation (Phase 2's close route, outcome + notes required), and the orders flow's consultation step (which adds its own verified-doctor gate and then delegates). All of them require the appointment to be completed and to belong to the calling doctor, and all of them write the same consultation.closed audit event in the same transaction.
Encryption note: notes is stored as an AES-256-GCM envelope (v1:<iv>:<tag>:<data>) via backend/src/utils/field-encryption.ts. Because it's encrypted, it cannot be searched or indexed — any future full-text search over consultation notes must decrypt in the application layer or use a separate searchable index, not a Postgres index on this column. Key rotation: backend/scripts/rotate-field-keys.mjs re-encrypts in batches.
Readable (GET /consultations/:id) only by the treating doctor and the
patient of the underlying appointment; anyone else gets 403, and the
stored ciphertext never leaves the module. What is still open is issue
#4's fuller workflow — sign-off, addenda, a doctor editing notes after the
fact — which is exactly why the referral snapshot (§4.4) exists rather
than referrals reading this row live.
Referral
id
patient_profile_id (FK -> PatientProfile)
source_consultation_id (FK -> Consultation, required)
referring_doctor_id, target_doctor_id (both FK -> DoctorProfile, required)
target_specialty_id (nullable FK -> Specialty — denormalised label, used
by the loop guard and admin views; not issue #23's specialty management)
snapshot_notes [ENCRYPTED — Phase 8 issue #46] (text copy of the referring consultation notes at time of referral — immutable)
parent_referral_id (nullable self-FK — the referral whose appointment
produced the consultation this referral came from; this is the chain
loop detection walks)
status (enum: pending|accepted|declined|completed|expired|cancelled)
no_show_count (Int, default 0), referring_doctor_notified_at (nullable)
loop_flagged_at (nullable), loop_flag_reason (nullable text)
cancelled_reason (nullable text)
resulting_appointment_id (nullable FK -> Appointment, unique — the
appointment this referral *currently* produced)
created_at, updated_at
indexes: patient_profile_id, referring_doctor_id, target_doctor_id,
status, loop_flagged_at
Design note: snapshot_notes deliberately duplicates Consultation.notes
rather than pointing live at it, so a later edit to the original
consultation can never silently change what the specialist already acted
on (PRD hard question 8.1.4). snapshotIntakeId from the original design
has no column yet — see the §4 intro note.
The other six fields are all Phase 3 issue work: parentReferralId +
loop_flagged_* are loop detection (#65), no_show_count +
referring_doctor_notified_at are the no-show cascade (#67),
cancelled_reason is admin override (#68). target_doctor_id being
required is now backed by a real routing rule rather than a scope
cut (issue #6, docs/adr/0008-referral-routing-without-target-doctor.md):
a specialty-only referral is resolved to the least-loaded eligible
doctor at creation, so the resolved id is always what this column
holds and nothing is ever created unassigned.
Status transitions that exist today:
pending → accepted (patient accept, conditional UPDATE — the same
concurrency shape as claimOpenSlot), pending → declined (patient),
accepted → completed (referral-origin appointment completed),
accepted → pending (appointment cancelled or first no-show — bookable
again, same snapshot, no re-intake), accepted → expired (second
no-show, threshold NO_SHOW_EXPIRY_THRESHOLD = 2), and
pending|accepted → cancelled (admin, audited — AuditEvent, §4.10).
pending ──accept──▶ accepted ──appointment completed──▶ completed
│ │
│ ├─appointment cancelled──▶ pending (slot released; same snapshot)
│ ├─no-show #1─────────────▶ pending (no_show_count = 1)
│ ├─no-show #2 (=threshold)─▶ expired (referring_doctor_notified_at set)
│ └─admin cancel────────────▶ cancelled (reason; audited; live appt closed)
├─decline──────────▶ declined
└─admin cancel──────▶ cancelled
Every arrow into pending/completed/expired from accepted is a
conditional UPDATE that only matches a referral still accepted and
still pointing at the appointment that triggered it, so a doubled hook
is a no-op (issue #67, integration-tested).
Prescription
id, consultation_id (FK), patient_profile_id (FK), prescribing_doctor_id (FK)
facility_profile_id (FK to pharmacy), amount_stroops (clinician-entered XLM total)
status (enum: issued|partially_fulfilled|fulfilled|expired|superseded)
# `partially_fulfilled` added Phase 5 (issue #73): the status is
# DERIVED from item-level fulfillment rows (see §4.7), never asserted.
supersedes_prescription_id (nullable, unique self-FK), created_at
# Corrections create a child row and never update the issued row.
PrescriptionItem
id, prescription_id (FK), drug_name, dosage, frequency, duration
LabOrder
id, consultation_id (FK), patient_profile_id (FK), ordering_doctor_id (FK)
facility_profile_id (FK to lab), amount_stroops (clinician-entered XLM total)
test_type, urgency (enum: routine|urgent), notes [ENCRYPTED]
status (enum: issued|partially_fulfilled|fulfilled|expired|superseded)
# `partially_fulfilled` is unreachable for a lab order — it is a
# single-item order, so issue #73's derivation is binary (§4.7).
supersedes_lab_order_id (nullable, unique self-FK), created_at
LabResult
id, lab_order_id (FK, unique), fulfilling_facility_id (FK), result_file_url, summary
created_at
result_object_key (unique, opaque — issue #16), result_content_type, result_byte_size
# The file is in object storage under result_object_key (docs/adr/0014);
the key is never returned to a client. result_file_url is not written.
# Create-once: one result per order (the lab_order_id unique), attached by
the order's own facility; a superseded order takes no result.
The facility and total are captured at issuance. The issuing clinician supplies
amount_stroops in native XLM stroops because v1 has no facility catalog or
pricing service. Corrections create a new immutable row. A created payment
intent moves to the correction; an already escrowed intent remains linked to
the original and is refunded through the existing admin refund action.
FulfillmentRecord
id, prescription_id (FK, nullable), lab_order_id (FK, nullable)
facility_id (FK), status (enum: pending|in_progress|partially_fulfilled|fulfilled|rejected)
created_at, updated_at
items: FulfillmentItem[]
# Exactly one of the two order FKs is set — enforced by a PostgreSQL CHECK,
not an application guard. One row per (order, facility): the UNIQUE is on
each (order FK, facility_id) pair (Postgres keeps NULLs, so each index only
constrains the rows its own FK covers). ADR 0012 originally put the UNIQUE
on the order FK alone — issue #73's split routing (remaining items go to a
second facility) needs several records per order, so uniqueness moved down
one level; see the ADR's 2026-10-26 amendment.
# All three FKs are RESTRICT: deleting an order or a facility account cannot
erase the fulfillment trail (docs/adr/0010's retention rule).
# Replaces the Phase 0 polymorphic (order_type, order_id) pair.
Decision + reasoning: docs/adr/0012 (issue #13).
# facility_id is the OWNER: a facility may only ever write rows it owns
# (hard rule 5). `status` is derived from the FulfillmentItem rows below —
# `fulfilled` is never settable by the facility directly. It is the
# facility the patient ROUTED to, which for a split order is not the
# order's own `facility_profile_id` (issue #73).
FulfillmentItem (Phase 5, issue #73 — item-level / partial fulfillment)
id, fulfillment_record_id (FK, cascade)
order_type (prescription|lab_order), order_item_id
# PrescriptionItem.id, or LabOrder.id itself for a lab order
# (a lab order is a single-item order)
quantity_ordered (nullable int — Phase 4 item models have no quantity
# column yet; null = "not tracked", never fake a zero)
quantity_dispensed (int, default 0)
status (enum: pending|dispensed|unfulfilled)
dispensed_key (nullable) — equals order_item_id exactly when
# status = 'dispensed', NULL otherwise. This is how the hard
# constraint "one item can never be dispensed by two facilities" is
# expressed as a plain partial unique index
# (order_type, dispensed_key): NULLs don't collide in Postgres, two
# non-NULL dispenses of the same item violate the index. A real
# `WHERE status = 'dispensed'` partial index would be equivalent SQL
# but Prisma cannot represent it in schema.prisma and `migrate diff`
# would try to drop one it finds in the database.
dispensed_at (nullable), created_at, updated_at
uniqueness: (fulfillment_record_id, order_type, order_item_id) — the
# same item can't be routed twice under one record
Order-status derivation (issue #73):
dispensed_items == items_of_order -> order.status = fulfilled
0 < dispensed_items < items_of_order -> order.status = partially_fulfilled
no dispensed items -> unchanged (issued)
Applied by the prescriptions/lab-orders module services (forward-only,
conditional update — an expired/superseded order is never resurrected)
called from pharmacy-orders AFTER its item rows commit. Field updates on
an issued order's lifecycle `status` are the ONE sanctioned mutation of
an issued order (§5.3 step 5); clinical content stays append-only.
Design note: kept separate from Prescription/LabOrder so a facility's operational status doesn't need write access to the clinical order record itself — it only writes to its own fulfillment row. Reduces blast radius of a compromised/misbehaving facility account.
One table rather than a PrescriptionFulfillment/LabOrderFulfillment pair: the §5.3 step-5 transition ("fulfilled only via the facility-confirmed path") then exists in exactly one implementation instead of two that can drift, and the facility's assigned-orders queue is one indexed query (WHERE facility_id = ? AND status IN (…)) instead of a cross-table union. The lab-specific payload was never on this row anyway — it lives in LabResult (§4.6).
Notification
id, user_id, type, payload (jsonb), read_at (nullable), created_at
LoginAttempt (issue #35)
id, scope ('email' | 'ip'), key, failed_count, window_start, last_failed_at
lock_until (nullable), created_at, updated_at
# unique (scope, key). One bucket per normalised email and per client IP.
# Deliberately in Postgres, not process memory: the deployment is a single
# instance today but must not be designed as one forever, and a restart
# must not hand an attacker a fresh budget.
# Counters move only via a single conditional upsert (INSERT ... ON
# CONFLICT DO UPDATE) — never read-then-write — so N concurrent bad logins
# are counted as N (integration-tested).
# The lock is set once per window; hammering a locked account grows the
# count but never extends the deadline, so a lock is a bounded wait.
User fields added in Phase 8 live in §4.1; the email_verified_at /
EmailVerificationToken pair (issue #34) is in §4.1 as well.
AuditEvent
id, actor_user_id (nullable — null = system), actor_role (nullable Role)
entity_type ('Appointment' | 'DoctorProfile' | 'PaymentIntent' | 'Consultation' | 'Prescription' | 'LabOrder' | 'Referral'), entity_id
action (dotted, e.g. 'appointment.booked'), from_state, to_state (nullable)
request_id (X-Request-Id of the causing request), metadata (jsonb, nullable)
created_at
# APPEND-ONLY: no update/delete path in code, and BEFORE UPDATE/DELETE
(row) + BEFORE TRUNCATE (statement) triggers reject mutation in the
database. A future retention policy must drop/recreate them in its own
reviewed migration.
# No foreign keys on purpose: an ON DELETE action would have to rewrite
audit rows (forbidden), and RESTRICT would let the trail block account
lifecycle (e.g. issue #41's purge). Ids are recorded as facts.
# Written only by audit.record() inside the transaction of the change it
describes — the event exists iff the change committed.
# metadata: <= 16 flat scalar keys, strings <= 200 chars; keys that look
like personal/secret data (name, email, phone, dob, notes, intake,
password, secret, token, key, body, payload, …) are refused. Never PHI.
# created_at is stamped by Prisma on the app server per row (ms
precision); same-millisecond rows are ordered by id.
# indexes: (entity_type, entity_id, created_at), (actor_user_id,
created_at), (created_at, id) — the last backs the admin cursor.
There is one audit trail in this schema. Phase 3's referral admin
actions (#68) do not get their own table — they are entity_type = 'Referral' rows in this one, written through the audit module's
record() in the same transaction as the referral change, so the trigger
that makes the trail append-only covers them too.
Transitions recorded (action: from → to):
appointment.booked:null → scheduled(metadata: slot id, doctor profile id, origin). Written by the appointments module for both booking paths — a direct booking and a referral-origin booking (the accept path), which is what theoriginmetadata distinguishes; there is no way to create an appointment that leaves no trail.consultation.closed:null → outcome(metadata: appointment id; never the notes — that's PHI). Written by the consultations module for every consultation write, whichever route addressed it (POST /consultations,POST /appointments/:id/consultation, or the orders flow).doctor.verification_status_changed: previous → new verification status (read underFOR UPDATE; a no-op set records nothing)payment_intent.fundedcreated → escrowed,payment_intent.releasedescrowed → released,payment_intent.refundedescrowed → refunded(metadata: order type, amount, asset code, escrow id, tx hash, contract id — public on-chain references only). The status write is now conditional on the observed state, so a racing second action gets409and records nothing.payment_intent.preparedcreated → awaiting_signature,payment_intent.prepare_expiredawaiting_signature → created(issue #80). Actor is the patient for prepare and the system for expiry; metadata adds the prepared transaction hash.referral.accepted:pending → accepted(metadata: the appointment id the accept booked). Written by the referrals module in the same transaction as the accept, so the patient's decision and the appointment it produced are both recorded or neither is.referral.declined:pending → declined.referral.reassign:null → null— the referral is retargeted, not state-moved, so the transition is carried in the metadata (from/to doctor profile ids + the admin's reason, ≤ 200 chars) rather than in from_state/to_state.referral.cancel: previous status →cancelled(metadata: the admin's reason). The live appointment of an accepted referral is closed in the same transaction (through the appointments service).
Not yet recorded (open gap, not silently dropped): a referral's
creation (null → pending), and the two transitions the appointment
cascade drives — accepted → completed and accepted → expired (the
no-show path) — because the cascade fires from inside the appointment's
status transaction, which has no actor of its own to attribute. The
referral-creation and cascade rows are owed when Phase 4/6 wire the
remaining state changes; until then the trail shows the referral's
history only from acceptance onwards.
PatientExport
id, patient_profile_id (FK -> PatientProfile, cascade — a derived copy)
status (enum: pending|processing|ready|failed|expired)
schema_version (nullable int — bundle format written), attempt (int, fencing token)
started_at, generated_at, expires_at (nullable)
size_bytes, chunk_count (nullable — set on ready)
failure_code, failed_section (nullable — machine-readable only, never an error message)
created_at, updated_at
# Every transition is a conditional UPDATE on the expected status (and
attempt), so two workers can't both generate one job and a stale claim
can't publish over a newer one. One in-flight job per patient (request
locks the PatientProfile row FOR UPDATE).
# The row is kept after expiry (no patient data in it) so status reads
answer "expired" rather than 404.
PatientExportChunk
export_id (FK -> PatientExport, cascade), attempt, seq — composite PK
data (text — one slice of the JSON bundle, ~PATIENT_EXPORT_CHUNK_BYTES)
# Only chunks of the export's current attempt are ever served; the cleanup
job deletes other attempts' and expired/failed exports' chunks.
Bundle format (schemaVersion 1): { format: "docforum.patient-export", schemaVersion, exportId, generatedAt, sections: { <name>: { version, items } } }
— see §3.1 for where sections come from.
EscrowEventCursor has one row per deployed escrow contract and persists the Soroban RPC pagination cursor and retention error. EscrowChainEvent is uniquely keyed by (contractId, eventId) and records the contract event, ledger, transaction hash, escrow id, match state, and alert timestamp. These are observations, not a source of payment state: matching checks the locally owned PaymentIntent; the poller never mutates a payment transition.
AccessLog
id, accessed_by (nullable FK → User, SET NULL on delete — the row keeps
its timestamp and entity while the PII pointer is removed)
patient_profile_id (nullable FK → PatientProfile, CASCADE — the record
owner, whose data was viewed; used by the patient-facing query)
entity_type ('IntakeForm' | 'Consultation' | 'Prescription' |
'PrescriptionItem' | 'LabOrder' | 'LabResult' | 'Referral' |
'PaymentIntent' | 'PatientProfile' | 'Appointment'), entity_id
route (nullable — the HTTP route that returned the payload),
detail (nullable — context string, never PHI)
accessed_at (default now)
# indexes: (accessed_at), (accessed_by), (patient_profile_id,
accessed_at), (entity_type, entity_id).
Answers "who looked at this" — distinct from AuditEvent's "who changed
this" (§4.10). Reads are far higher volume than state changes, so:
- writes go through
auditService.recordAccess(), which is fire-and-forget (never awaited on the read path — a unit test pins sub-50ms return with a hanging insert) and fail-open (a logging failure never fails the read, but is counted and surfaced viaGET /admin/access-logs/statsfor alerting); - rows are retention-managed, not append-only:
pruneOldAccessLogs()(wired into the prune interval insrc/server.ts) deletes rows older thanACCESS_LOG_RETENTION_DAYS(default 365). That delete is the only one in the audit repositories and is bounded to the retention cutoff. Patient-facing query (GET /patients/me/access-log) is scoped server-side to the caller's own profile (resolved from the authenticated user, never from the request) and strips all viewer PII to a role — a deleted viewer surfaces asdeleted_user.
FeatureFlag
key text PK -- dotted, lowercase: 'payments.wallet_funding'
description text
enabled bool -- kill switch: false = off for everyone
rolloutPercent int 0..100 -- sticky hash bucket (see below)
allowRoles Role[] -- empty = no role restriction
allowUserIds text[] -- always on (while enabled), bypasses role + percent
createdAt, updatedAt
One row per flag in each environment's own database, so staging and production
are independent by construction. Evaluation (modules/flags/evaluate.ts) is a
pure function: off if enabled is false; on if the user id is allowlisted;
off if allowRoles is non-empty and excludes the user's role; otherwise on
when sha256(key:userId) bucket (0–99) is below rolloutPercent. The bucket is
a hash, never random, so a user's answer is the same on every request and
raising the percentage never turns anyone off. Evaluation never throws and
defaults to off (unknown flag, malformed row, database down with nothing
cached). Flags are cached in memory for FEATURE_FLAG_CACHE_TTL_SECONDS
(default 15). Changes go only through PATCH /admin/feature-flags/:key, which
writes a feature_flag.created / feature_flag.updated AuditEvent in the
same transaction (counts and the percentage only — never the allowlisted ids).
First user: payments.wallet_funding gates POST /payments/intents/:id/prepare
(ADR 0005: wallet funding launches as an option), seeded off.
PaymentIntent
id, prescription_id / lab_order_id (exactly one typed FK — issue #85)
status (enum: created|awaiting_signature|submitting|escrowed|released|refunded|failed)
patient_profile_id (FK, RESTRICT), facility_id (FK)
amount (bigint — smallest unit of asset_code; never scaled in this repo)
asset_code, token_contract_id (SAC address; token_contract_id is nullable
only for pre-#79 rows, which resolve ESCROW_TOKEN_ID as native XLM)
escrow_id, stellar_tx_hash, soroban_contract_id (nullable — set on fund)
prepared_tx_hash, prepared_xdr, prepared_expiry_ledger, prepared_expires_at,
refund_after (nullable — live only in awaiting_signature; cleared on expiry)
created_at, updated_at
# Every status write is a conditional UPDATE on the observed state, in the
same transaction as its AuditEvent (§4.10). Prepare is
created -> awaiting_signature; expiry is awaiting_signature -> created.
`submitting` is reserved for ADR 0005's submit half (not built). Orders
are priced in XLM today, so issuance writes asset_code='XLM'.
PatientWalletLink
patient_profile_id (unique FK -> PatientProfile, cascade)
stellar_public_key, verified_at
# The PAYER wallet (ADR 0005). Rows are written by the SEP-10 flow, which
is not built yet; prepare requires one and returns 409
WALLET_NOT_LINKED otherwise.
WalletLink
facility_id (unique FK -> FacilityProfile, cascade)
stellar_public_key
# The PAYEE/payout wallet, facility-managed (ADR 0004). Unchanged by #79.
Assets are an environment allowlist (PAYMENT_ASSETS; testnet defaults XLM +
USDC SACs) rather than a fixed enum: an intent in any other asset is
rejected at creation. Before funding (and preparing) an issued asset, the
payee's Horizon account is checked for an authorised trustline
(PAYEE_TRUSTLINE_MISSING); native XLM needs none. Only assets without
clawback/transfer fees/rebasing belong on the list — escrow threat model
T5, enforced as a curated config policy.
- Client requests open slots for a doctor.
- Client attempts to book a specific
AvailabilitySlot→ backend runs this inside a DB transaction with a row-level lock / conditional update (UPDATE ... WHERE status = 'open'), so a second concurrent booking attempt on the same slot fails atomically instead of racing. - On success,
Appointmentis created referencing the now-bookedslot.
- Doctor records a
Consultationagainst their own completed appointment, then creates one or moreReferrals from it (POST /referrals, or the atomic batchPOST /referrals/from-consultation/:id/POST /consultations/:id/referrals) — each target gets its ownsnapshot_notescopy. Multi-referral is a deliberate decision recorded indocs/adr/0006-multiple-referrals-per-consultation.md: independent lifecycles born in one transaction, grouped only for presentation. A target is a named verified doctor or a specialty, and a specialty-only target is routed to a concrete doctor at creation (docs/adr/0008): least-loaded among eligible doctors — verified, holds the specialty, not the referring doctor, active account — where load counts that doctor'spending+acceptedreferrals; ties break on doctor id. Zero eligible doctors is a422 REFERRAL_NO_ELIGIBLE_TARGETand writes nothing, so an unassigned referral cannot exist. - Creation is one transaction: gates (own consultation, completed appointment, no self-referral, target verified [+ in specialty], no duplicate target in this consultation), lineage lookup (
parent_referral_idvia consultation → appointment → referral), then the loop walk (PRD §8.1.2 / issue #65) — a repeat doctor/specialty in the chain flags the referral for the admin queue rather than blocking it; exceeding the hop limit (REFERRAL_MAX_HOPS, default 4) hard-rejects with 422. - Patient must accept (PRD OQ-3):
POST /referrals/:id/acceptdoes a conditionalpending → acceptedUPDATE (exactly one concurrent accept wins) and books a realAppointmentagainst the target doctor through the appointments service — sameclaimOpenSlotpath as a direct booking,origin: 'referral', patient picks the slot, and no re-intake happens because the snapshot travels on the referral. Decline ispending → declined, terminal. - The appointment's own status transition then cascades back into the referral inside the same transaction (issue #67):
completed →referralcompleted;cancelled →referral back topending(slot released, re-bookable, same snapshot);no_show →count++ and back topending, except atNO_SHOW_EXPIRY_THRESHOLD(2) where it becomesexpiredwithreferring_doctor_notified_atset. Every one of these writes is conditional on the referral still beingacceptedand still pointing at that appointment, so a hook firing twice is a no-op. - Admin override (issue #68):
GET /referrals/admin/queue(stuck/aged/loop-flagged, with a derived reason), reassign a pending referral to another verified doctor (target column only — snapshot untouched), cancel apending/acceptedreferral with a reason (closing its live appointment through the appointments service). Reassign and cancel each write anAuditEvent(§4.10), readable atGET /referrals/admin/:id/audit-events. - The "zero verified doctors in that specialty" dead end (PRD edge case 8.2.2) is surfaced at creation: routing finds no eligible doctor, the call fails
422 REFERRAL_NO_ELIGIBLE_TARGET, and the referring doctor is told before the patient is handed anything. What is still not built is the patient-facing "waiting for a specialist" state — that needs a target-less referral to exist, which ADR 0008 deliberately rejects. A routed referral whose doctor never publishes a slot still sitspendinguntil issue #8's time-based expiry or an admin (#68) picks it up.
- Specialist issues
Prescriptionand/orLabOrderfrom aConsultation. - Patient views eligible facilities filtered by
FacilityCapabilitymatching the order — optionally sorted by distance from a patient-supplied point (§5.4). - Patient selects a facility →
FulfillmentRecordcreated (pending) with oneFulfillmentItemper routed item. Splitting a partially dispensed order across a second facility is the same call scoped to the remaining items (issue #73). - Facility updates its own
FulfillmentRecordstatus; for labs, attachesLabResult. An item becomesdispensedonly throughPATCH /pharmacy-orders/fulfillments/:id/items/:itemId, a one-shot conditional update backed by the(orderType, dispensedKey)unique index — a second facility dispensing the same item is a constraint violation (409), even under concurrency. - Order's own
statusfield is updated tofulfilledonly via this facility-confirmed path — patient/doctor cannot self-mark it. Since issue #73 this includespartially_fulfilled, and the write happens as a post-commit derivation inpharmacy-orders/servicesthrough the prescriptions/lab-orders module services (rules 4 and 5): facilities never touchPrescription/LabOrderrows, pharmacy-orders never writes those tables directly, and the update is conditional and forward-only.
Payment release rule under partial fulfillment (issue #73 — decided):
partial fulfillment never releases escrow. Release eligibility requires
the order to derive fulfilled — every item of the order dispensed,
wherever it was routed. A partially_fulfilled order holds the escrow no
matter how many items the dispensing facility already delivered. Rationale:
v1 has one PaymentIntent per order (ADR 0004), so releasing on partial
state would either over-pay a single facility for goods not delivered or
need a proration model that doesn't exist yet. Consequence: a patient who
walks away from the deliberately-un-routed remainder keeps the escrow held
until the admin refund path (POST /payments/intents/:id/refund) resolves
it. Prorated/partial release is an open question, logged in §6.2 — the
rule is "no release until the decided rule is met", and the decided rule
is all items dispensed. (Release itself remains admin-triggered in v1
per ADR 0004; this rule is what makes that action's eligibility check
honest once wired.)
- Patient calls
GET /facilities/eligible?capabilityType=…&near=lat,lng&radiusKm=…. - The repository applies an indexed bounding box prefilter (
(latitude, longitude)composite index) that is provably a superset of the radius circle, then computes exact haversine distance in SQL over those rows and sorts ascending (NULLS LAST— facilities without coordinates stay discoverable, just last). - The patient's
nearpoint never leaves the request: it is only ever a bind parameter in that statement — not stored, not logged, not echoed back (the response carries per-facilitydistanceKmonly). - Why not PostGIS: it needs an extension on every deployment (CI's throwaway DBs, the Render free-tier preview, bare local Postgres) to order one column, which is exactly the ceremony §6.3 warns against; bounding-box + haversine is exact here because the box is a superset. Measured in
backend/tests/integration/facility-distance.test.mts(p50 ≈ 5 ms over ~3 000 facilities).
Slot booking (§5.1) is protected by a conditional update, but nothing else was: a doctor editing their profile in one tab and an admin in another, or two tabs updating the same appointment, silently overwrote each other — last write wins, and the loser never finds out. This is the convention that closes that whole class of bug, and new mutable-resource routes must follow it.
- An individual mutable-resource read returns a strong
ETagcarrying the row's integerversion, e.g.ETag: "4"(also mirrored asversionin the JSON body). Collection reads deliberately do not get one: a client fetches the individual resource before editing. PATCH/PUTsends that exact value back inIf-Match. Missing header →428 Precondition Required(code: 'IF_MATCH_REQUIRED'); weak validator, a validator list, or anything else that isn't one strong"<int>"→400(code: 'INVALID_IF_MATCH') rather than a guess at which member was meant; a version that no longer matches →412 Precondition Failed(code: 'VERSION_CONFLICT').- The repository performs one conditional update —
UPDATE ... WHERE id = ? AND version = ?withversion = version + 1in the same statement — and returns the row that statement wrote. A zero-row result is a stale write. Read-compare-write is forbidden: it reopens the exact race it closes, because two callers can both read version 4 and both pass a check made before the write. In Prisma that isupdateManyAndReturn/updateManywithcount === 1, neverfindUnique→ compare →update. - Everything else keeps working the way it did. Endpoints whose state
transition is already conditional (booking's slot claim, payment
fund/release/refund, doctor verification, token redemption) keep that status
guard and are not additionally
If-Match-gated — two guards on one write produce two different failure modes and the second one teaches clients nothing. They do still incrementversion, and still publish the newETag, so a client can precondition whatever it does next. Every other write to a versioned row increments it too, including system writes (email verification). - Applies to
User,PatientProfile,DoctorProfile,FacilityProfile,FacilityCapability,AppointmentandPaymentIntent(columns documented in §4.1/§4.2). Append-onlyPrescription/LabOrderare excluded by design — a correction is a new row (supersedes_*), so there is nothing to lose. - Shared pieces live in
backend/src/middleware/optimistic-concurrency.ts(requireIfMatch,expectedVersion,setVersionEtag,versionConflict) so every module answers with the same status, message and code. Proof:backend/tests/integration/optimistic-concurrency.test.mts, including eight simultaneous writes on one version where exactly one succeeds.
- Naive slot booking without the DB-level conditional update in §5.1 — the most likely first bug filed against this repo. Resolved 2026-09-14: implemented exactly as designed in §5.1 — see
backend/src/modules/availability/repositories/index.ts(claimOpenSlot) andbackend/src/modules/appointments/services/index.ts(bookSlot, the transaction wrapping it). Integration-tested with real concurrency inbackend/tests/integration/booking.test.mts. Referral.target_doctor_idleft nullable with no fallback assignment logic — referrals to "a specialty" with no specific doctor need a real routing rule (round robin? load-based?) or they'll pile up unassigned. Resolved 2026-10-21 (Phase 3, issue #6 —docs/adr/0008-referral-routing-without-target-doctor.md): the column stays required and the fallback logic now exists at creation — a specialty-only target is routed to the least-loaded eligible doctor (verified, holds the specialty, not the referring doctor, active account; fewestpending+acceptedreferrals, ties by doctor id) inside the creation transaction. Zero eligible doctors fails the call with422 REFERRAL_NO_ELIGIBLE_TARGETinstead of writing an unowned row, so the pile-up this item predicts remains impossible. Still deliberately absent: a patient-facing "waiting for a specialist" state (it requires a nullable target), availability-aware routing (needs doctor-side availability/discovery), and a per-doctor "accepting referrals" switch — all deferred in the ADR with revisit triggers.- Polymorphic
FulfillmentRecord.order_id(§4.7) — polymorphic FKs are a known footgun for referential integrity in Postgres. Needs either two separate tables (PrescriptionFulfillment,LabOrderFulfillment) or a check-constraint + application-level guard. Current schema sketch is a simplification that should be revisited before implementation, not treated as final. Resolved 2026-10-22 (Phase 5, issue #13 —docs/adr/0012-fulfillment-record-typed-order-fks.md): neither option exactly as posed —FulfillmentRecordstays one table, but the(order_type, order_id)pair is gone. It is replaced by two real foreign keys (prescription_id,lab_order_id, eachUNIQUE) with a PostgreSQLCHECKrequiring exactly one, plus thefacility_idFK the sketch left as a TODO. Referential integrity is now the database's job, and the CHECK only chooses between two already-validated columns — which is what anorder_type+order_idguard could never do, since Postgres cannot make a bareTEXTconditionally reference one of two tables. Keeping one table (rather than splitting) is what keeps §5.3 step 5 a single transition implementation and the facility's assigned-orders queue a single indexed query; the discriminator mechanism is the onePaymentIntentalready uses (issue #85). Deferred by that decision rather than invented around: re-routing an order to a different facility after arejectedfulfillment would need a partial unique index, so in v1 arejectedrow is terminal for that order — a corrected order is a newsupersedes_*_idrow and gets its own fulfillment. Issue #73'sFulfillmentItemrows hang off this same single table (fulfillment_record_idFK), so item-level fulfillment inherits the ADR 0012 shape instead of re-opening it — onlyFulfillmentItem.order_type/order_item_idkeep a discriminator, and those name an item inside the record's own order, not a second polymorphic reference.4. JWT refresh rotation without a revocation list — a stolen refresh token has no clean kill switch until this is added. - Discovered 2026-10-15 while building email verification (Phase 8, issue #34): the email/SMS provider is still undecided (Phase 6, issue #17), so verification links can't actually be delivered in a hosted environment yet —
backend/src/modules/auth/services/email-dispatcher.tsis a no-op seam until a provider is integrated. Until thenbackend/scripts/mark-email-verified.mjsis the documented out-of-band path for an operator with DB access. Not a reason to skip the guard: the guard is what makes the flow safe once delivery exists, and shipping it locked-off would just mean shipping it untested.
+6. Resolved 2026-10-19 (issue #49): silent last-write-wins on every mutable record that wasn't the slot claim — a doctor in one tab and an admin in another, or two tabs editing one appointment, each wrote and the second erased the first with no signal to either. §5.4 now makes the compare and the write one conditional statement behind an If-Match precondition. Still open: the convention is only enforced where a route opts in, so a future mutable route that forgets requireIfMatch silently opts out — there is no lint or schema-level way to catch that yet, and ESLint itself is still untracked (issue #28).
- Discovered 2026-10-17 (issue #41): after
DELETE /patients/me, the caller's access token (stateless JWT, 15-minute TTL) still passesauthenticateuntil it expires — there's no revocation list (item 4 above, issue #18). It can't reach anything: every patient route resolves the caller throughgetMyPatientProfile, which refuses an anonymised profile; booking/payments refuse it viarequireVerifiedEmail(verification is cleared); refresh refusesdeleted_at. Closing the window itself is #18's job. Also: Postgres reportsON DELETE RESTRICTas SQLSTATE 23001, which Prisma surfaces as an unknown request error, notP2003—patients/servicesisForeignKeyRefusalhandles both;middleware/error-handler.tscurrently maps neither to a 4xx.
-
What happens to
AvailabilitySlot.status = 'held'if a booking transaction fails/times out mid-flight — is there a TTL to release the hold? (Needed or slots leak into permanent limbo.) -
Referral
expiredstatus exists in the enum but no expiry job is designed yet — orphaned referrals (PRD 8.1.3 sibling problem). Partly addressed 2026-10-17 (Phase 3, issue #67): repeated no-shows now drive a referral toexpired(NO_SHOW_EXPIRY_THRESHOLD = 2, withreferring_doctor_notified_atset as the seam Phase 6's notification layer will read). What is still missing is time-based expiry — a pending referral nobody ever accepts, or one whose target doctor never offers a slot, has no TTL and no worker. Tracked as issue #8; per §6.3.2 it should ship as a simple interval/cron, not a queue. -
No schema support yet for a doctor working across multiple facilities/locations (PRD edge case 6) —
AvailabilitySlotcurrently assumes one calendar per doctor. -
No audit-log tableFoundation resolved 2026-10-17 (issue #43):AuditEvent(§4.10) +audit.record()in the same transaction as the change, append-only at the DB level, wired into booking (both booking paths), doctor verification, payment fund/release/refund, the patient's referral accept/decline and the referral admin actions (referral.reassign,referral.cancel— Phase 3, issue #68, written asentity_type = 'Referral'rows in the same table). Still open: the referral's creation and the cascade-drivencompleted/expiredtransitions (no actor to attribute them to yet), the remaining Phase 4 transitions (prescriptions, lab orders, fulfillment), the admin login-throttle clear (/auth/login-attempts/clear) still only logs to the console; the app connects as the database owner, which could disable the triggers — enforcing append-only against the owner needs a separate least-privilege DB role (deployment work, issue #22). PRD §7 auditability is therefore "a real mechanism exists" rather than "auditability is done"; remaining coverage stays an open gap. -
CSRF on the cookie-authenticated routes — the refresh cookie has to be
SameSite=None; Secure(the Pages frontend is a different site — seedocs/adr/0003), which means any site the patient visits can make their browser send it to/auth/refreshand/auth/logout. Resolved 2026-10-15 (issue #37): a per-session CSRF token is bound into the refresh token and required in theX-CSRF-Tokenheader, theOriginheader is validated against an explicit allowlist, and CORS is now a per-environment allowlist (CORS_ORIGINS) instead of one configured string. The double-submit cookie pattern — the usual answer — is not usable here: a cookie set by this API is a third-party cookie todocforum.github.io, so the frontend's JavaScript can never read it; the token travels in the login/refresh response body instead. A request with noOrigin(curl, server-to-server) is allowed through the Origin check but still needs the token. Seebackend/src/middleware/csrf.ts, anddocs/api/README.mdfor what the client must send. Consequence: refresh cookies issued before this change carry nocsrfclaim and are rejected, i.e. existing sessions need one re-login. -
Added 2026-10-19 (issue #48): an
IdempotencyKeyrow caches a copy of a response body for 24 hours (§4.12). Today every opted-in route returns non-PHI data (an appointment id and status, a payment intent), so this is transport bookkeeping and not a patient-data surface — it is not part of the patient-export bundle (§3.1) and no endpoint lists it. The condition that would change that: if a route returning clinical free text (consultation notes, intake payloads, lab results) ever opts in, each row becomes a temporary PHI copy subject to right-of-access and retention, and the export registry in §3.1 has to gain a section for it before that route is allowed to opt in. Until then, adding a route to the opted-in list is a deliberate decision, not a default. -
Discovered 2026-10-24 (issue #16): lab result files have two read gaps. (a) The right-of-access export (§4.11) lists the
LabResultrow, but the file bytes are not in the bundle; the patient downloads them separately throughGET /orders/lab-orders/:labOrderId/result. (b) Read access mirrors consultation notes exactly: the patient's own account, the ordering doctor, and the fulfilling facility. So an active guardian of a dependent (ADR 0011) can't read a dependent's result, and neither can a doctor further along the referral thread. Widening either needs a deliberate decision, not a quiet one.
- Polymorphic fulfillment design (§4.7) may itself be premature abstraction — two small, boring, separate tables might beat one clever polymorphic one at this stage. Flagged as a design smell to revisit, not shipped as-is. Resolved 2026-10-22 (issue #13,
docs/adr/0012-fulfillment-record-typed-order-fks.md): the polymorphism was the premature part and it is gone. Splitting into two tables was weighed and rejected — it would give the §5.3 step-5 transition two implementations and the facility queue a cross-table union, i.e. more ceremony than the single typed-FK table, not less. - BullMQ/Redis job queue before there's a single job that needs it — fine to stub with a simple cron/interval check initially and introduce a real queue when reminder/expiry logic actually exists.
- Designing for horizontal service extraction (module boundary purity, §3) before there's a single reason to split — worth doing lightly (it's cheap discipline), but not worth extra ceremony (e.g., separate repos, separate CI, internal API versioning) yet.
- Added 2026-10-17 (issue #40): export bundles are stored as text chunks in Postgres (
PatientExportChunk, §4.11), not in object storage as §2's file-storage row prescribes — no object-storage provider is integrated yet, and a filesystem store would break on Render's ephemeral disk and on any second instance. The chunks are read/written only bypatients/repositories, are short-lived (PATIENT_EXPORT_TTL_HOURS, default 24) and are deleted by the cleanup job, so moving to S3 later swaps one storage seam rather than the design. Revisit when lab-result storage (Phase 5, issue #16) picks a provider. Update 2026-10-24 (issue #16): the provider is picked (Amazon S3,docs/adr/0014) andutils/object-storage.tsexists. Export chunks deliberately stay in Postgres for now: moving them is a separate change, and this item stays open until it is made.
- Testing: unit tests per module (
backend/tests/unit), integration tests for booking/referral/order flows specifically because those are the correctness-critical paths (backend/tests/integration). - Security — login throttling (Phase 8, issue #35):
POST /auth/loginis throttled per normalised email and per client IP, in Postgres (LoginAttempt, §4.9): exponential backoff withRetry-AfterfromLOGIN_THROTTLE_BACKOFF_AFTERfailures, a temporary lock atLOGIN_THROTTLE_LOCK_AFTER, and a cleanup interval insrc/server.ts. Two things this depends on operationally: (1)TRUST_PROXYmust be set correctly in production — Render terminates TLS in front of exactly one hop, soTRUST_PROXY=1; leaving it unset makes every request look like Render's proxy (one shared bucket for everyone), andtruelets a client spoofX-Forwarded-Forfor a fresh bucket per request; (2) the throttle deliberately does not reveal whether an account exists — locked, backoff and unknown-account responses are identical, and an absent account still pays the bcrypt comparison cost so response timing doesn't leak either. - Idempotent retries (Phase 8, issue #48):
POST /appointments,POST /payments/intentsandPOST /payments/intents/:id/{fund,release,refund}honour anIdempotency-Keyheader (src/middleware/idempotency.ts). A repeat of the same key with the same request body inside 24 h replays the stored status and body instead of running the mutation again; the same key with a different body is422; a second request arriving while the first is still running is409 IN_PROGRESSwith aRetry-After, because ownership is decided by the unique constraint on(userId, key)rather than by an in-memory lock that would be correct on one instance and broken on two. Keys are namespaced per user, and 5xx is never cached so a client can retry a failed call with the same key. See §4.12 for the model. - Migrations: Prisma migrations are the only way schema changes happen — no manual DB edits, ever.
- Environments: local (Docker Compose), staging, production — staging/production infra choice is an open item (see ROADMAP).
- Deployment target (Fly.io / Render / AWS / etc.) — still undecided for
production. A Render free-tier instance exists as a preview only
(so
docforum-web's GitHub Pages preview has a live backend to call) — seedocs/adr/0003-render-preview-deployment.md. Explicitly not this decision: no secrets manager, no backups, disposable data, 30-day free-tier DB expiry. - Notification provider — resolved (Phase 6, issue #17): Postmark for
email and Twilio for SMS, behind the provider-agnostic
notificationsinterface (see §2). Provider SDK calls must stay inside the dispatch adapter, never in business logic. - Video/voice provider — undecided, PRD OQ-1 must resolve first.
- Multi-region / i18n — not designed for, not blocked either.