Status: accepted · Date: 2026-10-31 · Related: ADR-0009 (RLS and tenant isolation), ADR-0011 (feature shape), ADR-0015 (care roles, can()), ADR-0028 (privilege matrix)
Enforcement: packages/api/src/platform/clinical/clinical-procedure.test.ts (the six refusals, direct call()), packages/api/src/platform/clinical/gate.test.ts (each check in isolation), packages/api/src/platform/clinical/router-guard.test.ts (every procedure of a Wundklar clinical feature carries the CLINICAL_METADATA symbol), packages/api/src/platform/clinical/access-state.test.ts (plan §1.5.1), packages/api/src/platform/clinical/redact.test.ts (US-06).
Spec gate 1 (docs/wundklar/SPEC_GATE_1.md) found two independent gaps that both reduce to the same cause: clinical authorization was spread across procedures, sync handlers, key delivery and object downloads, and the AVV / patient-scope / access-state checks ran only on create. A read, a sync pull, a signed GET or a report job could therefore return patient data for an Organization that had not accepted the AVV, in read_only state, or for a nurse looking at a patient outside her tour.
This ADR records the single gate every clinical path goes through from v1 onward.
Decision
One builder, clinicalProcedure, builds every clinical procedure. It is orgProcedure + a six-step gate in the order of plan §1.3.1:
| # | Check | Error on failure |
|---|---|---|
| 1 | Session + membership (orgProcedure) | UNAUTHORIZED / FORBIDDEN |
| 2 | Session assurance ≥ policy (requireStrongAuth default true; strong or a currently valid, bound device attestation satisfies) | ASSURANCE_REQUIRED |
| 3 | AVV accepted at least once (RN-04) | AVV_REQUIRED |
| 4 | Access state (§1.5.1); a write recorded before the trial/cancellation cutover is accepted for 48 h; closed always refuses | ACCESS_READ_ONLY / ACCESS_EXPORT_ONLY |
| 5 | Device (x-wundklar-device) names an active device of this User in this Organization — only when the procedure declares needsDevice: true | DEVICE_WIPED / DEVICE_PENDING_ACTIVATION / DEVICE_NOT_ENROLLED |
| 6 | Role permission via can(role, permission, policy) from team.rules | ROLE_NOT_PERMITTED |
| 7 | Patient scope: owner/admin/wound_expert see all; nurse/aide only patients on tours for date ± 1 or whom they wrote in the last 7 days | PATIENT_OUT_OF_SCOPE |
Three sister helpers reuse the same gate for the paths that do not go through clinicalProcedure:
clinicalMutationAccess(state, mutation)— the sync dispatcher (ticket 21) calls it per mutation of async.pushbatch; the kind descriptor supplies permission/mode/needsDevice.clinicalObjectAccess(state, object)— upload staging (ticket 22) and signed GETs;needsDevicedefaults to true because objects always carry ciphertext.clinicalJobAccess(systemDb, { organizationId, mode, reason })— background jobs; narrower (no session → no assurance / scope / device), checks only AVV + access state, and takes a namedSystemReasonsocheck-system-call-sites.mjsstill catches the connection-role path.
redactOutOfScope nulls identifying ciphertext (name_ct, address_ct, contact name_ct / phone_ct) for out-of-scope rows before anything leaves the boundary. Every enrolled device holds the Organization data key, so hiding fields in the UI alone is not enforcement.
Introspection, not naming. Every chain clinicalProcedure(...) returns is wrapped in a Proxy that stamps CLINICAL_METADATA on every object the chain returns, including the final procedure after .handler(...). A router guard test walks appRouter, detects the outer procedure through procedure["~orpc"].handler, and reads the symbol from that outer procedure. Coverage includes orgSettings, objects, and shareLinks. Exact non-clinical exceptions carry a reason: orgSettings.get bootstraps access/AVV state, and devices.challenge and devices.attest establish device assurance. New public share-link or admin exceptions require an explicit entry once their boundary exists. A feature that opens a clinical procedure built from plain orgProcedure fails that test — on the next CI run, in that feature's PR, not later.
expectClinicalRefusals(callProcedure, input) hard-asserts all six refusals. A successful response or a different refusal code fails the test. Probes use a strong session, a wiped device, a closed Organization, an empty patient scope, and an unknown role to isolate each check. A procedure that cannot express a probe passes unavailable: { device: "reason" } (or the corresponding axis) on that call. Empty reasons fail. Export procedures pass accessCode: "ACCESS_EXPORT_ONLY" for the closed-state probe.
How the gate reads its state
loadClinicalMiddleware runs once per request. It parallelises:
care_service(access state + transition timestamps),avv_acceptance(presence),org_policylatest version (requireStrongAuth),session_assurancefor the current session and User, including the attested device, Organization, and expiry,devicerow forx-wundklar-device(only when the header is sent),visit_schedulefor the nurse's tour on the current Europe/Berlin date ± 1,- authored clinical records for nurse/aide scope, from Berlin midnight seven calendar days before today through
now. Future timestamps never grant scope. Sources are patient creation, wounds, classification, risks, consent and revocation, visits, photos, measurements, assessments, reviews, plans and decisions, prescriptions and requests, stock movements, reminders, reports, share links, deliveries, and conflict records. Indirect wound/assessment joins include Organization equality. Rows without author metadata and access logs do not establish authorship.
All reads run as app_tenant through context.db: RLS already narrows every row to the Active Organization. session_assurance has a tenant SELECT policy on ownedByCurrentUser(user_id), so the tenant handle sees it. Tests attach an override through makeContext under a private Symbol; requests cannot supply it.
Device assurance requires an unexpired, non-null expires_at, the current session and User, the Active Organization, and a device_id equal to x-wundklar-device. The reader discards assurance when that binding fails. The gate checks the device's current enrolment status even when the procedure does not otherwise require a device. Revoking or wiping the attested device cannot be bypassed by naming another active device. Strong ceremonies may have a null expiry because the authenticated Session bounds their lifetime.
The 48-hour grace
A nurse records a visit offline on Tuesday at 09:00. The Organization transitions to read_only on Tuesday at 23:00 (end-of-trial billing cron). She syncs on Wednesday at 10:00. Her mutation's occurredAt is before the transition, and the transition is less than 48 h old, so the gate accepts the write. A later sync of a mutation whose occurredAt is after the transition is refused with ACCESS_READ_ONLY. The grace window preserves work recorded while writes were allowed. It applies only to read_only after trial_ends_at or export_only after cancel_effective_at, only when the transition is between zero and 48 hours old, and only for occurredAt inside the verified continuous write-enabled interval ending at that cutover. A later transition to closed grants no new grace, including for writes recorded during the preceding restricted period.
The pure gate requires writeEnabledSince as the start of that verified interval. Every access-state transition is appended to care_service_access_event (provisioning writes trial, the platform admin's activation active, the daily job read_only/export_only/closed at the cutover it applies, not the tick time). writeEnabledSinceOf in access-state.ts walks back from the cutover over the unbroken run of trial/active events and returns its start; a restricted event or an empty history before the cutover returns null and refuses grace. So read_only → active → export_only grants grace only to work recorded after the reactivation. Rows that existed before the history table were backfilled only where the columns prove it (trial at trial_started_at, read_only at trial_ends_at); paid states without evidence keep failing closed. updated_at is not evidence of activation.
Where the metadata symbol lives
CLINICAL_METADATA = Symbol.for("wundklar.clinical.v1") is a global registry symbol. A duplicate symbol (two bundles, two copies of this module) still compares equal, so the router-guard test survives a code-split. The metadata itself is a flat record: { permission, mode, needsDevice }. It is read for introspection only — the runtime path uses the state the middleware produces.
Consequences
- A later ticket cannot add a clinical procedure without going through this builder: the router guard would catch it, and
expectClinicalRefusalswould be its first test. - The sync dispatcher and job gates share the gate; a change in policy (say,
requireStrongAuth = false) flows into every path on the next request without a per-feature edit. redactOutOfScopekeeps the pseudonymous rows a nurse must see (status, display number, timestamps) while nulling the ciphertext she must not. The UI never has to make its own judgement.- The 48 h grace is enforced by the application boundary. RLS independently enforces tenant and author constraints.
team.rulesimports fromplatform/clinical: the lint rule D5 (platform does not depend on features) is relaxed on one line ingate.tswith a disable comment. The import was always intended: team.rules.ts's header names the clinical boundary as its sole non-feature caller.
Alternatives rejected
- Re-use oRPC middleware per feature. The old design was already this: each feature chose which gates to apply, and three independent gaps opened. A feature forgetting a middleware is a silent regression; a feature forgetting to use
clinicalProcedureis a router-guard test failure. - Enforce in RLS alone.
requireStrongAuth, patient scope and AVV live outside the row and would require per-row joins againstsession_assurance,avv_acceptanceand the nurse'svisit_schedule. Even if they could be joined into every policy, the gate's errors (ASSURANCE_REQUIREDwith the required factor,ACCESS_READ_ONLYwith the grace date) carry data a WHERE clause cannot return. - Query data per check inside its middleware. Would multiply round-trips. The single loader fires everything in parallel once per request.
- A big enum of permissions on
clinicalProcedure. The permission is already an enum (CarePermission, team.rules); re-defining it here would double-maintain. The gate just forwards it tocan().
Hardening verification (ticket 17b)
reader.test.ts exercises the real tenant handle on local PostgreSQL: device/session/Organization/expiry binding, revoked and wiped devices, the October 31 23:30 UTC tour window, and every authorship source at the Berlin midnight cutoff across DST. Other authors and Organizations never grant scope. testing.test.ts proves that callbacks omitting device, access, scope, or role checks fail the refusal helper, including a callback enforcing only assurance and AVV. router-guard.test.ts uses a non-empty real oRPC router with one protected and one unprotected procedure. access-state.test.ts covers active, read-only, and closed transitions.