ADR-0124: Deterministic, hash-audited risk scoring with frozen reproducibility snapshots
Date: 2026-07-24 Status: Accepted Deciders: Adrian (Soft4U BV), Claude (Opus 4.8) Extends: ADR-0020 (weighted-max EBA matrix), ADR-0089 (entity-risk ratchet), ADR-0092 (audited risk config), ADR-0103 (OpenSanctions bulk), ADR-0109 (append-only audit hash chain), ADR-0123 (deterministic golden-record resolution)
Context
Epic #511 raises the OSINT engine from real & honest to real, honest & auditable. Issue #515 is the risk-scoring pillar: EBA/GL/2021/02 requires scoring to be versioned configuration, not LLM output, reproducible over time, with reference lists frozen at source + date so a score recomputes identically later.
A full five-map read of the scoring surface established the ground truth (not a greenfield):
- The EBA scorer (
eba_risk_matrix.compute_eba_risk) is already a pure, deterministic function and already emits aninput_hash/output_hash. So #515 is not "make scoring deterministic" — it is "make a scoring run reproducible over time." - But reproducibility fails five ways: (1) three conflicting
MATRIX_VERSIONstrings, none stamped on the result;output_hashcovers only{dimensions, overall_score, risk_level}, with noconfig_hashand no combined run hash. (2) Reference lists are mutable JSON files loaded into a process-lifetime singleton, and theirsource/source_url/source_dateenvelope is stripped at load;risk_configurationsis unhashed. (3) The matcher knobs upstream of the matrix are unversioned — four matchers, four threshold sets, two collidingHIT_THRESHOLDvalues (0.90 vs 0.85), and the OpenSanctions bulk dataset is pulled from alatest/URL that DELETE+re-INSERTs daily/weekly with no version captured — so the same subject flips hit↔clear across a refresh, invisibly. (4) The LLMrisk_score(pre-floor) still drives the AI recommendation indecision_service.py:296, in parallel with the authoritative deterministic EBArisk_assessment. (5) Normalization is deterministic but unversioned — a text-cleaning drift shifts both the survivorship winner and the score hash with nothing to flag it.
The critical, non-obvious insight (product owner): "same matrix version + reference snapshot → identical hash" is a false guarantee unless the matcher config, dataset version, and normalization version are snapshotted too. The structured record fixes provenance, but the match decision still has a knob, and knobs need snapshots.
Decision
A scoring run reproduces iff every input that can change the output is either hashed or referenced by an immutable snapshot ID. We adopt that boundary and enforce it:
-
The run hash covers the whole reproducibility boundary, not just the numeric input:
run_hash = H(canonical({schema, engine_version, matrix_version, reference_snapshot_ids, matcher_config_version, dataset_snapshot_id, normalizer_versions, inputs})). -
One canonical, upgrade-surviving serializer (
canonical_json): sorted keys, compact separators,ensure_ascii=False,Decimal→ fixed-point strings for every weight/score/threshold (floats are not a stable cross-version contract), and an encoder that raises on non-canonical types (neverstr()-coerces). Timestamps are excluded from the hashed payload (thegolden_record_hashprecedent). Aschemaversion guards the canonicalization format itself; anengine_versionis hand-bumped on scoring-logic change, never derived frompip freeze, so a dependency patch cannot churn the hash. This replaces the four divergent hash one-liners in the codebase. -
Frozen reference snapshots in a new append-only RLS
reference_snapshotstable (list_key,data,source,source_url,source_date,content_hash), fixing the provenance-stripping at load and at config-embed. Each score records the snapshot IDs it read; a live edit cannot change a past score's inputs. -
Matcher-config + dataset snapshots — the reproducibility hole the knobs and the
latest/dataset reintroduce. All matcher thresholds (name-similarity,HIT_THRESHOLD, FP-suppression, pg_trgm cutoffs) plus therapidfuzz/pg_trgm library versions are frozen as amatcher_configsnapshot (matcher_config_version); the OpenSanctions bulk CSV is content-hashed at ingest (dataset_snapshot_id). #515 snapshots these; it does not consolidate the four matchers or resolve the 0.90/0.85 collision (corrective work for #513/#518). -
Two-output, versioned normalization:
NormalizedValue{canonical, match_key, raw, normalizer_version}. Survivorship compares onmatch_key(so casing/diacritic variants are not counted as conflicts) but survivescanonical(stored/displayed).normalizer_versionsenter the run hash. -
The deterministic EBA score becomes authoritative everywhere; the LLM
risk_scoreis demoted to a labelledraw_synthesis_risk_score(generalizing the pattern already coded incompliance_case._build_investigation_completed_details).decision_service.py:296— the one live decision path still keyed to the LLM float — is repointed at the floored EBA composite. All three monotonic ratchets (ADR-0089) and the deterministic escalation floors are preserved; demotion can only raise or hold, never lower. -
Source-class protected-field authority (refining ADR-0123/#516):
FieldAuthoritykeyed on aSourceClassenum (classify a source once in the connector contract, not per field), withon_no_authorized_claim="not_assessed"as the explicit fail-closed hinge, and integrity events routed to alerting. -
The discipline is enforced by CI, not aspiration: one golden fixture per lever (normalizer / matrix / matcher / snapshots / engine), an end-to-end backstop (green levers + red e2e = an un-versioned input = a determinism bug), a coverage meta-guard (a new lever without a fixture fails to merge), and a fail-closed startup assertion (refuse to boot against a phantom version). Dark-launched behind
hash_audited_scoring_enabled.
Decision context:
- Latency: pure in-process hashing over ~30 fields; sub-millisecond. Snapshot lookups are indexed reads. Not on the scoring hot path in a way that changes p95.
- Dependency surface: no new packages; reuses
hashlib/json/Decimal; one new Alembic table (reference_snapshots) +scoring_runs. - Debuggability: a run's hash + snapshot IDs +
scoring_runsrow make a score fully re-derivable; a CI lever failure names the blast radius and the version to bump. - Reversibility: single flag (
hash_audited_scoring_enabled=False) restores current behavior. Hours to undo. - Blast radius: additive (snapshot capture + run record) + one decision-path repoint (
decision_service) + a comparator hardening; behind a flag. - Alternative considered: hash only the EBA matrix + reference snapshot (rejected — reintroduces the matcher/dataset/normalization reproducibility hole the owner flagged).
Consequences
Positive
- A score is re-derivable years later from its run hash + immutable snapshots — examiner-defensible per EBA/GL/2021/02 + EU AI Act Art. 12/15.
- Any determinism-affecting change (weights, list, matcher knob, normalizer, engine) is loud in CI and forces a deliberate version bump with a reviewable diff compliance can sign off.
- The LLM stops influencing the risk number in the one remaining decision path; the deterministic floor is authoritative.
- Reference-list provenance (source + date) is captured, not stripped.
Negative
- Five version levers whose failure mode is "someone changes behavior without bumping the right one" — mitigated by per-lever CI fixtures + coverage guard + startup assertion, but it is real recurring maintenance friction (the friction is the "intended or bug?" checkpoint).
- Per-tenant/global snapshot storage grows append-only; a retention/GC policy is deferred.
- The matcher score-band knobs are snapshotted but dead until #513 makes
sanctions_hitsstructured (OQ2, verified) — the snapshot is correct but partly inert today.
Neutral
- The EBA scorer's math is unchanged; only its inputs are now frozen and its output versioned/hashed.
- Matcher consolidation (the 0.90/0.85 collision, four matchers) is explicitly out of scope → #513/#518.
Alternatives Considered
Alternative 1: Hash only the matrix version + reference snapshot
- Version the EBA weights + freeze the reference lists; leave the matcher and normalization unversioned.
- Why rejected: the EBA input
sanctions_match_type/pep_levelis decided by fuzzy matching with tunable thresholds and a daily-refreshed dataset; a threshold or dataset change flips the same subject hit↔clear → the score changes with an identical matrix version + reference snapshot. The guarantee would be false. (Product-owner insight.)
Alternative 2: sha256(pickle.dumps(...)) of the in-memory objects
- Hash the Python objects directly.
- Why rejected: pickle output depends on class definitions + library versions; it churns on every dependency bump and reorder, producing meaningless hash noise that trains people to ignore the hashes. Canonical serialization of semantic inputs is required.
Alternative 3: Keep the LLM risk_score as the primary number with an audit log
- Log the LLM's number for traceability without demoting it.
- Why rejected: an LLM number is non-reproducible and pre-floor;
decision_servicecomputing a recommendation from it can under-state a floored-CRITICAL case (OB-Holding: synthesis ~0.83 vs floored 90). The deterministic floor must be authoritative.