# Hash-chained clinical trail

The repository's architectural decision, rationale and consequences.

# Hash-chained clinical trail

**Status:** accepted. **Date:** 2026-10-31. **Tickets:** 20, 20b.

**Enforcement:** `packages/api/src/features/trail/trail.test.ts` runs the six ticket acceptance tests against disposable Postgres. It also covers transaction rollback, cursor paging, an independent hash fixture, and daily scheduling across Berlin's DST transitions.

## Decision [#decision]

Clinical history lives in `trail_event`, `trail_link`, and `trail_verification`. `trail_work` holds system-owned scheduling and verification cursors. The security audit log remains separate. Clinical history preserves corrections and encrypted-record provenance. The security audit records platform administration and security actions.

Handlers call `append(tx, event)` from `trail.service.ts` inside the transaction that writes the clinical record. The append acquires `pg_advisory_xact_lock(hashtextextended('wk:trail:' + organizationId, 0))`, allocates the next per-Organization sequence above the highest event, link, verification receipt, or durable `trail_work.event_seq`, and inserts one event. A rollback removes the clinical write and its trail event together. Later handler tickets own those call sites.

The same-Organization device foreign key uses `ON DELETE RESTRICT`, matching the actor reference. Deleting a referenced device or its User cannot rewrite a sealed row.

An event carries entity type and ID, patient ID, actor, device, occurred and recorded timestamps, and a JSON payload. The payload holds wound ID, before and after snapshots, correction reason, `supersedesId`, and a SHA-256 payload hash. Before and after snapshots recursively omit fields ending in `_ct` or `Ct`, including fields inside arrays. This chooses the omission option from the ticket: a handler cannot accidentally record plaintext in an encrypted column's history. Callers must encrypt sensitive fields under their schema's ciphertext column names and must not put patient identifiers into free-text metadata.

The sequence lock also serializes sealing. Verification uses a separate `wk:trail-verify:` lock for daily receipt idempotence, so scanning old history does not block clinical appends. No `FOR UPDATE` privilege is needed on an insert-only table. [ADR-0028](0028-immutable-clinical-records-and-privilege-matrix.md) supplies the table privileges and named system reason.

## Chain format [#chain-format]

The format follows [ADR-0017](0017-client-side-envelope-encryption-and-key-hierarchy.md):

```text
genesis = SHA-256(UTF-8("wundklar-trail-genesis:" + organizationId))
hash[n] = SHA-256(raw(hash[n-1]) || 0x0a || UTF-8(JCS(event[n])))
```

The v1 format freezes these fields in `trail.format.ts`: `id`, `organizationId`, `seq`, `action`, `actorId`, `deviceId`, `patientId`, `entityType`, `entityId`, `payload`, `occurredAt`, and `recordedAt`. Adding database columns does not change v1 hashes. A future format requires a separate version rather than changing this list. Bigint ID becomes a decimal string. Postgres formats timestamps as UTC strings with six fractional digits before hashing, preserving microseconds that JavaScript Date would truncate. `payload` is the stored canonical JSON string, so changes to its bytes also change the chain. JCS sorts object keys by UTF-16 and uses ECMAScript JSON serialization for primitives. The payload hash covers the canonical payload without its `payloadHash` member.

Hashes are stored as lowercase hex. The next hash consumes the previous hash's raw 32 bytes. The ASCII sealer fixture preserves the original chain bytes. A separate umlaut fixture comes from the RFC 8785 `canonicalize` implementation used by the crypto package. Its reproducible generator is `.scratch/wundklar-v1/evidence/20/jcs-vector.mjs`.

`trailSeal` writes one link per event, in batches of at most 250 events per Organization per tick. The frozen schema has `from_seq`, `to_seq`, and `hash`, so each link uses `from_seq = to_seq = event.seq`. The predecessor hash is the previous link's hash, or the Organization genesis for sequence 1. Each event retains the original v1 envelope. Concurrent ticks re-read the head after taking the same transaction lock, so a second tick cannot seal a duplicate range.

The server uses `node:crypto` for SHA-256, supported by the Worker's existing Node compatibility mode. Importing the crypto package's source from the API currently exposes incompatible DOM and Worker WebCrypto declarations. This adapter keeps the same wire algorithm without changing ticket 06's files or package dependencies.

## Verification and readers [#verification-and-readers]

`trailVerify` uses the Effect clock. Daily work starts at or after 03:00 Europe/Berlin. Each Organization processes at most 250 events per tick. `trail_work` persists the fixed target, sealed head, last checked sequence, and intermediate hash in the same transaction as the page. The next tick resumes that page sequence, including across midnight. Completion records one `ok`, `mismatch`, or `gap` receipt. A partial scan writes no success receipt. A new daily pass starts from genesis so earlier corruption remains detectable.

The verifier checks contiguous events through the durable event high-water mark, including the unsealed suffix. It compares chain links through the sealed head and validates payload hashes. Successful receipts report only the verified sealed prefix. The `app.trail_work_enqueue` trigger updates the high-water mark in the event's transaction. Deleting an unsealed tail therefore leaves evidence, and append never reuses that sequence. The migration backfills work from existing events, links, and receipts once. The RLS baseline owns the enqueue trigger function body and revokes EXECUTE from non-owner roles, including after a restore without ACLs.

Sealing records gaps and continues chaining the events that remain. A missing sequence never prevents later events from being sealed. Repeated identical gap observations do not create a receipt every minute. Details contain only the failing sequence.

Both jobs discover at most 25 Organizations from the indexed `trail_work` visit times. They use `FOR UPDATE SKIP LOCKED` on cursor rows and stamp only Organizations they reach before the deadline. An active append cannot block discovery, and later Organizations get a turn when a tick runs out of time. Busy advisory locks are skipped with `pg_try_advisory_xact_lock`. Recurring ticks never run `DISTINCT` over event history.

Both jobs use `asSystem(..., "wundklar.trail-seal", ...)` and respect `clinicalJobAccess` in read mode. Missing AVV and export-only or closed access stop processing. Read-only access allows integrity checks on existing history. `TRAIL_POLICY` keeps the 5-second seal budget, 15-second verification budget, 500-millisecond transaction-local lock timeout, and 2-second statement timeout. In-flight database work drains before the pool closes. Timeout fails with `TrailJobInterrupted`, whose status is `interrupted`. Database failures preserve the Organization and SQLSTATE in `TrailBatchFailure`, without logging payloads or query values. Healthy Organizations still commit their work.

`trail.list` uses `clinicalProcedure` with an enrolled device. Filters for entity, patient, actor, action kinds, and occurred-time range combine. Descending sequence cursors and a maximum page size of 100 keep new inserts from shifting later pages. Writes validate the payload before insertion. Reads return a null payload for a malformed persisted event so healthy events remain readable. Verification still reports that corruption as a mismatch. Both correction versions remain listed. `supersededBy` resolves successor IDs even when a successor is outside the requested page. The `(organization_id, supersedes_id)` index supports this lookup. Append writes the projection beside the canonical payload. The projection stays outside the frozen v1 hash fields. Readers validate a claimed successor against its payload, and verification detects a projection that disagrees with the canonical payload. An explicit out-of-scope patient is refused; unfiltered lists return pseudonymous metadata with the entire out-of-scope payload set to null.

`trail.verifyStatus` uses the same clinical boundary with the same enrolled-device requirement as the list. It returns the latest receipt and current sealed head. A newer successful receipt cannot clear a known fault beyond the prefix that receipt verified. A missing receipt is null, rather than an implied successful verification.

## Limits and integration [#limits-and-integration]

This chain detects corruption relative to stored links and receipts. It cannot prove integrity against a superuser who rewrites every event, link, and receipt together. External signed checkpoints would be a separate decision.

The cron registry passes its existing database handle and clock into these jobs. Neither job opens a pool or falls back to remote resources. Ticket 20b adds three journaled migrations for the restrictive device foreign key, durable work state, and indexed payload lookup.

