Skip to main content

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:

  1. The retention clockwhen 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 of determine_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?"
  2. 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.

  1. New backend-local model app/models/retention_clock.py (data shapes only, mirroring the register_discrepancy / ai_act_conformity backend-local precedent):

    • RetentionStateactive_relationship / retained_in_period / retention_expired_eligible_for_erasure.
    • RetentionStartStatusresolved (start known) / not_started (active relationship) / unknown (terminal but no closed timestamp).
    • RetentionClockstate, 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.
  2. New pure service app/services/retention_clock_service.py:

    • compute_retention_clock(case_status, case_closed_at, *, hold=None, now=None) — the clock. It reuses DSRService.determine_erasure_action as the single source of the DELETE decision (eligible_for_erasure is defined as action == 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 derives case_closed_at = updated_at if terminal else None exactly as handle_erasure does (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 → unknown start → retained_in_period, eligible_for_erasure=False. Retention wins under uncertainty: an unknown clock is NEVER computed as expired/erasable.
  • eligible_for_erasure is True only when determine_erasure_action == DELETE (terminal AND closed ≥ 5y). It is never True on 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_until and BLOCK erasure (eligible_for_erasure forced False, an expired state pulled back to retained_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, and run_retention_purge are 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 RetentionHold seam 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 RetentionHold seam and the one-directional honour logic, keeping this change bounded.