Skip to main content

Risk Assessment

Multi-layered risk assessment built exclusively on the EBA (European Banking Authority) risk factor matrix, deterministic red flag detection, a configurable risk scoring system with versioned configurations, and sector-specific risk engines. The risk engine produces quantified risk scores with full traceability to individual risk factors.

As of 2026-03-31 the system is EBA-only: the ARIA risk matrix has been removed and EBA_RISK_MATRIX_ENABLED flag eliminated. EBA scoring is always active.

Components

ModulePurpose
risk_engine.pyCore risk scoring engine aggregating multiple risk signals
risk_matrix_service.pyConfigurable risk matrix management and evaluation
eba_risk_matrix.pyEBA/GL/2021/02 risk factor implementation (7 dimensions, 20 factors, SHA-256 determinism proof)
red_flag_engine.pyDeterministic red flag detection based on jurisdiction-specific rules
precious_metals_risk_engine.pySector-specific risk engine for precious metals dealers
risk_config_service.pyVersioned risk configuration management — load, activate, audit
entity_baseline_service.pyCross-case, cross-time entity risk baseline; reconcile_baseline_risk() one-way ratchet (ADR-0089)
app/api/risk_config.pyREST endpoints at /api/risk-config/

EBA Risk Matrix

The EBA risk matrix implements EBA/GL/2021/02 (Guidelines on risk factors) as a scored 7-dimension matrix. The overall score is a weighted_max aggregate: a weighted average of dimension scores with a floor boost (FLOOR_BOOST_FACTOR = 0.60) applied when any single dimension exceeds the critical threshold (CRITICAL_DIMENSION_THRESHOLD = 80), consistent with EBA guidance that a critical risk factor should dominate the assessment.

7 dimensions (weights from eba_standard_v1.yaml, EBA_WEIGHTS in eba_risk_matrix.py):

DimensionWeightFactors
Customer0.25ownership_complexity, pep_exposure, sanctions_exposure, adverse_media, business_profile
Geographic0.20jurisdiction_risk, operational_geography, ubo_geography
Product/Service0.15product_complexity, regulatory_status
Delivery Channel0.08non_face_to_face, digital_presence
Transaction0.12financial_profile, transaction_patterns
Network/Association0.10network_risk, shared_address_risk, subsidiary_opacity
Temporal/Historical0.10company_age, filing_regularity, adverse_history

Risk levels:

LevelScore Range
Critical90+
High70–89
Medium40–69
Low20–39
Clear0–19

SHA-256 audit trail: EBARiskResult carries an input_hash and output_hash so auditors can verify stored results without re-running the scorer. The matrix version (eba_standard_v1) is captured in every result, satisfying 5-year AML retention requirements.

_unwrap_score helper (2026-04-13)

eba_risk_matrix.py now defines a module-level _unwrap_score(entry) helper that mirrors ReferenceDataService.get_risk_score so the four inline ref_datasets[...].get(key) lookups in _score_business_profile and _score_product_service_dimension correctly unwrap three supported shapes: a plain numeric, {"score": N, …}, or {"risk_score": N, …}. Previously the fast path returned the whole entry dict which blew up with TypeError: float() argument must be a string or a real number, not 'dict' and sent the reassess_risk activity into a Temporal retry loop. Commit dc5f1d4a.

Deterministic CRITICAL floors — calibration hardening (2026-06-30, ADR-0082)

Two calibration inversions surfaced by the OB Holding adversarial validation are fixed by deterministic, evidence-traceable rules layered over the weighted-max aggregate:

  • Subject criminal-investigation floor (#2). A confirmed criminal-law-enforcement investigation of the subject itself now floors the authoritative EBA score to CRITICAL (≥90) via a deterministic ENTITY_CRIMINAL_INVESTIGATION escalator, at parity with NETWORK_SANCTIONS_CONFIRMED. Previously it capped at HIGH/85 (a critical adverse-media dimension yields a weighted-max floor below 90) while lesser connected-entity sanctions hits reached 90 — an inversion where the more serious signal scored lower. The escalator is set from the existing has_entity_criminal signal, which respects officer rejections. eba_risk_matrix.py, risk_matrix_service.py.
  • Gambling MCC scored at its true vertical (#3). MCC 7995 now resolves to the gambling industry category (score 90) via a conservative MCC→category map (mcc_to_industry_category), used both in the product/service dimension lookup and to set business_profile. Previously a high-tier industry finding was coerced to construction (50) and the MCC lookup missed entirely (the industry-risk dataset is keyed by category name, not MCC code), so a pure-gambling merchant under-tiered to CDD.

These corrections raise scores only where the evidence justifies it; no dimension that was already capped is inflated.

Two Risk Representations, and the Monotonic-Risk Invariant (2026-07-03, ADR-0089)

Every case carries two risk representations that can diverge, because they are produced by different mechanisms:

  1. The LLM synthesis verdictrisk_level / risk_score on the investigation result (0.0–1.0 scale), written by synthesis_agent as its free-text read of the evidence.
  2. The authoritative EBA compositerisk_assessment.composite_score (0–100 scale, risk_assessment.risk_level), the deterministic weighted_max aggregate from eba_risk_matrix.py described above, including the ADR-0082 CRITICAL floors (subject-criminal, gambling MCC, etc.).

Because (1) is an LLM's synthesis and (2) is a deterministic floored score, a lower-recall or differently-worded synthesis could historically present a softer verdict than the floored assessment it was supposed to summarize — the same class of defect at two different layers:

  • Case-level, display-only (PR #176, reconcile_display_risk)app/workflows/activities.py ratchets additional_data["risk_level"]/risk_score (and the latest investigation_results entry) up to match risk_assessment's floored verdict whenever the synthesis under-states it, using _RISK_LEVEL_RANK (clear < low < medium < high < critical). It only ever raises; a lower synthesis score can add narrative colour but can never soften what the officer/regulator sees. This runs once per investigation, in-memory, on that case's own result.
  • Entity-level, persistent (ADR-0089, reconcile_baseline_risk) — the same principle, but across cases and time. A live OB Holding 1 OÜ re-run scored the identical legal entity CRITICAL/90 on one run and medium/51 on another (recall variance on the criminal/EPPO finding), and the lower run silently overwrote the entity's persisted baseline — a direct breach of the cardinal principle that the system may add scrutiny but must never suppress a signal.

The invariant

Per-entity risk is monotonic absent an explicit, audited downgrade. A re-screen may raise or maintain an entity's risk; it may never silently lower it. The only path down is a maker-checker officer decision, recorded in audit_events.

Where the ratchet sits in the pipeline

entity_baseline_service.py keys one row per legal entity (registration_number + country, uq_entity_baseline_identity) in entity_baselines (ADR-0066), independent of how many cases or re-screens touch that entity. upsert_baseline() is called after every investigation/re-screen completes (initial case, follow-up loop iteration, or perpetual-KYC re-screen) and, before writing, reads the currently-established row and calls the pure function reconcile_baseline_risk(established, incoming) -> BaselineDecision:

  • Compares tier rank first (_BASELINE_TIER_RANK: clear=0 < low=1 < medium=2 < high=3 < critical=4, with the MCC vocabulary high_tier_1/2 → critical, high_tier_3 → high normalized in via _normalize_tier before ranking), then score as a tie-breaker.
  • Fails closed asymmetrically via _tier_rank(tier, unknown_high=...): an unrecognised established tier ranks 99 (highest — never silently superseded by a recognised incoming tier); an unrecognised incoming tier ranks -1 (lowest — can never spuriously raise, and can never be mistaken for a legitimate raise that would mask a real downgrade). A None score compares as -1 on both sides, so a run that produced no score can never lower an established floor.
  • On raise-or-maintain, the incoming value becomes effective (effective_score/effective_tier = incoming).
  • On a would-be downgrade, the established value is held as effective; the raw incoming run is preserved separately in last_run_risk_score / last_run_risk_tier (never discarded — it is the forensic record of what the re-screen actually found); and a divergence_state JSONB payload is written (raw_score, raw_tier, detected_at, reason, material_check_incomplete, alert_id) pending an audited downgrade.

upsert_baseline() writes the reconciled decision — never the raw incoming values — through the on_conflict_do_update set_, so latest_risk_score / latest_risk_tier remain the single source of truth every existing reader already consumes (no reader migration). next_review_due is always computed from the effective tier (compute_next_review_date), so a held-off downgrade can never loosen the AMLR Art. 26(2) review cadence either. A held downgrade also raises a high-priority RISK_DIVERGENCE MonitoringAlert (monitoring_alert_service.create_risk_divergence, non-blocking — the floor still holds even if the alert insert fails) — the officer task that is the only route to a downgrade, via request_baseline_downgrade / approve_baseline_downgrade (maker-checker, ADR-0070, same-actor rejected via assert_distinct_approver). Approval is the sole code path that ever moves latest_risk_score/tier down, and it writes an immutable risk_downgrade_approved audit_events row naming both maker and checker.

Migration 080 (entity_baselines, additive, down_revision 079) adds the five columns the ratchet needs: last_run_risk_score (Integer), last_run_risk_tier (String), established_findings (JSONB, default [] — the persisted material-findings floor, see material_findings.py), divergence_state (JSONB, nullable), material_check_incomplete (Boolean, default false).

Deterministic scoring removes the other source of drift

A ratchet only has something stable to hold if identical evidence produces identical scores. Before ADR-0089 the compliance-output agents sampled (synthesis_agent at temperature=0.1, mcc_classifier at 0.1, case_intelligence_agent at 0.2, memo_justification_agent at 0.3, finding_debugger at 0.1, task_generator at 0.1, belgian_agent at 0.1), so the same evidence could still drift across runs. All seven are now pinned to temperature=0 (greedy decoding), with a guard unit test asserting ModelSettings.temperature == 0 on each so a future edit cannot silently reintroduce sampling. dashboard_agent is deliberately excluded and stays at 0.7 — it is interactive chat, not a persisted or regulator-facing verdict.

Honest limit: this does not, and cannot, make the underlying live-web retrieval (BrightData/Tavily/Google adverse-media search) deterministic — a re-screen can still miss a source it found last time. ADR-0089 does not pretend otherwise: it removes the two axes that had no excuse (LLM sampling, a blindly-overwriting upsert) and makes retrieval gaps fail closed (a data-gapped material check sets material_check_incomplete, penalises confidence_engine.compute_confidence, and can never itself justify a downgrade) — so a recall miss can raise a RISK_DIVERGENCE for a human to review, but it can never again silently present a CRITICAL entity as medium. See ADR-0089 for the full root-cause analysis and the material-findings re-injection / audited-downgrade components (C/E).

Unified Risk Configuration

Risk scoring configuration is managed via the RiskConfigService and exposed through the /api/risk-config/ REST API. Configurations are versioned: tenants can create new versions, preview their impact, and activate a version explicitly. Activating a new version records the change in risk_config_audit.

Database Tables

TablePurpose
risk_configurationsVersioned risk config records — scoring model, reference dataset overrides, activation status. RLS-enforced per tenant.
risk_config_auditImmutable audit log for config activations and deactivations. RLS-enforced per tenant.

Both tables have FORCE ROW LEVEL SECURITY and are covered by standard tenant isolation policies.

API Endpoints (/api/risk-config/)

Defined in app/api/risk_config.py. All mutating endpoints require the super_admin role; GET /active and POST /recalculate/{case_id} are open to any authenticated officer.

MethodPathDescription
GET/api/risk-config/activeGet the currently active configuration (no super_admin gate)
GET/api/risk-config/versionsList all config versions for the current tenant (paginated)
GET/api/risk-config/versions/{config_id}Get a specific configuration version
POST/api/risk-config/versionsCreate a new draft by cloning the active config
PUT/api/risk-config/versions/{config_id}Update a draft configuration (validated)
POST/api/risk-config/versions/{config_id}/activateActivate a draft (archives the prior active version)
GET/api/risk-config/versions/{id_a}/diff/{id_b}Recursive diff between two versions
GET/api/risk-config/auditList configuration audit log
POST/api/risk-config/recalculate/{case_id}Recalculate a case's risk under the active config
POST/api/risk-config/batch-reevaluateStart a BatchRiskReEvaluationWorkflow for all active cases

Stale Configuration Detection

When a case was scored under an older configuration version, the case detail page shows a stale config banner with a Recalculate button. Clicking it re-scores the case under the currently active configuration and updates the stored risk score without requiring a full re-investigation.

Admin UI — Risk Configuration Page

The /admin/risk-configuration admin page provides a three-tab interface for managing the active scoring model:

TabPurpose
Scoring ModelView and edit the active EBA dimension weights, factor thresholds, and risk level boundaries
Reference DatasetsInspect and override reference dataset values (FATF lists, PEP tiers, industry risk classifications) used by the EBA matrix
VersionsBrowse all configuration versions, compare diffs, and activate a version

The old /admin/reference-data page now redirects to /admin/risk-configuration.

Recent Fixes (2026-04-06)

Delivery Channel Fix

The delivery_channel dimension weight was incorrectly set to 0 in some configurations, causing the EBA matrix to produce inflated scores. It now carries the EBA-standard weight of 0.08 (8%) in EBA_WEIGHTS. The non_face_to_face factor contributes a positive score (5.0 of its 15-point factor maximum) for cases onboarded through the digital portal, reflecting EBA guidance that non-face-to-face identification carries inherent risk; the digital_presence factor is scored separately (20-point maximum).

PEP Detection Fix

False PEP escalations were occurring when the screening agent found common-name matches without sufficient confidence. The fix tightens the PEP matching threshold: a PEP finding only elevates risk when the match confidence exceeds 0.7 (previously any match triggered escalation). Clean screening results now correctly produce VERIFIED severity in the structured summary.

MCC-Aware License Verification

The verification checks pipeline now includes regulatory license verification that is MCC-aware. The MCC code (assigned by the MCC classifier agent earlier in the pipeline) determines the business vertical, which in turn determines which regulatory licenses are required. Missing licenses produce a hit finding with PSD2 Art. 11 / CRR Art. 8 regulatory basis.

Segment Risk Calibration

EBA dimension weights can be overridden per regulatory segment via apply_risk_calibration(weights, calibration) in eba_risk_matrix.py. The RiskCalibration model carries five dimension multipliers (customer_weight_multiplier, geographic_weight_multiplier, product_weight_multiplier, channel_weight_multiplier, transaction_weight_multiplier); calibration operates at the dimension level, not on individual factors. After multiplication the weights are re-normalised to sum to 1.0. Examples from config/segments/: CZ Banking applies geographic_weight_multiplier = 1.2; BE precious metals applies customer_weight_multiplier = 1.5 and transaction_weight_multiplier = 1.4. Segment calibration is applied during the post_osint risk reassessment checkpoint.

Risk Config 403 Fix

The /api/risk-config/recalculate/{case_id} endpoint was gated behind super_admin role, preventing compliance officers from recalculating risk scores when configurations changed. The gate has been removed -- any authenticated user with tenant access can trigger recalculation (see the # No super_admin check comment in recalculate_case_risk). The endpoint accepts either a case_id (UUID) or a workflow_id (wf_xxx), inserts a new risk_assessments row (preserving history), and updates the case's additional_data.

Recalc never-suppress guard (2026-06-30, ADR-0082, #156)

recalculate_case_risk has a findings-aware "rich" path and a findings-blind fallback (country + MCC only). The fallback is taken when additional_data["investigation_results"] is empty — which races with the workflow persisting investigation_results after its post-OSINT reassessment. Without a guard, a recalc fired in that window reset an investigated case (e.g. an EPPO CRITICAL/90/EDD result) to a country-only baseline (medium/42/CDD), silently suppressing the risk. _recalc_should_preserve (risk_config.py) now forbids a findings-blind recompute from downgrading a higher findings-based assessment; a rich-path recalc may still change the score freely (evidence-traceable).

The append-only risk_assessments history table — one row per write recording trigger / score / assessed_at (e.g. initial/42 → post_osint/90 → manual_recalculate/42) — is the canonical forensic tool for answering "which computation set the displayed risk", and is how this race was diagnosed.