ADR-0140: Retention holds + AMLR Art. 77(2) +5-year cap + no-purge guard + reference-derogation
Date: 2026-07-25
Status: Accepted
Deciders: Adrian (Soft4U BV), Claude (Opus 4.8)
Extends: ADR-0139 (retention clock + Art. 77(1) retained-set assertion — the RetentionHold seam this ADR fills), ADR-0108 (proactive retention-purge sweep — the ERASE side this ADR gates), ADR-0064/0109 (immutable, hash-chained audit_events), ADR-0067 (fail-closed / never-suppress)
Issue: #549 (AMLR epic #528, Wave 4 — retention / data protection)
Context
ADR-0139 (#548) added the typed retention clock and left a deliberately bounded,
one-directional RetentionHold seam (active, until, reason, basis) for its sibling
issue #549. #548 honoured a hold (extend / block only) but never gave the hold a source, a
bound, or a wiring into the erasure path. Three things remained to build:
-
The AMLR Art. 77(2)/(3) extension is bounded. Art. 77(2) permits the five-year statutory retention to be extended — e.g. for an ongoing ML/TF investigation (a reference-derogation) or under a legal hold — but "by a further period not exceeding five years". #548's honour logic let a hold's
untilextendretain_untilwith no ceiling: a hold reaching 20 years into the future would retain for 20 years, violating both Art. 77(2) and GDPR Art. 5(1)(e) storage-limitation. -
The no-purge guard was not wired. ADR-0108's
run_retention_purgedecided eligibility purely fromdetermine_erasure_action(hold-unaware). A case whose statutory window had expired but which was under an active legal hold would be erased by the sweep — the hold existed as a data shape but blocked nothing. The same gap sat one layer deeper inDSRService.handle_erasure, whose per-case re-check (ADR-0108's fail-safe boundary) is also hold-unaware — so a subject appearing in a held case could be erased via a sharedperson_hasheven if the enumeration filter were fixed. -
A hold had nowhere to live.
run_retention_purgehad no place to read a hold from.
The forces: the fix must add retention (a hold blocks erasure) without ever shortening
retention below the statutory five years (Art. 77(1) is a floor) and without letting
retention run unbounded (Art. 77(2) is a ceiling; GDPR minimisation favours erasing
over-held data). It must not weaken ADR-0108's fail-safe re-check or ADR-0139's fail-closed
clock. And it should avoid a migration if the existing cases row can carry the hold.
Decision
Fill the ADR-0139 seam. All changes are additive and default to the pre-#549 behaviour when no hold is present.
-
Hold source =
cases.additional_data["retention_hold"]JSONB — no new table, no migration. A new pureretention_hold_from_case(additional_data)parses the payload{"active", "until": <ISO-8601>|null, "reason", "basis"}into a typedRetentionHold. Absent/malformed →None(default-absent). A reference-derogation is the same shape with the derogation article as itsbasis(e.g."AMLR Art. 77(2) reference-derogation"). FAIL-CLOSED: an active hold whoseuntilcannot be parsed is honoured as an indefinite hold (blocks erasure) rather than silently dropped. -
AMLR Art. 77(2) +5-year cap in
compute_retention_clock(newART77_2_MAX_EXTENSION_YEARS = 5). A hold is effective only whileactiveAND itsuntilhas not elapsed (until is None= indefinite legal hold; elseuntilin the future). For an effective hold with a defineduntiland a known statutory expiry:retain_until = max(retention_expiry, min(hold.until, retention_expiry + 5y))— clamped DOWN to the cap and never below the statutory floor, giving the invariantstatutory_expiry ≤ retain_until ≤ statutory_expiry + 5y. A clamp sets the newRetentionClock.hold_cappedflag and is disclosed in the note. The cap is load-bearing: eligibility is re-derived asretain_until <= now, so a hold cannot keep a case paststatutory + 5y— retention never runs unbounded. An indefinite hold (until=None) blocks with no defined end (event-bound legal hold — see Alternatives); an expired/inactive hold has no effect (the case is erasable on the normal statutory clock — minimisation). -
No-purge guard wired into the erasure path at every deletion boundary.
run_retention_purgecomputes each candidate's eligibility via the hold-awarecompute_retention_clock(reading the hold offadditional_data) instead of the baredetermine_erasure_action; a held/derogated case is excluded from the eligible list, so it never reacheshandle_erasureor the case-scoped store purge. As defense-in-depth (mirroring ADR-0108's "pre-filter + per-case re-check" pattern) both deletion boundaries are made hold-aware too:DSRService.handle_erasurereads each case's hold and forces REFUSE (with alegal_holdretention-basis on theDSRResult) when the hold-aware clock is not eligible;_purge_case_scoped_storesre-reads the hold before touching MinIO/Neo4j. Because the hold is honoured per case, a subject in both a held case and an expired case is correctly retained in the former and erased in the latter — never all-or-nothing.
Decision context:
- Latency: pure computation over the two clock columns plus one JSONB field already on
the fetched
casesrow; no new query round-trips, no external calls. - Dependency surface: zero new packages.
dsr_servicegains a lazy (function-local) import ofretention_clock_serviceto avoid an import cycle (retention_clock_serviceimports fromdsr_serviceat module load). - Debuggability: a blocked erasure is visible on
DSRResult.retainedwith an explicitlegal_holdbasis and the derogation article; a clamped hold is visible on the clock'shold_cappedflag + note. No silent state. - Reversibility: additive. Remove the hold key from
additional_dataand every case reverts to the pure statutory clock; the cap/guard code is inert when no hold is present. - Blast radius:
determine_erasure_actionis untouched (still the canonical statutory policy).handle_erasure,run_retention_purge,_purge_case_scoped_stores, andcompute_retention_clockgain hold-awareness that is a no-op for the (universal today) no-hold case — verified byte-identical against the existing DSR/purge suites. - Alternative considered: a dedicated
retention_holdstable (rejected — thecasesrow already carriesadditional_data; a table adds a migration and a second source of truth for no present writer). See Alternatives.
Consequences
Positive
- A hold now provably blocks the purge at every deletion boundary (enumeration,
handle_erasure, case-scoped stores) — the no-purge guard is demonstrable, not nominal. - Retention is bounded on both sides: never below the statutory five years (Art. 77(1) floor), never beyond statutory + five years (Art. 77(2) ceiling) — the cap actively frees over-held data (GDPR minimisation), it is not cosmetic.
- Per-case hold semantics mean a shared subject is retained/erased correctly per case, and a reference-derogation carries its article into the audit trail.
- No migration; a hold is a JSONB field on the existing
casesrow.
Negative
- The hold has a source and a reader but not yet a writer/UI — no endpoint sets or
audits the placing of a hold; a hold is recorded by writing
additional_data. A first-class hold-management surface (place / lift / audit the hold event) is follow-up. handle_erasurenow depends (lazily) onretention_clock_service, coupling the reactive DSR path to the retention-clock module; the import is function-local to avoid a cycle.
Neutral
- Making
handle_erasurehold-aware also blocks a reactive Art. 17 erasure request under an active hold (legally correct — GDPR Art. 17(3)(b) legal-obligation / (e) legal-claims), a behaviour change only for cases that carry a hold (none exist pre-#549). - An indefinite hold (
until=None) is deliberately NOT capped (see Alternative 2); the invariantstatutory ≤ retain_until ≤ statutory+5ygoverns date-defineduntilvalues.
Alternatives Considered
Alternative 1: A dedicated retention_holds table
- A typed, per-case (or per-subject) persisted hold record with its own migration.
- Why rejected: the
casesrow already carriesadditional_dataJSONB, the purge already fetches the case row, and there is no present writer that needs a separate relational surface. A table adds a migration, RLS policies, and a second source of truth for the hold for no current consumer. Recorded as the least-invasive choice; revisit if a hold-management surface needs relational querying/joins (e.g. "all cases under hold across tenants").
Alternative 2: Cap indefinite (until=None) holds at statutory + 5y as well
- Force every hold, including an open-ended legal hold, to expire at the Art. 77(2) ceiling.
- Why rejected: an
until=Nonehold models an event-bound legal hold (a court order / litigation hold) whose end is the conclusion of the matter, not a calendar date — and one cannot destroy evidence under an active court order. Art. 77(2)'s +5y ceiling governs date-defined statutory extensions / reference-derogations (which carry anuntil). Capping the indefinite hold would also silently re-enable erasure of court-held evidence and would contradict ADR-0139's already-shipped indefinite-hold test. The distinction is documented onRetentionHold; a defineduntilis always capped.
Alternative 3: Gate only the purge enumeration, leave handle_erasure hold-unaware
- Skip held cases in
run_retention_purge's candidate list only. - Why rejected:
handle_erasureresolves a subject across ALL their cases in a tenant byperson_hashand re-derives eligibility per case; a hold-unaware re-check would erase a held subject reached via a shared hash from a different, unheld case. The enumeration gate alone is not provable. Making the re-check hold-aware is the same defense-in-depth ADR-0108 already uses for the statutory decision.