# Retention erasure and verifiable history

The repository's architectural decision, rationale and consequences.

# Retention erasure and verifiable history

**Status:** accepted; implemented on branch `t74` (see Implementation). **Date:** 2026-11-01. **Ticket:** 74.

## Context [#context]

The current purge reads a different retention setting from the one organizations edit. It can delete records during an export, leave patient objects and trail payloads behind, and miss devices with limited patient scope. These failures can cause irreversible data loss or incomplete erasure. The purge must remain disabled until the safeguards below are implemented and verified together.

[ADR-0021](0021-hash-chained-clinical-trail.md) hashes the entire v1 event, including the plaintext payload and patient and entity identifiers. Its bytes cannot be removed while retaining independent verification of that same hash. A migration must record this limit rather than present a stored link as proof of an erased event.

## Eligibility and preservation [#eligibility-and-preservation]

The latest `org_policy` version is the sole source of `retentionYears`; `care_service.retention_years` is not used for eligibility. The policy schema supplies the 10-year default when there is no version or the field is absent. A malformed stored policy blocks the patient. The public policy accepts integer values from 1 through 30. The old zero-year fixture does not define production policy.

A candidate has a non-null `careEndedAt` and is eligible only strictly after that instant plus the configured number of calendar years in `Europe/Berlin`. Compare instants after the calendar calculation, including leap days and daylight-saving changes. Re-read the patient, latest policy, access state and all blockers inside the deletion transaction. Discovery is a hint, never authorization to delete.

A queued, leased, or running export of any kind blocks erasure for the entire organization. Export scopes are currently untyped JSON, so the job cannot safely prove that an export excludes a patient. This includes `legal_hold` exports. Finishing such an export does not release a hold.

An open dispute or active legal hold blocks erasure. Model preservation state durably, with its patient or organization scope, creator, reason, timestamps, and release or resolution actor. An owner or admin can set a hold or record a dispute; an owner or admin explicitly releases or resolves it. Release of one hold does not release another. A hold has no automatic expiry. Missing or unknown preservation review is blocked until an authorized owner or admin records that the patient has no outstanding hold or dispute. A later hold or dispute overrides that clearance. Record no clinical content in the reason. The owner decisions below remain open.

A row lock alone cannot exclude a new export or hold. Serialize the purge and every policy, clinical-access, export, hold, dispute and clearance write for that organization using one transaction-scoped advisory lock. Enforce the lock at the database boundary, including direct tenant inserts, and define a consistent order before the existing trail lock. The transaction takes the lock, reads the latest committed state, then locks the patient and rechecks eligibility. Writers starting after the purge must see the deleted patient and reject patient-scoped work. A timeout or serialization failure rolls back the entire patient deletion. Unknown state fails closed.

## Trail format and historical migration [#trail-format-and-historical-migration]

Introduce a versioned v2 chain. The v2 envelope contains a commitment to a separately stored, erasable body holding patient identifiers and clinical payload. Use a fresh cryptographically random per-patient secret to compute a keyed digest of canonical body bytes. Do not use a bare digest of low-entropy health data. Keep the body and secret only while the patient is retained. Erasure removes both in the same transaction as the patient; the non-identifying v2 envelope and its commitment remain. Verification recomputes each v2 chain link, and checks the body against its commitment while the body and secret exist. After erasure it can verify chain continuity, but cannot reconstruct or check the erased body.

Migrate existing v1 history before enabling purge. Under the organization trail lock, verify all existing links and the unsealed suffix, preserve original sequence order and microsecond timestamps, and build the v2 envelopes and erasable bodies. Record a durable migration checkpoint binding the verified v1 head, v2 head, per-sequence mapping, sequence range and migration version. Switch readers, sealer and verifier atomically to the versioned format. A gap, mismatch, incomplete migration or concurrent append prevents cutover and purge. Migration work must be bounded and resumable; it must not expose partially converted history as complete. Account for existing synced trail copies and indexes when erasing bodies.

The checkpoint attests that the conversion verified the old chain at migration time. Once a v1 body is erased, the retained v1 links and checkpoint cannot independently re-prove its original preimage or the correctness of the conversion. Their integrity has the same stored-database trust limit as ADR-0021. An external signature attests to the converter and protects the checkpoint against later changes, but independent proof of the conversion itself requires a separate proof system. Do not report a full re-verification of erased v1 inputs. Existing readers must represent an erased body explicitly, and must not disclose commitments as clinical content.

## Objects and receipts [#objects-and-receipts]

In the patient deletion transaction, enumerate all patient-owned objects before their references cascade: every photo variant, consent signature, visit signature, report PDF and share-link ciphertext. Find share links through their report as well as their optional patient ID. Include other patient-owned keys discovered by the schema inventory. Reconcile attached staging references. Treat unknown hashes as unknown.

Object keys must have exclusive ownership. Prove no surviving patient or organization references a key, and serialize attachment and erasure so a key cannot be reused concurrently. If ownership cannot be proven, retain the patient and report a failure. Never infer ownership from a prefix alone. Completed export bundles that include this patient need an explicit retention and ownership decision before erasure; mixed-patient bundles cannot be silently deleted.

Insert an idempotent object-deletion request for every key in a durable, system-owned outbox in the same transaction as the patient delete. The outbox and receipt survive patient deletion. A bounded later worker claims requests and retries failed or interrupted storage deletes. Prevent new upload URLs and key reuse, and wait until all previously issued upload URLs have expired before deleting the object. Coordinate with the existing per-object lock. Object deletion is idempotent. A crash after the storage operation but before acknowledgement must be safe to retry. A receipt records requested counts and available ciphertext hashes without clinical content or raw object keys. It reports object deletion as pending until every request succeeds, and completed only after durable acknowledgement. Do not log keys, patient payloads, or storage credentials.

## Scheduling, access and actors [#scheduling-access-and-actors]

Use `clinicalJobAccess` in write mode inside the protected transaction. Missing AVV and read-only, export-only, or closed access block erasure. A daily Berlin-calendar schedule claims work durably, with bounded batches and fair progress across organizations and retries. A tick interruption is checked between patients. Each patient transaction has local lock and statement timeouts and is drained before its pool closes.

Record a system actor for retention events. The job must not require a current owner membership or impersonate a human. Preserve the v1 actor representation during migration and enforce that only the system path can write system-actor events.

## Devices [#devices]

Capture devices that ever held the patient in `device_scope` before deletion, including entries already marked removed. Deliver an explicit patient-erasure notice to those devices even after their current clinical scope no longer contains the patient. Keep notices replayable until acknowledged, including across pull pages, without exposing other patients to limited-scope devices. Ordinary scope removal remains distinct from erasure.

On receipt, the mobile engine atomically removes the patient's cached records, local blobs, conflicts and queued or rejected work, including dependent work that cannot be sent. It persists a content-free visible notice and advances the cursor in the same local transaction. Server mutation and object-attachment paths reject stale work for a deleted patient. Device erasure completes when a device next connects; an offline device cannot be remotely proven erased.

## Implementation [#implementation]

Each decision above is implemented with a `74: …` regression test in `packages/api/src/features/retention/retention.test.ts` (and the mobile engine test for devices). Where the owner decisions are open, the implementation takes the conservative default:

- **Policy.** The latest `org_policy` version is validated with the policy schema's `retentionYears` field inside the patient transaction; a malformed value skips the patient (`POLICY_INVALID`). Eligibility is computed in SQL as `(care_ended_at at Berlin + N years) at Berlin < now`.
- **Serialization.** `app.retention_serialize()` takes `wk:retention:<org>` before inserts into `org_policy`, `avv_acceptance`, `export_job` and `retention_hold`, before `retention_hold` updates, and before `care_service` updates that change access-state columns. `trail.append` takes the same lock before the trail lock, so the order is always retention, then trail. The purge also takes `wk:erasure:<org>` exclusively; `sync.snapshot` and `sync.pull` hold it shared while they deliver patient data.
- **Preservation.** `retention_hold` holds legal holds, disputes and clearances. Reasons are codes, so no free text is stored. Owners and admins record and release them through `retention.recordPreservation`, `releasePreservation` and `listPreservation`; the row policy enforces the same rule for direct tenant writes. A clearance counts only when no hold or dispute was created at or after it. Blocker codes are `EXPORT_ACTIVE`, `EXPORT_RETAINED` (a completed, unexpired export package), `HOLD`, `UNREVIEWED` and `OBLIGATION` (an active prescription, active share link, pending report job or pending KIM delivery).
- **Export packages (default for the open decision).** Export scopes are untyped, so every completed package may contain the patient. An unexpired package blocks erasure. An expired package is no longer served for download, and the erasure that first finds it queues its objects in the outbox (`counts.exportBundles`). A later erasure does not queue them again.
- **Objects.** The receipt and the outbox list photo variants, consent and visit signatures, report PDFs and share-link ciphertext. Ownership is checked against every key column in the schema, including export, document, import and upload keys. Staging rows neither prove nor block ownership. Once a key is in `object_deletion`, staging and attachment refuse it (tenants can read their organization's outbox rows). Staging signs the PUT URL while it holds the object lock, so erasure cannot queue a key between the check and the signature. Each object is deleted 15 minutes after erasure, because presigned PUTs live at most 5 minutes. Right before deleting, the worker rechecks under the object lock that no surviving row names the key; if one does, it keeps the object and leaves the receipt pending (`REFERENCED`). A receipt completes in one statement after each object commit and at the start of every drain, so concurrent or interrupted workers cannot leave it pending.
- **Trail v2.** Bodies are stored in the existing `trail_event` columns (`patient_id`, `entity_id`, `supersedes_id`, `payload`) and erased in place, so the synced row shape is unchanged. Commitments (`trail_commitment`), per-patient keys (`trail_body_key`), v2 links (`trail_link_v2`) and the checkpoint (`trail_migration`) are stored separately. The sealer computes commitments, not `append`, so tenants never read keys. Erasure therefore waits until the patient's events are sealed (`TRAIL_UNSEALED`). The `retention.purged` event names the receipt, not the patient. The seal job migrates one page per visit and cuts over at the head under the trail lock. A sealed event without its v1 link fails the migration (`missing-link`) and is reported as a gap. The verifier takes the same trail lock as sealing, migration, append and erasure. Retained v1 links stay untouched.
- **Scheduling.** Every tick drains the outbox. Erasure runs once per Berlin day from 03:00. `retention_run` stores each organization's day, its resume cursor and a five-minute lease that keeps overlapping ticks away while a batch runs; organizations rotate five at a time, with 50 patients per batch. An interrupted run records nothing, and the next tick after the lease expires resumes the day. A per-patient timeout or failure is skipped by SQLSTATE and retried the next day.
- **Devices.** `erasure_notice` lists every device recorded in `device_scope` for the patient. A snapshot records its patients in a committed transaction before it reads them, and every pull page records the patients it delivers (`added_cursor` stays null, so the scope diff is unchanged). Those scope rows are deleted with the patient. The mobile engine also tags new blobs with their patient. Its erasure removes drafts of the patient's wounds and stable-mutation receipts as well. It runs under the same lock as enqueue and leaves a durable `erased:` marker, so the patient stays out of scope and new work for the patient is refused.
- **System actor.** The tenant insert policy on `trail_event` refuses the reserved `wundklar-system` actor, even for a tenant context whose user is that ID.

Known limits: an event that names the patient only inside another entity's payload is not erased. An unattached blob queued before this release, and referenced only by a removed `photo.capture`, stays on the device until the vault is wiped. Server rejection of stale work for a deleted patient relies on the existing patient foreign keys and scope checks, and has no new regression test. Mobile draft autosave does not take the enqueue lock: a draft saved after the erasure page commits stays until it is discarded, but the screen hides it because the patient is out of scope.

## Owner decisions [#owner-decisions]

- Confirm who may set and release holds, who may resolve disputes, and whether a second approver is needed. Until then, owner and admin may record these actions, and unreviewed patients remain blocked.
- Confirm whether an active prescription, pending report or delivery, active share link, or another legal obligation must independently block erasure. Treat an unresolved obligation as a hold until decided.
- Decide how to retain or delete completed export packages that include the patient, especially packages covering several patients, and how backup expiry or restore must honor an erasure. Until then, an unexpired package blocks erasure, and an expired package is no longer downloadable and is deleted with the next erased patient.
- Confirm retention period interpretation and whether the organization-wide policy may be shortened for existing records. The proposed default uses the latest validated policy and Berlin calendar arithmetic, subject to the above blockers.
- Decide whether an externally signed pre-erasure checkpoint is required for independent evidence of migrated v1 history.
- Confirm that erasing v2 bodies in place in `trail_event` (with the commitment and key stored separately) satisfies "separately stored body", or require a separate body table before production use.
- Decide how long erasure receipts (`retention_receipt`, which keeps the opaque patient id) and completed outbox rows are kept.

