Skip to main content

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 two date comparisons + at most two Decimal(str(v))/Decimal(100) divisions. Negligible against the Neo4j ownership-graph read that dominates UBOComputationService.compute. No user-visible latency change.
  • Dependency surface: zero new packages — datetime/decimal are stdlib. One new Pydantic model (ThresholdSnapshot) and one optional field on BeneficialOwnerResult in the editable-installed trustrelay-models package; consumers pick it up with no reinstall.
  • Debuggability: the resolver returns a single ThresholdSnapshot naming the exact rule branch (jurisdiction / default / pre_effective_predecessor / high_risk_override / explicit_override), the as_of date, 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 logs ubo_high_risk_override_not_stricter_refused when it refuses a misconfigured override.
  • Reversibility: additive. The snapshot field defaults None; the resolver keeps the backward-compatible 3-tuple resolve_ubo_threshold. Reverting is deleting the two new keyword args on .compute and 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. .compute gains 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 existing ubo_computations.results JSONB — no new column, no migration.
  • Alternative considered: persist the snapshot to a new ubo_computations column — rejected because the append-only results JSONB already carries per-result threshold_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:

  1. effective_date on every jurisdiction entry — e.g. "2027-07-10" for the AMLR (Reg. (EU) 2024/1624) go-live, or null for a national regime (Swiss AMLA/GwG, UK PSC) already in force. resolve_ubo_threshold(country) read only value/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.

  2. 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_date is null or <= as_of is in force — its value/inclusive/legal_basis apply (rule = "jurisdiction").
  • Pre-effective predecessor fallback (documented interpretation): when an AMLR-harmonised entry's effective_date is after as_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_basis citing 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 with effective_date = null (CH, GB) are never routed through this fallback. This is the honest resolution of "the data carries one future-dated entry": the effective_date gates 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_date must 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 monitored risk_tierEDD/CDD/SDD), and
  • record_no_bo_identified (which has the investigation risk_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_basis for 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.)
  • ThresholdSnapshot is duplicated onto every BeneficialOwnerResult in a computation (they share one threshold), mirroring the existing per-result threshold_pct duplication.
  • The high-risk signal is wired at two of the three .compute call 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_date only 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 results JSONB already carries per-result threshold_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_risk signal. Passing high_risk: bool as an input keeps the resolver pure and reuses the established signal at the call sites that already hold it.