Skip to main content

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:

  1. 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 until extend retain_until with 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.

  2. The no-purge guard was not wired. ADR-0108's run_retention_purge decided eligibility purely from determine_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 in DSRService.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 shared person_hash even if the enumeration filter were fixed.

  3. A hold had nowhere to live. run_retention_purge had 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.

  1. Hold source = cases.additional_data["retention_hold"] JSONB — no new table, no migration. A new pure retention_hold_from_case(additional_data) parses the payload {"active", "until": <ISO-8601>|null, "reason", "basis"} into a typed RetentionHold. Absent/malformed → None (default-absent). A reference-derogation is the same shape with the derogation article as its basis (e.g. "AMLR Art. 77(2) reference-derogation"). FAIL-CLOSED: an active hold whose until cannot be parsed is honoured as an indefinite hold (blocks erasure) rather than silently dropped.

  2. AMLR Art. 77(2) +5-year cap in compute_retention_clock (new ART77_2_MAX_EXTENSION_YEARS = 5). A hold is effective only while active AND its until has not elapsed (until is None = indefinite legal hold; else until in the future). For an effective hold with a defined until and 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 invariant statutory_expiry ≤ retain_until ≤ statutory_expiry + 5y. A clamp sets the new RetentionClock.hold_capped flag and is disclosed in the note. The cap is load-bearing: eligibility is re-derived as retain_until <= now, so a hold cannot keep a case past statutory + 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).

  3. No-purge guard wired into the erasure path at every deletion boundary. run_retention_purge computes each candidate's eligibility via the hold-aware compute_retention_clock (reading the hold off additional_data) instead of the bare determine_erasure_action; a held/derogated case is excluded from the eligible list, so it never reaches handle_erasure or 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_erasure reads each case's hold and forces REFUSE (with a legal_hold retention-basis on the DSRResult) when the hold-aware clock is not eligible; _purge_case_scoped_stores re-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 cases row; no new query round-trips, no external calls.
  • Dependency surface: zero new packages. dsr_service gains a lazy (function-local) import of retention_clock_service to avoid an import cycle (retention_clock_service imports from dsr_service at module load).
  • Debuggability: a blocked erasure is visible on DSRResult.retained with an explicit legal_hold basis and the derogation article; a clamped hold is visible on the clock's hold_capped flag + note. No silent state.
  • Reversibility: additive. Remove the hold key from additional_data and every case reverts to the pure statutory clock; the cap/guard code is inert when no hold is present.
  • Blast radius: determine_erasure_action is untouched (still the canonical statutory policy). handle_erasure, run_retention_purge, _purge_case_scoped_stores, and compute_retention_clock gain 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_holds table (rejected — the cases row already carries additional_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 cases row.

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_erasure now depends (lazily) on retention_clock_service, coupling the reactive DSR path to the retention-clock module; the import is function-local to avoid a cycle.

Neutral

  • Making handle_erasure hold-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 invariant statutory ≤ retain_until ≤ statutory+5y governs date-defined until values.

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 cases row already carries additional_data JSONB, 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=None hold 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 an until). 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 on RetentionHold; a defined until is 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_erasure resolves a subject across ALL their cases in a tenant by person_hash and 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.