# One clinical authorization boundary

The repository's architectural decision, rationale and consequences.

# One clinical authorization boundary

**Status:** accepted · **Date:** 2026-10-31 · **Related:** [ADR-0009](0009-row-level-security.md) (RLS and tenant isolation), [ADR-0011](0011-backend-feature-shape.md) (feature shape), [ADR-0015](0015-care-service-roles-on-better-auth-organizations.md) (care roles, `can()`), [ADR-0028](0028-immutable-clinical-records-and-privilege-matrix.md) (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 [#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 a `sync.push` batch; the kind descriptor supplies permission/mode/needsDevice.
- `clinicalObjectAccess(state, object)` — upload staging (ticket 22) and signed GETs; `needsDevice` defaults 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 named `SystemReason` so `check-system-call-sites.mjs` still 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 [#how-the-gate-reads-its-state]

`loadClinicalMiddleware` runs once per request. It parallelises:

- `care_service` (access state + transition timestamps),
- `avv_acceptance` (presence),
- `org_policy` latest version (`requireStrongAuth`),
- `session_assurance` for the current session and User, including the attested device, Organization, and expiry,
- `device` row for `x-wundklar-device` (only when the header is sent),
- `visit_schedule` for 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 [#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 [#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 [#consequences]

- A later ticket cannot add a clinical procedure without going through this builder: the router guard would catch it, and `expectClinicalRefusals` would 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.
- `redactOutOfScope` keeps 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.rules` imports from `platform/clinical`: the lint rule D5 (platform does not depend on features) is relaxed on one line in `gate.ts` with 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 [#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 `clinicalProcedure` is a router-guard test failure.
- **Enforce in RLS alone.** `requireStrongAuth`, patient scope and AVV live outside the row and would require per-row joins against `session_assurance`, `avv_acceptance` and the nurse's `visit_schedule`. Even if they could be joined into every policy, the gate's errors (`ASSURANCE_REQUIRED` with the required factor, `ACCESS_READ_ONLY` with 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 to `can()`.

## Hardening verification (ticket 17b) [#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.

