ADR-0139: Retention clock + AMLR Art. 77(1) retained-set assertion
Date: 2026-07-25
Status: Accepted
Deciders: Adrian (Soft4U BV), Claude (Opus 4.8)
Extends: ADR-0108 (proactive retention-purge sweep — the ERASE side), ADR-0064/0109 (immutable, hash-chained audit_events)
Issue: #548 (AMLR epic #528, Wave 4 — retention / data protection)
Context
AMLR Art. 77(1) requires an obliged entity to retain the CDD dataset, the supporting records, and the records demonstrating the assessment was carried out, for a defined period (five years) after the end of the business relationship or the occasional transaction — and then to erase them (GDPR Art. 5(1)(e) storage limitation).
The platform already had the ERASE side. ADR-0108's retention_purge_service sweeps
retention-expired cases and runs DSRService.handle_erasure; the canonical policy
DSRService.determine_erasure_action(case_status, case_closed_at) decides REFUSE (retain)
vs DELETE (erase). But two things were implicit and untyped:
- The retention clock — when the five-year period starts (relationship end /
case_closed_at), when it expires, and what state a case is in — existed only as the boolean output ofdetermine_erasure_action. There was no typed, per-case answer to "is this active, retained-in-period, or expired, and until when must it be kept?" - The affirmative Art. 77(1) retained-set assertion — the explicit statement of WHAT must be retained (which categories) and UNTIL WHEN and on what legal basis — did not exist at all. The system could say "we may not erase yet", but not "we are retaining X until Y because Art. 77(1)". Art. 77(1) is an affirmative retention duty, the counterpart to erasure, and a regulator-facing record of it was missing.
The sibling issue #549 adds legal HOLDS and the Art. 77(2) +5-year extension cap (both can EXTEND the expiry). This ADR is deliberately bounded to the clock + the assertion and leaves a clean, typed seam for #549.
Decision
Add a typed retention clock and a typed Art. 77(1) retained-set assertion, both
derived (nothing persisted, no migration — the case already carries status +
updated_at), composing with the ADR-0108 machinery without weakening its fail-safe.
-
New backend-local model
app/models/retention_clock.py(data shapes only, mirroring theregister_discrepancy/ai_act_conformitybackend-local precedent):RetentionState—active_relationship/retained_in_period/retention_expired_eligible_for_erasure.RetentionStartStatus—resolved(start known) /not_started(active relationship) /unknown(terminal but no closed timestamp).RetentionClock—state,start_status,retention_start,retention_expiry(statutory start + 5y),retain_until(effective, ≥ expiry after any hold),retention_years,eligible_for_erasure,basis(AMLR Art. 77(1)),hold,note.RetainedCategory+RetainedSetAssertion— the affirmative record: WHAT (the Art. 77(1) categories, each with its article limb + where it physically lives) + UNTIL WHEN (retain_until) + BASIS (AMLR Art. 77(1)).RetentionHold— the #549 seam (active,until,reason,basis), default absent. #548 never constructs an active hold; it only honours one, fail-closed.
-
New pure service
app/services/retention_clock_service.py:compute_retention_clock(case_status, case_closed_at, *, hold=None, now=None)— the clock. It reusesDSRService.determine_erasure_actionas the single source of the DELETE decision (eligible_for_erasureis defined asaction == DELETE), then enriches it with the start / expiry / state typing. Because eligibility is derived from the canonical policy, the clock can never be more permissive than the purge, and the purge's own per-case re-check (ADR-0108's fail-safe boundary) is untouched.build_retained_set_assertion(clock, *, case_id=None, now=None)— the assertion.compute_retention_clock_for_case(session, *, case_id, tenant_id=None)— a thin DB reader that derivescase_closed_at = updated_at if terminal else Noneexactly ashandle_erasuredoes (one source of truth for the closed-timestamp derivation).
Fail-closed invariants (GDPR Art. 5(1)(e) + AMLR Art. 77; ADR-0067 never-suppress):
- An active (non-terminal) relationship →
active_relationship/not_started,eligible_for_erasure=False, no expiry — the clock has not started, never expired. - A terminal case with no closed timestamp →
unknownstart →retained_in_period,eligible_for_erasure=False. Retention wins under uncertainty: an unknown clock is NEVER computed as expired/erasable. eligible_for_erasureisTrueonly whendetermine_erasure_action == DELETE(terminal AND closed ≥ 5y). It is neverTrueon an unknown or active clock.- The retained-set assertion is always explicit — the categories, the basis, and the
retain-until (or an honest "not yet fixed" note for
not_started/unknown) are stated, never a silent gap. - #549 seam, one-directional: an active hold can only EXTEND
retain_untiland BLOCK erasure (eligible_for_erasureforcedFalse, an expired state pulled back toretained_in_period). It can never shorten retention or enable erasure.
Decision context:
- Latency / cost: pure computation over two case columns; the DB reader is a single
indexed
SELECT status, updated_at. No external calls, no new writes. - Dependency surface: zero new packages. One backend-local model, one service, one test file. No migration — the clock and the assertion are derived, not stored.
- Reversibility: additive and unwired into any decision path — deleting the two files removes the feature with no schema or data impact.
- Blast radius:
determine_erasure_action,handle_erasure, andrun_retention_purgeare all UNCHANGED. The clock consumes the canonical policy; it does not replace it. The existing retention-purge tests are untouched and still pass. - Alternative considered: re-implement the 5-year cutoff inside the clock. Rejected — a second copy of the DELETE decision is exactly the divergence risk (two policies drifting) this programme exists to kill. The clock reuses the one canonical function.
Consequences
Positive
- A typed, per-case retention clock (start / expiry / state) and an affirmative Art. 77(1) retained-set assertion (what + until-when + basis) now exist as first-class, traceable records — the affirmative counterpart to the ADR-0108 erasure sweep.
- Fail-closed by construction: an unknown or active clock is never eligible for erasure; eligibility is derived from — and so can never outrun — the canonical policy.
- Clean, typed
RetentionHoldseam for #549 (legal holds + Art. 77(2) +5y cap); the honour logic is one-directional (extend / block only), so #549 cannot accidentally shorten retention.
Negative
- The assertion's retained-set categories are a curated mapping of Art. 77(1) onto the stores the system actually holds; the transaction-monitoring records limb is honestly marked as MLRO-owned / outside this system's scope rather than silently dropped.
Neutral
- Derived, not persisted — no migration, no new table. If a persisted assertion record is later required (e.g. to snapshot the retain-until at closure), that is a separate decision.
- Unwired into the decision/API path in #548; surfacing it (e.g. on the retention transparency endpoint or the case-pack) is follow-up.
Alternatives Considered
Alternative 1: Re-implement the 5-year cutoff inside the clock
- Compute the DELETE decision independently in
compute_retention_clock. - Why rejected: two copies of the erasure-eligibility decision would drift. The clock reuses
determine_erasure_action, so the clock and the purge agree by construction and the fail-safe stays the single source of truth.
Alternative 2: Persist the clock / assertion in a new table
- Snapshot start / expiry / retained-set per case at closure.
- Why rejected (for #548): the inputs (
status,updated_at) already exist and the computation is pure and cheap; persisting adds a migration and a staleness surface for no present consumer. Deferred until a consumer needs a frozen snapshot (e.g. a hold that must pin the pre-extension expiry).
Alternative 3: Fold the hold/extension logic into #548
- Build legal holds and the +5y cap now.
- Why rejected: that is the scope of the sibling #549. #548 provides only the typed
RetentionHoldseam and the one-directional honour logic, keeping this change bounded.