Skip to main content

ADR-0160: The EBA matrix numbers are ours, and there is exactly one of them

Date: 2026-08-01 Status: Accepted Deciders: Adrian (Soft4U BV), Claude Opus 5 Amends: ADR-0020 (EBA risk matrix with weighted-max aggregation)

Context

Three defects in eba_risk_matrix.py and its neighbours, all one class — a scoring artifact making a claim nothing checked.

1. False provenance. :65 read # Dimension weights from eba_standard_v1.yaml, plus four (from EBA schema) claims on factor maxima, thresholds, match types and age bands. Nothing loads a YAML. The file that name refers to is tracked at docs/research/atlas-extraction/eba_standard_v1.yaml — misfiled as a research artifact — and it declares five dimensions, matching not one of the seven live weights (YAML: customer 0.30 / geographic 0.25 / product_service 0.20 / delivery_channel 0.10 / transaction 0.15, with no network and no temporal).

EBA/GL/2021/02 publicly teaches the four risk-factor categories. It assigns no numeric weight, maximum or threshold to any of them. Every number in this matrix is ours.

2. A second weights literal. risk_matrix_service.py:37-45 redeclared the seven dimension weights — identical in value to EBA_WEIGHTS, independent in fact. It is load-bearing: the one-way ratchet (for key in WEIGHTS) and the projection feeding _aggregate_weighted_max both key off the local copy. A weight or dimension edit in the scorer left reassess() silently scoring on the old set. The module's header comment claims single-sourcing — true for MATRIX_VERSION, false for the weights eight lines below.

3. A third implementation. trustrelay_engines/risk_matrix.py carried MATRIX_VERSION = "eba_standard_v1" (stale), a delivery_channel map missing intermediary_distribution (ADR-0086), CRITICAL_DIMENSION_THRESHOLD 80.0 vs 85.0 and FLOOR_BOOST_FACTOR 0.60 vs 0.85 — and nothing imported it.

Decision

Say what the numbers are. All five false-provenance sites now state that the categories are EBA/GL/2021/02's and the numbers are ours, and that network and temporal are ours with no EBA paragraph — they must never be given a borrowed GL range.

One declaration. risk_matrix_service.WEIGHTS now is eba_risk_matrix.EBA_WEIGHTS. Byte-neutral at the point of change; the guard asserts identity, not equality, because dict(EBA_WEIGHTS) is equal and still diverges on the next scorer-side edit.

Delete the third copy, no shim — ADR-0122 precedent, applied to a scorer rather than a registry. A second scorer no test exercises is a second answer nobody compares.

Consequences

Positive

  • An auditor reading the matrix is told which numbers are law and which are judgement.
  • A weight edit can no longer reach the scorer without reaching the ratchet.
  • The drift that served intermediary_distribution-blind scores is gone.

Negative

  • The judgement is now explicit and therefore contestable. That is the point, but it does invite the question "why 0.25 for customer?", which this ADR does not answer with evidence — only with the record that it is ours.

Neutral

  • No score changes. No factor id added, removed or renamed; no threshold moved; no matrix_version bump; no migration.

Explicitly NOT done, and why

address_risk was not restored. The issue framed it as a dropped factor. Four reasons against, in order of weight:

  1. The YAML it is "missing" from is the superseded 5-dimension original (ADR-0020's 2026-06-28 revision note records the 5→7 evolution). Comparing the implementation to it measures against an obsolete artifact.
  2. Double-count. The mass-registration limb already raises network.shared_address_risk. Within-dimension aggregation is max() and the ADR-0020 floor is max_dim × 0.85, so a signal in both dimensions propagates twice.
  3. It needs new EBARiskInput fields, which enter _input_to_dictinput_hash and scoring_runs.payload["inputs"]. Every run would hash differently at identical risk.
  4. Without those inputs wired it lands as a permanently-zero factor beside the existing regulatory_status = 0.0 — the exact ADR-0067 defect this issue exists to catch.

The registered-address signal is not lost: it is implemented as findings (registered_office_premises.py, virtual_office.py, pure_mailbox_detector.py, ADR-0041) and scores via network.shared_address_risk.

Recorded, not fixed here

  • risk_matrix_service.py:299 writes network_scan — a key in neither EBA_WEIGHTS nor _MAX_SCORES. It is persisted into risk_assessments.factor_scores and then dropped by the weighted projection. Persisted but never weighted. Recorded so an audit reading live rows does not mistake it for a matrix factor; fixing it is scoring-affecting.
  • The un-hashed lever is the tenant's active risk configurationcompute_eba_risk reads dimension_weights, risk_levels, critical_dimension_threshold, floor_boost_factor and auto_escalation_rules from it, none of which is in the ADR-0124 pin set. Two runs with identical pins can therefore score differently. Pinning it bumps RUN_HASH_SCHEMA and is its own change.
  • The five eba_reference paragraph anchors should route through the ADR-0119 resolver / ADR-0121 semantic gate, not be pasted as comments — so a drifted anchor fails a gate instead of sitting in prose.
  • frontend/RiskSpiderChart.tsx:29-37 DIMENSION_LABELS is a third dimension declaration; a new dimension renders unlabelled.

Alternatives Considered

Alternative 1: correct the comment to point at docs/research/atlas-extraction/

  • Rejected. The file disagrees with every live value, so citing it would be a worse claim than the original — precise, checkable, and false.

Alternative 2: keep the engines copy and sync it

  • Rejected on ADR-0122's reasoning: the dependency direction means a shim can only re-export the drifted copy, and a copy nothing imports has no consumer to serve.

Alternative 3: assert weight EQUALITY between the two modules instead of identity

  • Rejected, and mutation-tested: dict(EBA_WEIGHTS) satisfies equality and still diverges on the next edit. Identity is the property that protects the ratchet.