Skip to main content

ADR-0137: Typed nine-measure CDD register (AMLR Art. 20(1)(a)-(i)) + (h) beneficiary + (i) representative-authority

Date: 2026-07-25 Status: Accepted Deciders: Adrian (Soft4U BV), Claude Opus 4.8 (implementation agent)

Decision context:

  • Latency: not measured — compute_cdd_register is a pure in-memory pass over one CddCoverageInput that internally reuses interpret_cdd_coverage (already unit-latency); no DB/graph/network calls added. Safe to defer.
  • Dependency surface: none new. Five typed models added to the existing trustrelay-models package (re-exported via app/models/cdd_register.py); no third-party packages.
  • Debuggability: the register is a pure function of a single dataclass. A wrong state is reproduced by constructing the same CddCoverageInput — no fixtures, no graph. Each measure carries its own article_ref, evidence, gap, and source_sections provenance.
  • Reversibility: high. The register is additive and unwired into any consumer; deleting compute_cdd_register + the model file + the four new CddCoverageInput fields restores the prior state with no migration and no behaviour change (interpret_cdd_coverage is byte-unchanged).
  • Blast radius: additive only. The legacy §2(a)-(e) map, get_amlr_coverage, and all its consumers are untouched; no persisted schema; no runtime path calls the register yet (consumers are separate surface issues).
  • Alternative considered: extend the existing dict-shaped §2(a)-(e) map with four more §2(x) keys — rejected because a loose dict has no typed state enum, cannot distinguish not_applicable from not_assessed, and would silently break every reader that iterates the five fixed keys.

Context

AMLR (Reg. (EU) 2024/1624) Art. 20(1) enumerates nine customer-due-diligence measures — (a) through (i) — that an obliged entity must apply. Verbatim from the adopted text (verified against the Lex corpus, lex_articles regulation AMLR, article 20): (a) identify + verify the customer; (b) identify beneficial owners + verify + understand ownership/control structure; (c) purpose & intended nature; (d) targeted-financial-sanctions verification incl. the >50% proprietary-rights / majority-interest control test; (e) nature of the customer's business / occupation; (f) ongoing monitoring; (g) PEP / family member / close associate determination; (h) where a transaction is conducted on behalf of or for the benefit of natural persons other than the customer, identify + verify those persons (the beneficiary measure); (i) verify that any person purporting to act on behalf of the customer is so authorised, and verify their identity (the representative-authority measure).

The existing CDD coverage engine (app/services/amlr_section_c.py, interpret_cdd_coverage) models only five sections — the legacy §2(a)..§2(e) labels — which map to a subset of the nine measures and lack (d), (f), (h), and (i) entirely. In particular there was no representation at all for a life-insurance / legal-arrangement beneficiary (h) or for a power-of-attorney / mandate holder acting on the customer's behalf (i). The §2(x) map is also a loose dict[str, dict] with a per-section float coverage; it has no typed measure state, so it cannot express the regulatorily-important distinction between a measure that genuinely does not apply (no representative → (i) not applicable) and a measure that applies but was not evidenced (a gap). Collapsing those two into "0% coverage" both over- and under-states depending on the reader.

The AMLR readiness SRS (docs/superpowers/specs/2026-07-24-amlr-compliance-srs.md, FR-CDD-03) requires the nine measures be "modelled as distinct, individually-evidenced measures ... none complete without evidence." The assistant, the regulator-ready case-pack PDF, and the dashboard will each need to report per-measure CDD status; today they would each re-derive it from the loose map, re-introducing the claim-vs-check defect class (an artifact reporting a control's shape instead of its state).

Decision

Add a typed nine-measure CDD register as the canonical, structured record of which Art. 20(1)(a)-(i) measures were applied and their evidence — the single "shared oracle" the assistant / PDF / UI consumers will read (those consumers are separate surface issues; this ADR covers only the register + its pure computation).

Typed models in trustrelay-models (trustrelay_models/cdd_register.py, re-exported via app/models/cdd_register.py):

  • CddMeasureState — a four-value enum satisfied / partial / not_assessed / not_applicable. not_applicable is strictly distinct from not_assessed and always carries a stated reason.
  • CddMeasuremeasure_id (a..i), article_ref ("AMLR Art. 20(1)(x)"), verbatim title, state, coverage (0.0-1.0), evidence (provenance strings), gap (honest marker, "" when none), not_applicable_reason, and source_sections (which legacy §2(x) section(s) mapped in — [] for the four measures the 5-section model lacked).
  • CddRegister — the ordered nine measures plus computed roll-ups (applicable_count, satisfied_count, not_assessed_count, overall_coverage); not_applicable measures are excluded from the coverage denominator.
  • RepresentativeAuthority (input for (i)) and BeneficiaryIdentification (input for (h)).

A pure compute_cdd_register(inp: CddCoverageInput) -> CddRegister in amlr_section_c.py derives the register. It reuses interpret_cdd_coverage internally, so the legacy map and the register can never diverge, and maps:

MeasureSource
(a) identify + verify customer§2(a)
(b) BO + ownership/control structure§2(c) and §2(e) (two sections → one measure)
(c) purpose & intended nature§2(d) stated purpose (+ EDD SoF/SoW)
(d) targeted financial sanctionsnew — fail-closed not_assessed unless evidenced
(e) nature of business / occupation§2(d) NACE sector (one section → measures (c) and (e))
(f) ongoing monitoringnew — fail-closed not_assessed
(g) PEP / family / close associate§2(b) director/senior-management screening
(h) beneficiarynew — from BeneficiaryIdentification, fail-closed
(i) representative authoritynew — from RepresentativeAuthority, fail-closed

Fail-closed / presence-≠-evidence discipline (ADR-0067):

  • A measure with no evidence reads not_assessed, never a silent satisfied. The four new measures on an empty input are all not_assessed.
  • (h) and (i) require real verified evidence: a bare has_representative=True / relationship_has_beneficiary=True flag never satisfies the measure; satisfied requires both the authority/beneficiary AND the identity to be verified.
  • not_applicable is produced only when the presence question was assessed and the concept genuinely does not apply (no representative → (i) n/a; no beneficiary other than the customer → (h) n/a), always with a not_applicable_reason and never with a gap. It is never used to hide an unassessed gap.
  • Measure (d) does not treat a director-only screen as sanctions coverage — director screening is surfaced as supporting context but the customer/BO targeted-sanctions screen + >50% control test remain the obligation, so (d) stays not_assessed (never over-stated).

CddCoverageInput gains four additive, defaulted fields (sanctions_screening_evidenced, ongoing_monitoring_evidenced, beneficiary, representative) consumed only by compute_cdd_register. interpret_cdd_coverage ignores them, so its output is byte-identical.

Consequences

Positive

  • The nine Art. 20(1) measures are individually typed with state + article ref + evidence + provenance — the SRS FR-CDD-03 acceptance ("all nine separately tracked with typed outcomes + provenance; none complete without evidence").
  • The regulatorily-material not_applicable vs not_assessed distinction is now first-class and machine-checkable.
  • (h) beneficiary and (i) representative authority — previously unrepresentable — are now modelled and fail-closed.
  • One typed oracle for downstream consumers kills the claim-vs-check re-derivation risk.

Negative

  • The register maps §2(b) "directors & senior management" screening to measure (g) PEP. This is the closest available persons-screening datapoint but is an operational mapping (directors are not necessarily the customer/BO the letter names); it errs toward crediting a real screen and is documented in code. When a dedicated customer/BO PEP-screen datapoint is wired, (g) should read that instead.
  • Measures (d), (f), (h), (i) require the caller to supply evidence that this pure engine does not itself gather; until the wiring issues land they will read not_assessed in production, which is honest but means the register under-states real coverage that exists elsewhere in the pipeline (sanctions screening, monitoring).
  • Two coverage representations now coexist (the legacy §2(x) map and the register). They are kept consistent by construction (the register reuses the map), but a future editor must preserve that coupling.

Neutral

  • No migration and no persisted schema — the register is pure compute over existing inputs.
  • The register is unwired into any consumer; it has zero runtime effect until the separate surface issues consume it.

Alternatives Considered

Alternative 1: Extend the existing §2(a)-(e) dict with four more §2(x) keys

  • Add §2(f)..§2(i) entries to the map returned by interpret_cdd_coverage.
  • Why rejected: the map is a loose dict[str, dict] with a float coverage and no typed state — it cannot express not_applicable distinctly from not_assessed, has no enforced article ref/evidence contract, and every existing reader iterates the five fixed keys (get_amlr_coverage hardcodes ("§2(a)", "§2(b)", "§2(c)", "§2(d)", "§2(e)")), so adding keys would silently change roll-ups and break byte-compatibility.

Alternative 2: Replace interpret_cdd_coverage outright with the register

  • Have get_amlr_coverage and all consumers move to the typed register in one step.
  • Why rejected: get_amlr_coverage's article_28 map is consumed by the case-pack, the compliance-docs builder, the dashboard agent, and the graph API. A same-PR replacement is a large substitutive blast radius for a change whose consumers are explicitly scoped to separate issues; the additive superset lets those migrate independently while the legacy output stays byte-stable.