ADR-0180: An empty retention sweep IS an event
Date: 2026-08-10 Status: Accepted Amends: ADR-0108 (§4 only; ADR-0108 stays Accepted) Deciders: Adrian (Soft4U BV), Claude Opus 5, Codex (review, PR #1125)
Context
ADR-0108 §4 records that the sweep "Audits each tenant's purge that erased anything (append-only, ADR-0064; Art. 5(1)(e) legal basis recorded). An empty sweep is not an event."
That was a defensible call at the time and it is wrong now, for a reason ADR-0108 could not have weighed: it makes two opposite states indistinguishable.
A retention sweep that runs and lawfully deletes nothing produces zero
RETENTION_PURGE events. A retention sweep that never runs — because the
Temporal schedule was never provisioned, or was deleted, or the worker is
down — also produces zero. The /admin retention panel reads
_purge_audit_summary(), which counts RETENTION_PURGE alone, so both render
identically: "0 purge audit events recorded."
The GDPR Art. 5(1)(e) question an auditor asks is not "how much did you delete?" It is "is the storage-limitation control operating?" Under ADR-0108 §4 that question had no answer, and the more reassuring reading — everything is lawfully retained — was the one the panel implied.
This is ADR-0163's defect class applied to an absence: a missing record read as
a benign value. It is the same shape as ADR-0166's not_assessed coverage
records and ADR-0161 §11's insistence that resolution needs positive evidence
rather than evidence of a return.
Decision
RETENTION_SWEEP is written on every completed sweep, including one that
deleted nothing. RETENTION_PURGE is unchanged and remains the deletion log.
Two events, because they answer two questions and one artifact cannot answer both without ambiguity:
| Event | Written when | Answers |
|---|---|---|
RETENTION_PURGE | records were erased | how much have we deleted? |
RETENTION_SWEEP | the sweep completed, always | did the control run? |
The receipt states zero_deletion_run explicitly rather than leaving a
reader to infer it from a count — inferring it is exactly what nobody could do
before. Its absence is now the signal: no RETENTION_SWEEP means the sweep
did not complete.
Four consequences follow, and each is load-bearing:
-
The receipt write is retried in place, and a persistent failure is reported rather than raised.
This ADR first said the run must RAISE so Temporal's retry applies. That was wrong, and the correction is recorded here rather than left as a decision the implementation quietly rejects (Codex, PR #1125):
RetentionPurgeWorkflowretries the whole activity (maximum_attempts=2), not the acknowledgement. A second attempt re-runs the sweep over already-erased data, produces an all-no-op manifest, and writes THAT as the receipt — replacing the true record of what was deleted with a false one. The cure was worse than the ambiguity.The retry now sits where the failure is: three in-place attempts per receipt. A still-failing write is reported — ERROR naming every tenant, plus a first-class
receipt_write_failedon the returned tally.The honest residual:
retention_status()reconstructs its state fromAuditEventrows alone, so a failed write is indistinguishable there from a sweep that never ran — it surfaces asnever_sweptorsweep_stale. Both are conservative (they under-claim, never over-claim), and the ERROR log is what separates them. Closing that gap needs durable state outsideaudit_events, which is not built here and is not pretended to be. -
Receipts are written per tenant, under that tenant's own id. A single receipt with
tenant_id=Nonefalls back to the demo tenant inAuditService, so it would be chained and RLS-owned as a demo-tenant event while every other tenant's audit chain held no evidence of its own sweep. A tenant cannot read a receipt it does not own. A cross-tenant summary is also written, explicitly markedscope: "system". -
The consumer must read it.
_purge_audit_summary()reads both event types and returns a three-statecontrol_state(never_swept|swept_no_deletions|swept_with_deletions), whichRetentionCardrenders on every state. A receipt nothing reads closes nothing — the same obligation ADR-0166 records forcontrol_coverage. -
The receipt does not travel in the activity result. Temporal records an activity's return value in workflow history.
run_retention_purgebuilds the per-case manifests for every terminal case in every tenant, so returning them makes the history payload grow with the whole population — and it crosses the boundary after the destructive work has committed. Exceeding the server's payload limit there fails the activity, andRetentionPurgeWorkflow's retry re-runs the sweep over already-erased data, replacing the true receipt with an all-no-op one: the same re-run hazard §1 exists to avoid, reached by a different route.So
retention_purge_activityappliesbounded_activity_result, which keeps only scalars — every count,complete,zero_deletion_run,run_id, and thereceipt_write_failedlist capped at 20 entries. It is bounded by construction, not by an allowlist of today's keys: a non-scalar value is dropped and its key is reported underomitted, so a field added later is visible rather than silently re-introducing the defect. The manifests are not lost — they are already inaudit_events, per tenant, which is where a regulator reads them.
Consequences
Positive
- A dead scheduler is visibly distinct from a lawful no-op, on the surface an operator actually looks at.
- The storage-limitation control can be shown to be operating without a deletion having to occur — which is the normal steady state of a healthy deployment.
- Per-tenant chaining means each tenant's own trail evidences its own sweep.
Negative
- One audit row per tenant per sweep, forever. At a 24-hour cadence that is 365 rows per tenant per year in an append-only, hash-chained table that is never pruned. This is a real and permanent storage cost, accepted because the alternative is a control that cannot be shown to run.
- A receipt-write failure does NOT fail the activity — it is retried three times in place and then reported. An earlier revision of this ADR said it raises; that was reversed because the activity-level retry re-runs the purge (see §1). The residual is that a persistent failure is visible only in the ERROR log and the returned tally, not on the status surface.
- The cross-tenant summary duplicates data already in the per-tenant receipts.
Neutral
RETENTION_PURGEsemantics are untouched, so "how many deletions have we performed?" stays answerable without filtering.- The activity returns bounded counts, so an operator reading a workflow
execution in the Temporal UI sees the tally and the receipt status, and goes
to
audit_eventsfor the per-case detail. That is one more hop than before. - No migration: both are
audit_eventsrows.
Alternatives Considered
Alternative 1: Leave ADR-0108 §4 as written; infer liveness from the schedule
Read the Temporal schedule's lastRun instead of writing an event.
Why rejected: that is the claim-vs-check defect the retention surface
already documents against itself — a schedule's existence is a claim about
configuration, not evidence that the sweep executed and completed. ADR-0096
made the same argument for monitoring schedules.
Alternative 2: Write RETENTION_PURGE on every run, with zero counts
One event type, deletion counts of zero on a no-op.
Why rejected: it destroys the deletion log. "How many deletions have we
performed?" would require filtering on the tallies inside details, and every
existing consumer counting RETENTION_PURGE rows would silently start
over-reporting. Two questions, two records.
Alternative 3: Log the empty sweep at INFO and leave the audit trail alone
Why rejected: logs rotate. ADR-0175 records the same lesson from the WAL archive — a log records intentions that ran; an artifact records outcomes that landed — and the retention control's whole failure mode is silence.
Decision context
- Latency: one additional
audit_eventsINSERT per tenant per sweep, on a 24-hour cadence. Not measured; the sweep is a background schedule with no request path and the write is dwarfed by the erasure work it follows. - Dependency surface: none. Reuses
AuditServiceandaudit_events. - Debuggability: improved — this is the change's purpose. A failed receipt
write is now loud (three in-place retries, then an ERROR naming each failing
tenant and a
receipt_write_failedentry on the result) rather than a swallowed warning. It does NOT raise: see §1 for why the activity-level retry is the wrong granularity. - Reversibility: the write is one call site; the consumer's
control_stateis additive and optional in the TypeScript type. Hours, not a migration. - Blast radius: additive to the audit trail;
RETENTION_PURGEand every existing consumer of it are unchanged. The one substitutive change is the shape ofretention_purge_activity's RESULT, which is now bounded counts and receipt status rather than the full manifests (§4). Nothing in the workflow reads that result, so the reduction has no consumer to break; what it removes is an unbounded write into Temporal history. - Alternative considered: infer liveness from the Temporal schedule — rejected as claim-not-check (see Alternative 1).