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
| Module | Purpose |
|---|---|
risk_engine.py | Core risk scoring engine aggregating multiple risk signals |
risk_matrix_service.py | Configurable risk matrix management and evaluation |
eba_risk_matrix.py | EBA/GL/2021/02 risk factor implementation (7 dimensions, 20 factors, SHA-256 determinism proof) |
red_flag_engine.py | Deterministic red flag detection based on jurisdiction-specific rules |
precious_metals_risk_engine.py | Sector-specific risk engine for precious metals dealers |
risk_config_service.py | Versioned risk configuration management — load, activate, audit |
entity_baseline_service.py | Cross-case, cross-time entity risk baseline; reconcile_baseline_risk() one-way ratchet (ADR-0089) |
app/api/risk_config.py | REST 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):
| Dimension | Weight | Factors |
|---|---|---|
| Customer | 0.25 | ownership_complexity, pep_exposure, sanctions_exposure, adverse_media, business_profile |
| Geographic | 0.20 | jurisdiction_risk, operational_geography, ubo_geography |
| Product/Service | 0.15 | product_complexity, regulatory_status |
| Delivery Channel | 0.08 | non_face_to_face, digital_presence |
| Transaction | 0.12 | financial_profile, transaction_patterns |
| Network/Association | 0.10 | network_risk, shared_address_risk, subsidiary_opacity |
| Temporal/Historical | 0.10 | company_age, filing_regularity, adverse_history |
Risk levels:
| Level | Score Range |
|---|---|
| Critical | 90+ |
| High | 70–89 |
| Medium | 40–69 |
| Low | 20–39 |
| Clear | 0–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_INVESTIGATIONescalator, at parity withNETWORK_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 existinghas_entity_criminalsignal, 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
gamblingindustry category (score 90) via a conservative MCC→category map (mcc_to_industry_category), used both in the product/service dimension lookup and to setbusiness_profile. Previously a high-tier industry finding was coerced toconstruction(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:
- The LLM synthesis verdict —
risk_level/risk_scoreon the investigation result (0.0–1.0 scale), written bysynthesis_agentas its free-text read of the evidence. - The authoritative EBA composite —
risk_assessment.composite_score(0–100 scale,risk_assessment.risk_level), the deterministicweighted_maxaggregate fromeba_risk_matrix.pydescribed 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.pyratchetsadditional_data["risk_level"]/risk_score(and the latestinvestigation_resultsentry) up to matchrisk_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 vocabularyhigh_tier_1/2 → critical,high_tier_3 → highnormalized in via_normalize_tierbefore ranking), then score as a tie-breaker. - Fails closed asymmetrically via
_tier_rank(tier, unknown_high=...): an unrecognised established tier ranks99(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). ANonescore compares as-1on 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 adivergence_stateJSONB 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
| Table | Purpose |
|---|---|
risk_configurations | Versioned risk config records — scoring model, reference dataset overrides, activation status. RLS-enforced per tenant. |
risk_config_audit | Immutable 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.
| Method | Path | Description |
|---|---|---|
GET | /api/risk-config/active | Get the currently active configuration (no super_admin gate) |
GET | /api/risk-config/versions | List all config versions for the current tenant (paginated) |
GET | /api/risk-config/versions/{config_id} | Get a specific configuration version |
POST | /api/risk-config/versions | Create 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}/activate | Activate 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/audit | List configuration audit log |
POST | /api/risk-config/recalculate/{case_id} | Recalculate a case's risk under the active config |
POST | /api/risk-config/batch-reevaluate | Start 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:
| Tab | Purpose |
|---|---|
| Scoring Model | View and edit the active EBA dimension weights, factor thresholds, and risk level boundaries |
| Reference Datasets | Inspect and override reference dataset values (FATF lists, PEP tiers, industry risk classifications) used by the EBA matrix |
| Versions | Browse 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.