Skip to main content

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:

EventWritten whenAnswers
RETENTION_PURGErecords were erasedhow much have we deleted?
RETENTION_SWEEPthe sweep completed, alwaysdid 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:

  1. 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): RetentionPurgeWorkflow retries 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_failed on the returned tally.

    The honest residual: retention_status() reconstructs its state from AuditEvent rows alone, so a failed write is indistinguishable there from a sweep that never ran — it surfaces as never_swept or sweep_stale. Both are conservative (they under-claim, never over-claim), and the ERROR log is what separates them. Closing that gap needs durable state outside audit_events, which is not built here and is not pretended to be.

  2. Receipts are written per tenant, under that tenant's own id. A single receipt with tenant_id=None falls back to the demo tenant in AuditService, 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 marked scope: "system".

  3. The consumer must read it. _purge_audit_summary() reads both event types and returns a three-state control_state (never_swept | swept_no_deletions | swept_with_deletions), which RetentionCard renders on every state. A receipt nothing reads closes nothing — the same obligation ADR-0166 records for control_coverage.

  4. The receipt does not travel in the activity result. Temporal records an activity's return value in workflow history. run_retention_purge builds 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, and RetentionPurgeWorkflow'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_activity applies bounded_activity_result, which keeps only scalars — every count, complete, zero_deletion_run, run_id, and the receipt_write_failed list 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 under omitted, so a field added later is visible rather than silently re-introducing the defect. The manifests are not lost — they are already in audit_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_PURGE semantics 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_events for the per-case detail. That is one more hop than before.
  • No migration: both are audit_events rows.

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_events INSERT 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 AuditService and audit_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_failed entry 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_state is additive and optional in the TypeScript type. Hours, not a migration.
  • Blast radius: additive to the audit trail; RETENTION_PURGE and every existing consumer of it are unchanged. The one substitutive change is the shape of retention_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).