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_registeris a pure in-memory pass over oneCddCoverageInputthat internally reusesinterpret_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-modelspackage (re-exported viaapp/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 ownarticle_ref,evidence,gap, andsource_sectionsprovenance. - Reversibility: high. The register is additive and unwired into any consumer; deleting
compute_cdd_register+ the model file + the four newCddCoverageInputfields restores the prior state with no migration and no behaviour change (interpret_cdd_coverageis 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_applicablefromnot_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 enumsatisfied/partial/not_assessed/not_applicable.not_applicableis strictly distinct fromnot_assessedand always carries a stated reason.CddMeasure—measure_id(a..i),article_ref("AMLR Art. 20(1)(x)"), verbatimtitle,state,coverage(0.0-1.0),evidence(provenance strings),gap(honest marker, "" when none),not_applicable_reason, andsource_sections(which legacy §2(x) section(s) mapped in —[]for the four measures the 5-section model lacked).CddRegister— the ordered ninemeasuresplus computed roll-ups (applicable_count,satisfied_count,not_assessed_count,overall_coverage);not_applicablemeasures are excluded from the coverage denominator.RepresentativeAuthority(input for (i)) andBeneficiaryIdentification(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:
| Measure | Source |
|---|---|
| (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 sanctions | new — fail-closed not_assessed unless evidenced |
| (e) nature of business / occupation | §2(d) NACE sector (one section → measures (c) and (e)) |
| (f) ongoing monitoring | new — fail-closed not_assessed |
| (g) PEP / family / close associate | §2(b) director/senior-management screening |
| (h) beneficiary | new — from BeneficiaryIdentification, fail-closed |
| (i) representative authority | new — from RepresentativeAuthority, fail-closed |
Fail-closed / presence-≠-evidence discipline (ADR-0067):
- A measure with no evidence reads
not_assessed, never a silentsatisfied. The four new measures on an empty input are allnot_assessed. - (h) and (i) require real verified evidence: a bare
has_representative=True/relationship_has_beneficiary=Trueflag never satisfies the measure;satisfiedrequires both the authority/beneficiary AND the identity to be verified. not_applicableis 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 anot_applicable_reasonand never with agap. 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_applicablevsnot_assesseddistinction 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_assessedin 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 byinterpret_cdd_coverage. - Why rejected: the map is a loose
dict[str, dict]with a floatcoverageand no typed state — it cannot expressnot_applicabledistinctly fromnot_assessed, has no enforced article ref/evidence contract, and every existing reader iterates the five fixed keys (get_amlr_coveragehardcodes("§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_coverageand all consumers move to the typed register in one step. - Why rejected:
get_amlr_coverage'sarticle_28map 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.