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_versionbump; 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:
- 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.
- Double-count. The mass-registration limb already raises
network.shared_address_risk. Within-dimension aggregation ismax()and the ADR-0020 floor ismax_dim × 0.85, so a signal in both dimensions propagates twice. - It needs new
EBARiskInputfields, which enter_input_to_dict→input_hashandscoring_runs.payload["inputs"]. Every run would hash differently at identical risk. - 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:299writesnetwork_scan— a key in neitherEBA_WEIGHTSnor_MAX_SCORES. It is persisted intorisk_assessments.factor_scoresand 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 configuration —
compute_eba_riskreadsdimension_weights,risk_levels,critical_dimension_threshold,floor_boost_factorandauto_escalation_rulesfrom it, none of which is in the ADR-0124 pin set. Two runs with identical pins can therefore score differently. Pinning it bumpsRUN_HASH_SCHEMAand is its own change. - The five
eba_referenceparagraph 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_LABELSis 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.