Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ User
Backfilled to created_at for pre-existing rows in the
add_email_verification migration, so accounts from before this phase
are treated as verified)
version (int, default 1 — Phase 8 issue #49; optimistic-concurrency
counter, see §5.4. Bumped by every write to the account, including
email verification)
created_at, updated_at

EmailVerificationToken (Phase 8 issue #34)
Expand All @@ -117,9 +120,12 @@ EmailVerificationToken (Phase 8 issue #34)

PatientProfile (1:1 User)
id, user_id, full_name, date_of_birth, guardian_user_id (nullable — see PRD edge case: minors)
version (int, default 1 — issue #49, §5.4)

DoctorProfile (1:1 User)
id, user_id, full_name, verification_status (enum: pending|verified|rejected)
version (int, default 1 — issue #49, §5.4. Bumped by the admin
verification transition too)
specialties: DoctorSpecialty[]

Specialty (controlled vocabulary, admin-managed)
Expand All @@ -129,24 +135,41 @@ DoctorSpecialty (join table)
id, doctor_profile_id, specialty_id

FacilityProfile (1:1 User)
id, user_id, name, type (enum: lab|pharmacy), capabilities: FacilityCapability[]
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.

### 4.2 Availability & Appointments
```
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)
intake_id (FK -> IntakeForm)
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

IntakeForm
Expand Down Expand Up @@ -312,6 +335,48 @@ schemaVersion, exportId, generatedAt, sections: { <name>: { version, items } } }
4. Facility updates its own `FulfillmentRecord` status; for labs, attaches `LabResult`.
5. Order's own `status` field is updated to `fulfilled` only via this facility-confirmed path — patient/doctor cannot self-mark it.

### 5.4 Mutable-record optimistic concurrency (Phase 8, issue #49)
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.**

1. An individual mutable-resource read returns a strong `ETag` carrying the row's
integer `version`, e.g. `ETag: "4"` (also mirrored as `version` in the JSON
body). Collection reads deliberately do not get one: a client fetches the
individual resource before editing.
2. `PATCH`/`PUT` sends that exact value back in `If-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'`).
3. The repository performs **one** conditional update — `UPDATE ... WHERE id = ?
AND version = ?` with `version = version + 1` in 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 is `updateManyAndReturn` / `updateMany` with
`count === 1`, never `findUnique` → compare → `update`.
4. 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 increment `version`, and still publish the new `ETag`,
so a client can precondition whatever it does next. Every other write to a
versioned row increments it too, including system writes (email verification).
5. Applies to `User`, `PatientProfile`, `DoctorProfile`, `FacilityProfile`,
`FacilityCapability`, `Appointment` and `PaymentIntent` (columns documented in
§4.1/§4.2). Append-only `Prescription`/`LabOrder` are excluded by design —
a correction is a new row (`supersedes_*`), so there is nothing to lose.
6. 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.

## 6. Hard questions (architecture-level — see PRD §8 for product-level)

### 6.1 What do we think would break?
Expand All @@ -320,6 +385,7 @@ schemaVersion, exportId, generatedAt, sections: { <name>: { version, items } } }
3. 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.
4. JWT refresh rotation without a revocation list — a stolen refresh token has no clean kill switch until this is added.
5. **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.ts` is a no-op seam until a provider is integrated. Until then `backend/scripts/mark-email-verified.mjs` is 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).

### 6.2 What edge cases are we missing (architecture-level)?
1. 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.)
Expand Down
3 changes: 3 additions & 0 deletions ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
4. **Cross-module access only through a module's service interface.** Never import another module's `repositories/*` directly.
5. **Facilities write to their own `FulfillmentRecord` only** — never directly to `Prescription.status` / `LabOrder.status`.
6. Every module = `routes/ controllers/ services/ repositories/`. Keep that shape.
7. **Mutable-record edits use optimistic concurrency (Phase 8, issue #49).** Individual-resource read returns a strong version `ETag` (`"4"`); PATCH/PUT must send it in `If-Match` — missing is `428`, weak/list/garbage is `400`, stale is `412`. Write it as ONE conditional `UPDATE ... WHERE id = ? AND version = ?` that increments `version` (Prisma: `updateManyAndReturn`); read-compare-write is forbidden. Status transitions that are *already* conditional (booking, payment status, verification) keep their own guard, don't get a second `If-Match` one, but still bump + republish the version. Append-only `Prescription`/`LabOrder` are excluded. See ARCHITECTURE.md §5.4.

## Data model map (see ARCHITECTURE.md §4 for full field lists)
`User` → `PatientProfile | DoctorProfile | FacilityProfile`
Expand All @@ -31,6 +32,8 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
`PatientProfile` → `PatientExport` → `PatientExportChunk` (Phase 8, issue #40 — right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in `src/export-sections.ts` — see ARCHITECTURE.md §3.1).
`FacilityProfile` → `WalletLink` (payout address) ; `PatientProfile`/`FacilityProfile` → `PaymentIntent` (opaque `orderType`/`orderId` — see docs/adr/0004)

Mutable records above (plus `Appointment` and `PaymentIntent`) carry a `version` int for the `ETag`/`If-Match` convention — hard rule 7, ARCHITECTURE.md §5.4. Mutable profile fields a new route exposes need a conditional write, not just a `.update()`.

## Payments (Phase 5.5 — see docs/adr/0004-custodial-payments-v1.md)
- **Custodial v1**: `docforum-core` holds its own Stellar payer + releaser identities (`PLATFORM_PAYER_SECRET`/`PLATFORM_RELEASER_SECRET` env vars). Patients never hold or sign with a wallet — the only reason this is possible without violating `docforum-web`'s "no direct Stellar calls" hard rule.
- `WalletLink` is scoped to the **facility** (the on-chain payee), not the patient.
Expand Down
21 changes: 21 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ for status and issue links.
- [ ] JWT refresh revocation list (ARCHITECTURE.md §6.1.4). Tracked as [issue #18](https://github.com/DocForum/docforum-core/issues/18) (Medium, 150 pts) — unblocked now, operates on Phase 1's auth module.
- [ ] Held-slot TTL/release job (ARCHITECTURE.md §6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) — unblocked now, operates on Phase 1's `AvailabilitySlot`.
- [x] Access-control test suite: who may read intake, consultation and payment data. Integration tests (`tests/integration/access-control.test.mts`) establishing the test harness and access-control patterns for the three PHI data categories; intake/consultation test cases are scaffolded as placeholders until Phase 2/3 routes exist, payment-intent access cases exercise the live admin-gated routes. Tracked as [issue #44](https://github.com/DocForum/docforum-core/issues/44) — **Done**.
- [ ] Optimistic concurrency (`version` + `If-Match`) for mutable records. A `version` int on `User`, `PatientProfile`, `DoctorProfile`, `FacilityProfile`, `FacilityCapability`, `Appointment` and `PaymentIntent`; individual reads publish it as a strong `ETag`; `PATCH`/`PUT` require that exact `If-Match` (`428` missing, `400` weak/list/garbage, `412` stale); writes are a single conditional `UPDATE ... WHERE id = ? AND version = ?` that increments, never read-compare-write. Transitions that are already conditional (booking, payment status, verification) keep their own guard and are **not** given a second one. Append-only `Prescription`/`LabOrder` excluded. Convention in ARCHITECTURE.md §5.4. Tracked as [issue #49](https://github.com/DocForum/docforum-core/issues/49) (High, 200 pts) — **in review**, branch `feat/optimistic-concurrency-version-if-match-for`. Foundation only: the one existing editable resource (`DoctorProfile`, via `PATCH /doctors/me`) is wired end-to-end as the reference implementation; every new mutable-resource route in Phases 2–5 must adopt the convention.
- [ ] Privacy access-control audit: confirm intake/consultation notes are visible only per PRD §7 (patient, treating doctor(s) in-thread, fulfilling facility for that specific order only) — write tests, not just code review. Still blocked — meaningfully auditable only once Phase 3/4/5 access paths exist. Not yet scoped into an issue.
- [ ] Decide and document what's explicitly out of scope for v1 launch vs. genuinely blocking (e.g., drug-interaction checking, PRD §8.2.8 — needs an explicit go/no-go, not silence). Tracked as [issue #20](https://github.com/DocForum/docforum-core/issues/20) (Trivial, 100 pts) — a decision task, unblocked now.

Expand Down Expand Up @@ -405,3 +406,23 @@ for status and issue links.
observed state, so a racing second action gets 409 instead of silently
overwriting). Rollback coupling proven with a real injected DB failure,
not a mock. Phase 3/4 wiring explicitly left pending.
- 2026-10-19 — Issue #49 (optimistic concurrency) opened as a PR: a `version`
int on the seven mutable models (`User`, `PatientProfile`, `DoctorProfile`,
`FacilityProfile`, `FacilityCapability`, `Appointment`, `PaymentIntent`),
individual-resource reads publishing it as a strong `ETag`, and `PATCH`/
`PUT` requiring that exact `If-Match` — `428` missing, `400` for a weak
validator or a list, `412` stale. The write is one conditional
`UPDATE ... WHERE id = ? AND version = ?` with the increment in the same
statement (Prisma `updateManyAndReturn`, so the response body is the row
that statement wrote), which is what makes eight simultaneous edits on one
version resolve to exactly one winner in the integration test. Transitions
that are already conditional — booking's slot claim, payment
fund/release/refund, doctor verification — deliberately keep only their own
status guard instead of gaining a second, unrelated one; they do bump and
republish the version. Append-only `Prescription`/`LabOrder` are excluded by
design, and the test asserts the exact set of tables carrying a `version` so
it can't quietly widen. `PATCH /doctors/me` is the reference implementation
of the convention (ARCHITECTURE.md §5.4) — there was no existing
patient-profile or appointment edit route to convert, so later phases'
mutable routes adopt the convention as they're built rather than being
retrofitted here.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- Optimistic concurrency for mutable records (Phase 8, issue #49).
--
-- One integer per row, defaulting to 1 so existing rows are valid at their
-- current state without a backfill. It is the storage half of the ETag/If-Match
-- convention in ARCHITECTURE.md §5.4 and is meaningless on its own: nothing may
-- compare-then-write, the compare has to live in the UPDATE's WHERE clause.
--
-- Deliberately NOT added to Prescription/LabOrder (append-only — a correction is
-- a new row, so there is no update to lose) or to immutable tables
-- (AvailabilitySlot, Specialty, WalletLink, AuditEvent, the export tables).

-- AlterTable
ALTER TABLE "User" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;

-- AlterTable
ALTER TABLE "PatientProfile" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;

-- AlterTable
ALTER TABLE "DoctorProfile" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;

-- AlterTable
ALTER TABLE "FacilityProfile" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;

-- AlterTable
ALTER TABLE "FacilityCapability" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;

-- AlterTable
ALTER TABLE "Appointment" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;

-- AlterTable
ALTER TABLE "PaymentIntent" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
7 changes: 7 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ enum Urgency {

model User {
id String @id @default(uuid())
version Int @default(1)
email String @unique
phone String?
passwordHash String
Expand Down Expand Up @@ -169,6 +170,7 @@ model LoginAttempt {

model PatientProfile {
id String @id @default(uuid())
version Int @default(1)
userId String @unique
fullName String
dateOfBirth DateTime?
Expand Down Expand Up @@ -255,6 +257,7 @@ model PatientExportChunk {

model DoctorProfile {
id String @id @default(uuid())
version Int @default(1)
userId String @unique
fullName String
verificationStatus VerificationStatus @default(pending)
Expand Down Expand Up @@ -290,6 +293,7 @@ model DoctorSpecialty {

model FacilityProfile {
id String @id @default(uuid())
version Int @default(1)
userId String @unique
name String
type FacilityType
Expand All @@ -304,6 +308,7 @@ model FacilityProfile {

model FacilityCapability {
id String @id @default(uuid())
version Int @default(1)
facilityProfileId String
capabilityType CapabilityType
// e.g. a lab test category, or a medication class — free text on purpose,
Expand Down Expand Up @@ -339,6 +344,7 @@ model AvailabilitySlot {

model Appointment {
id String @id @default(uuid())
version Int @default(1)
patientProfileId String
doctorProfileId String
availabilitySlotId String @unique
Expand Down Expand Up @@ -475,6 +481,7 @@ enum OrderTypeForPayment {

model PaymentIntent {
id String @id @default(uuid())
version Int @default(1)
orderType OrderTypeForPayment
// Opaque — Prescription/LabOrder don't exist as real records yet
// (Phase 4). Not validated against anything until then.
Expand Down
Loading
Loading