Every clinical insert and every correction append one trail event in the
same transaction. The trail is per-Organization, insert-only and
cryptographically chained so a replayed or dropped event is detectable.
Decision:
ADR-0021;
code:
packages/api/src/features/trail.
Chain
hash_0 = SHA-256("wundklar-trail-genesis:" + organizationId)
hash_n = SHA-256(hash_{n-1} ‖ 0x0a ‖ JCS(event))JCS is RFC 8785 canonical JSON (via canonicalize); hash_{n-1} is the
previous event's prev_hash. The chain starts with the Organization's
provisioning event, so an org that never acted still has a verifiable
genesis.
The event payload includes the actor (user and device), the entity touched,
a stable kind from TRAIL_ACTIONS and the before / after shape required
for corrections, plans and share-link accesses. Nothing in the payload is
clinical text in the clear: patient and photo references stay as ids,
while free-form reasons are kept inside the cryptographic hash so an audit
can still prove them without exposing the text in logs.
Writing
Trail rows live in three tables (trail_event, trail_link, trail_verification)
granted system-only. The tenant handle never writes them directly; every
clinical handler calls
appendTrail
inside the same transaction as the clinical insert — so if the handler
rolls back, the trail does too, and if the trail write would fail the
clinical write fails with it. The link rows are added by the sealer under a
per-org advisory lock so parallel ticks cannot interleave.
Sealing and verifying
The cron tick runs two jobs:
trailSealevery tick seals unsealed events per Organization under the advisory lock, writing thetrail_linkrows that cover[previous_last_hash, current_last_hash].trailVerifyruns daily at 03:00 Europe/Berlin and recomputes the chain, writing atrail_verificationrow with the result; a mismatch raises an alert, nothing is overwritten.
Both are registered in
packages/api/src/platform/jobs/run.ts
(ADR-0022).
Reading
trail.list returns events for an entity, newest first, with the chain
reference each row sits on. The desktop case page shows them inline; the
MD-package export includes them verbatim under the same hashes so a court
can replay the chain independently.