ADR-0133: Effective-dated, category-aware BO threshold with per-computation snapshot
Date: 2026-07-25 Status: Accepted Deciders: Adrian (Soft4U BV), Claude Opus 4.8 (implementation agent)
Decision context:
- Latency: not measured — the resolver reads an already-cached reference dataset
(
get_reference_data().get_dataset("ubo_thresholds")) and does twodatecomparisons + at most twoDecimal(str(v))/Decimal(100)divisions. Negligible against the Neo4j ownership-graph read that dominatesUBOComputationService.compute. No user-visible latency change. - Dependency surface: zero new packages —
datetime/decimalare stdlib. One new Pydantic model (ThresholdSnapshot) and one optional field onBeneficialOwnerResultin the editable-installedtrustrelay-modelspackage; consumers pick it up with no reinstall. - Debuggability: the resolver returns a single
ThresholdSnapshotnaming the exactrulebranch (jurisdiction/default/pre_effective_predecessor/high_risk_override/explicit_override), theas_ofdate, the legal basis, and whether the override fired. A wrong determination is answerable from the persisted snapshot alone ("which threshold ran, and why?") without re-deriving. The never-raise guard logsubo_high_risk_override_not_stricter_refusedwhen it refuses a misconfigured override. - Reversibility: additive. The snapshot field defaults
None; the resolver keeps the backward-compatible 3-tupleresolve_ubo_threshold. Reverting is deleting the two new keyword args on.computeand the snapshot stamp — a few-line change, no migration. - Blast radius: additive-with-defaults.
resolve_ubo_threshold's 3-tuple contract is preserved (new params are keyword-only with defaults), so its five existing callers and the engine/service tests are unchanged in behaviour..computegains two keyword-only params (as_of,high_risk), wired only in the two call sites that carry the risk signal (monitoring ownership-change,record_no_bo_identified); the coverage-read call sites keep the safe default. The snapshot rides the existingubo_computations.resultsJSONB — no new column, no migration. - Alternative considered: persist the snapshot to a new
ubo_computationscolumn — rejected because the append-onlyresultsJSONB already carries per-resultthreshold_pct, so nesting the snapshot there needs no schema change and keeps the determination and its threshold rule in one row.
Context
Issue #542 (AMLR readiness epic #528, Wave 2 — BO graph) closes a data-honesty gap in the
UBO ownership threshold. The config/reference_data/ubo_thresholds.json dataset already
carried two fields the resolver ignored:
-
effective_dateon every jurisdiction entry — e.g."2027-07-10"for the AMLR (Reg. (EU) 2024/1624) go-live, ornullfor a national regime (Swiss AMLA/GwG, UK PSC) already in force.resolve_ubo_threshold(country)read onlyvalue/inclusive/legal_basis, so it applied the AMLR rule and cited AMLR Art. 51 unconditionally — including for a computation dated before the AMLR is in force. That is the presence-≠-in-force defect: citing a regulation that is not yet applicable, and producing a determination that cannot be reproduced against its contemporaneous law. -
high_risk_override(top-level:{"value": 15.0, "inclusive": true, "applies_by_default": false, "effective_date": null}) — the AMLR Art. 52(2) higher-risk lower threshold (15% rather than the default 25%). The resolver never read it, so a genuinely high-risk case was assessed at 25% and could miss a beneficial owner holding between 15% and 25%.
Neither gap was a code-shape problem — the data was correct; the resolver simply did not read it. AMLR Art. 52(2) provides that Member States (and, for higher-risk situations, the obliged entity's risk-based approach) may set a lower percentage than the 25% baseline; the dataset encodes 15% as the higher-risk figure. A UBO determination is an EU AI Act Art. 12 traceable output — it must record which threshold rule was applied and be reproducible against that rule.
The already-correct Decimal arithmetic (#540 / ADR-0130 — exact Decimal, never
Decimal(float)) and typed-absence handling must be preserved: the threshold stays a
Decimal at the engine boundary.
Decision
Make the UBO threshold effective-dated and entity-category-aware, and return a reproducible per-computation snapshot of the rule that was applied.
1 — Effective-dating (resolve_threshold_snapshot(country, *, as_of, high_risk))
The resolver selects the threshold in force as of a computation date (as_of,
date | datetime | None, defaulting to today). A datetime is reduced to its calendar
date (the threshold is dated to the day, not the instant).
- A jurisdiction entry whose
effective_dateisnullor<= as_ofis in force — itsvalue/inclusive/legal_basisapply (rule = "jurisdiction"). - Pre-effective predecessor fallback (documented interpretation): when an
AMLR-harmonised entry's
effective_dateis afteras_of(the common case today, since the AMLR applies 2027-07-10 and the current date is earlier), the not-yet-in-force AMLR value is not silently applied. The resolver applies the contemporaneous predecessor — Directive (EU) 2015/849 (AMLD 4th) Art. 3(6), whose beneficial-owner threshold is also 25% inclusive — and labels it honestly (rule = "pre_effective_predecessor",legal_basisciting AMLD 2015/849 Art. 3(6), and noting the AMLR is not yet in force). The applied numeric threshold is identical (25% inclusive), so qualification is byte-unchanged; only the citation becomes contemporary. National regimes witheffective_date = null(CH, GB) are never routed through this fallback. This is the honest resolution of "the data carries one future-dated entry": theeffective_dategates when the AMLR citation is correct; before it, the in-force predecessor (same value) governs.
2 — Entity-category-aware high-risk override (Art. 52(2), only LOWERS)
When the case is a genuinely high-risk category, the resolver applies the
high_risk_override on top of the effective-dated base — subject to two guards:
- Its own
effective_datemust be in force (null= always). A future-dated override does not apply before its date. - Never-suppress guard: the override is applied only when it is stricter (lower)
than the currently selected threshold. A misconfigured or higher override
(
override_fraction >= base_fraction) is refused — the case keeps the stricter default — and the refusal is logged. Lowering the threshold only ever ADDS scrutiny (catches more beneficial owners); it can never raise the bar above the standard threshold, and an unknown/non-high-risk category keeps the standard default (never a fabricated value).
How the high-risk category is signalled + wired. High-risk is the same signal ADR-0129
(#537, the sibling Wave-2 register-discrepancy control) already uses: the pure
register_discrepancy_service.is_higher_risk(review_tier, risk_level) — an EDD review
tier or a HIGH/CRITICAL composite risk level. It is threaded into the resolver via a
new high_risk: bool param on UBOComputationService.compute, wired at the two call sites
that carry the risk signal:
- the monitoring ownership-change re-compute (
check_ownership_change, which has the monitoredrisk_tier—EDD/CDD/SDD), and record_no_bo_identified(which has the investigationrisk_assessment.risk_level/review_tier).
The AMLR-coverage read (GraphService.get_amlr_coverage, persist=False) is left at the
safe default (25%) — it is a CDD-field-coverage measurement keyed on registration number
that does not carry the case risk tier; per the never-fabricate rule an unknown category
takes the standard default. Threading the tier into that read is a tracked follow-up.
3 — Per-computation snapshot (reproducibility)
resolve_threshold_snapshot returns a ThresholdSnapshot (new trustrelay-models model)
capturing value_pct, the exact 0-1 fraction (Decimal — the compare value, #540),
inclusive, the applied legal_basis, the applied rule's effective_date,
high_risk_input, high_risk_override_applied, the as_of date, and the rule branch.
UBOComputationService.compute stamps the snapshot onto every BeneficialOwnerResult
(new optional threshold_snapshot field), so the append-only ubo_computations.results
JSONB records exactly which effective-dated + category-aware rule ran — no new column,
no migration. An explicit caller-supplied ubo_threshold produces an
explicit_override snapshot so the audit trail never shows an ignored resolved rule.
Reproducibility (BR-2 spirit): identical (country, as_of, high_risk) inputs always
produce an identical snapshot — the only clock input is the as_of day, and all arithmetic
is Decimal(str(v)) (never Decimal(float)). resolve_ubo_threshold remains as a
backward-compatible 3-tuple wrapper delegating to the snapshot.
Consequences
Positive
- A historical UBO determination reproduces against its contemporaneous threshold, and the persisted snapshot answers "which threshold rule ran, and why?" for the audit trail (EU AI Act Art. 12).
- A genuinely high-risk case now catches beneficial owners in the 15–25% band that the flat 25% missed (AMLR Art. 52(2)) — strictly more scrutiny.
- Today's determinations cite the in-force law (AMLD predecessor) rather than a not-yet-applicable AMLR article, with the identical 25% value.
- The never-raise guard makes "the override only lowers" structural, not a convention.
Negative
- The persisted
legal_basisfor a computation dated before 2027-07-10 now reads the AMLD predecessor citation rather than AMLR Art. 51 (the value/inclusive/qualification are identical). This is the intended honesty change, but a reader expecting the AMLR citation on a pre-2027 record must understand the effective-dating. (No prior surface persisted the basis — the service discarded the tuple's third element — so there is no data regression.) ThresholdSnapshotis duplicated onto everyBeneficialOwnerResultin a computation (they share one threshold), mirroring the existing per-resultthreshold_pctduplication.- The high-risk signal is wired at two of the three
.computecall sites; the coverage read keeps the safe default (a documented, tracked follow-up rather than a fabricated value).
Neutral
resolve_ubo_threshold's 3-tuple contract and the engine's Decimal threshold boundary are unchanged; the new params are keyword-only with defaults.- The pre-effective fallback is a no-op for national regimes (
effective_date = null).
Alternatives Considered
Alternative 1: Apply the AMLR value now, treat effective_date as informational
- Keep citing AMLR Art. 51 today and store
effective_dateonly as metadata. - Why rejected: it applies a threshold under a regulation that is not yet in force and makes a historical determination irreproducible against its contemporaneous law — the exact presence-≠-in-force defect #542 exists to close. The AMLD predecessor (same 25% value) is the honest contemporary citation.
Alternative 2: Persist the snapshot in a new ubo_computations column
- Add a dedicated JSONB/typed column for the applied threshold rule.
- Why rejected: the append-only
resultsJSONB already carries per-resultthreshold_pct; nesting the snapshot there records the rule in the same row with no migration. A new column would be schema churn for data that belongs with the determination it governed.
Alternative 3: Derive high-risk inside the resolver from case state
- Have the resolver fetch the case risk tier itself.
- Why rejected: the resolver is a pure reference-data function with no DB/case context;
coupling it to case state would make it un-unit-testable and duplicate the ADR-0129
is_higher_risksignal. Passinghigh_risk: boolas an input keeps the resolver pure and reuses the established signal at the call sites that already hold it.