Sanctions Screening
Sanctions screening infrastructure for matching entities against international sanctions lists, plus the ADR-0045 three-tier false-positive (FP) suppression pipeline that keeps officer review queues clean without ever deleting a hit.
Screening runs during the OSINT investigation stage and is invoked again, recurrently, by Continuous Monitoring. It covers the subject company plus all natural persons associated with the entity — directors and beneficial owners — against the locally-cached OpenSanctions table, and every raw hit is partitioned into one of three visible buckets: auto_dismissed (Tier 1 evidence), suppressed_by_rule (Tier 2 learned rule), or requires_review (officer must decide).
Screening Coverage — Subject + All Natural Persons (issue #33)
Screening is not director-only. The canonical natural-person screening set merges directors and beneficial owners, so a sanctioned UBO who is not a board member is no longer missed:
persons = merge_screening_persons(directors_detailed, ubos) # dedup; directors first
queries = build_director_queries(persons, last_activity_date=...)
merge_screening_persons (sanctions_screening_pipeline.py) adds directors first, then UBOs, deduplicating by lowercased name. When the same individual appears as both a director (richer record carrying the DOB/nationality discriminators that reduce false positives) and a UBO (often name-only), the more complete director record is kept. This is not fabrication: a name-only record screens by name with UNKNOWN discriminators, which the suppression engine skips rather than scoring as a mismatch (ADR-0045 invariant — never fabricate a discriminator).
The subject company is screened via build_company_query (name + registration country + LEI), and each natural person via build_director_queries, which populates DOB and nationality only from explicit registry fields — never substituting a board-mandate start date for a DOB or the company's registration country for the director's nationality.
Recurring Re-Screening & Evidence Trail
The same screen_entities pipeline is invoked recurrently by Continuous Monitoring for risk-paced re-screening of the onboarded population (ADR-0066). The monitoring check re-runs merge_screening_persons + build_director_queries + screen_entities under a tenant-scoped session, gated by a per-case risk cadence (EDD 90d / CDD 180d / SDD 365d).
Post-suppression results are persisted to the typed, append-only screening_results evidence trail (ADR-0063, issue #45) as timestamped ScreeningResult rows — recording "screened, clean" rows as well as hits, so the trail constitutes ongoing-monitoring proof (AMLR Art. 21/26). The ScreeningListType enum used for these rows gained a WANTED value alongside EU_SANCTIONS, UN_SANCTIONS, OFAC, PEP, and ADVERSE_MEDIA; sanctions classes are always classified ahead of PEP so a hit carrying both topics is never downgraded.
An indeterminate OpenSanctions lookup (ScreeningUnavailableError → .error on the result) is never recorded as clean — the monitoring re-screen emits no row for that person, surfaces a WARNING, and does not advance the cadence. See Continuous Monitoring for the full cardinal-rule treatment.
False-Positive Suppression Pipeline (ADR-0045)
The suppression engine is the core of this page. It is evidence-based and always visible — the system can ADD scrutiny but never silently suppress a risk signal. Three tiers:
| Tier | Module | Behavior |
|---|---|---|
| Tier 0 — name-token pre-filter | sanctions_suppression_integration.py | Runs the two-token surname matcher (score_person_match); auto-dismisses hits whose surname similarity falls below SURNAME_MATCH_THRESHOLD, filtering OpenSanctions prefix/phonetic false positives (e.g. "PERKA"→"Peroutka") before they reach review |
| Tier 1 — evidence-based auto-dismissal | sanctions_fp_suppression.py | Deterministic, no ML. Evaluates up to 6 discriminators (DOB, YOB, nationality, date-of-death vs. activity, LEI, gender) and auto-dismisses only when ≥ 2 independent discriminators contradict |
| Tier 2 — officer-originated learned rules | sanctions_suppression_service.py | Persists officer dismissals as sanctions_suppression_rules (HMAC-SHA256 discriminator hash, tenant-salted, 12-month expiry) and checks active rules at screening time |
| Tier 3 — periodic re-check | sanctions_suppression_service.py (flag_rules_for_review) | Conservative housekeeping pass surfacing expiring/expired rules into the dashboard renewal queue; never auto-revokes or auto-extends |
Tier 1 requires compositional evidence — a single-discriminator mismatch (which could be a data-entry error) never auto-dismisses. Every Tier-1 evaluation emits a structured sanctions_fp_tier1_evaluated audit event citing the exact discriminator values compared (EU AI Act Art. 12 / Art. 14). Every Tier-2 dismissal requires a non-empty officer rationale (Art. 13) and writes an audit event.
Sanctioned Ownership & Control (AMLR Art. 20(1)(d), ADR-0127)
A flat name-screen answers "is this entity on a list?" — it does not answer "is this entity owned or controlled by a sanctioned party?" The EU/OFAC 50%-rule freezes an entity that is >50% owned or otherwise controlled by sanctioned persons individually or in aggregate, even when the entity itself is not listed. app/services/sanctioned_ownership.py (compute_sanctioned_ownership_control) is the deterministic, pure, DB-free pass that answers it by joining the structured OpenSanctions sanctions_matches (#513, built by sanctions_match_builder.py) to the ownership/control graph (graph_service.fetch_ownership_graph, ADR-0024/0053/0054).
It runs inside the reassess_risk activity after populate_knowledge_graph (so both the graph and the structured matches exist) and is dark-launched behind sanctioned_ownership_control_enabled (default False) — flipping it on is Calibration-Review-gated.
Determination — individual OR collective
| Basis | Condition | Result |
|---|---|---|
subject_directly_sanctioned | the subject entity is itself a sanctions match | CONFIRMED |
individual_majority | a verified-lane party holds >50% of the subject's proprietary rights (MAJORITY_CONTROL_FRACTION) | CONFIRMED |
individual_control | a verified-lane party controls the subject (control edge / de-jure >50%) | CONFIRMED |
collective_majority | the summed KNOWN verified-lane holdings exceed 50% in aggregate | CONFIRMED |
A CONFIRMED determination fires the SANCTIONED_OWNERSHIP_CONFIRMED escalator, which floors the authoritative EBA composite score to ≥90 / CRITICAL in risk_matrix_service (monotonic ratchet — a later reassessment can never wash the floor out; the floor holds even under a custom config that omits the rule).
Two-lane attribution (ADR-0073 R9 / ADR-0078) — load-bearing
Get this wrong and you either miss the sanction (never-suppress violation) or fabricate a sanctioned owner (presence-≠-evidence violation):
- verified-identifier lane — the sanctions record's
identifiers.reg_nomatches a graph node's registration number → a CONFIRMED sanctioned party that MAY floor subject risk to CRITICAL. - name-only lane — a name match with no corroborating identifier → a labelled unverified candidate, surfaced for officer review, never folded into the subject's confirmed sanctioned-ownership on name alone, and never dropped. Name attribution uses the exact screened entity
subject_ref(falling back tomatched_name), so an alias/transliteration match — where the sanctions LIST caption differs from the graph node's name — is not silently lost.
Fail-closed & integrity guards
- Unknown-weight edge →
not_assessed, never a silent 0 (issue #530): an ownership path crossing an unweighable edge cannot be excluded from >50%, so the determination isnot_assessed(HIGH finding) — it adds scrutiny, never a false clear. - Dedup before the collective sum (Codex #3): two
sanctions_matchesidentifying the same graph node (duplicate OpenSanctions entities, or two unsuppressed records sharing one reg-no) are collapsed by graph node id before summing — so a single 30% sanctioned owner can never be double-counted to 60% and fabricate a false collective CRITICAL. Dedup only ever reduces, never inflates. - Persistence (Codex #1): the determination block and its findings are returned by the
reassess_riskactivity and copied ontos.investigation_results[-1]bycompliance_case._apply_sanctioned_ownership_result(idempotent replace) — a Temporal activity mutating only its deserialized-local copy would evaporate, leaving the CRITICAL floor invisible to the officer surfaces and the case-pack. The copy-back runs at the post-network / post-document checkpoints (graph fully populated).
Honest coverage marker
The investigation producer (run_sanctions_screening_with_suppression) screens the subject company + its directors/UBOs — it does not independently sanctions-screen the parent/owner COMPANY nodes discovered in the ownership graph. When the structure contains such unscreened company nodes and the determination would otherwise read clean, the result carries a coverage marker (graph_company_screening: "not_performed") and emits a sanctioned_ownership_coverage_gap finding (HIGH) — so a structure with unscreened corporate owners can never read as a bare "no sanctioned party" (checked-and-clear) result (ADR-0067 never-suppress). Verified-lane confirmation on a separately-named corporate owner additionally requires registration-number-bearing sanctions data (the simple OpenSanctions table carries none yet — #513 deferred). Full graph-company screening is tracked as issue #566 (epic #528).
Components
| Module | Purpose |
|---|---|
sanctioned_ownership.py | Deterministic Art. 20(1)(d) sanctioned-ownership/control test (compute_sanctioned_ownership_control): joins structured sanctions_matches to the ownership/control graph, applies the individual/collective 50%-rule, two-lane attribution, fail-closed not_assessed, node-id dedup, and the honest coverage marker. Pure, DB-free, no wall-clock |
sanctions_match_builder.py | Builds the structured investigation_result["sanctions_matches"] (#513) from post-suppression requires_review hits — carrying subject_ref (the exact screened entity), match_type, list_type, and the identifiers the ownership cross needs |
sanctioned_ownership.py model (app/models/sanctioned_ownership.py) | SanctionedOwnershipControlResult / SanctionedParty — determination vocabulary, verified/candidate parties, findings, escalators, provenance, and the coverage marker |
sanctions_screening_pipeline.py | Orchestrates per-entity screening: queries OpenSanctions via match_local, normalises rows, calls partition_hits, and aggregates the result for the OSINT payload (screen_entities, build_company_query, merge_screening_persons, build_director_queries, summarize_screening). Raises ScreeningUnavailableError when OpenSanctions data is not loaded (surfaced as .error, never recorded clean) |
sanctions_fp_suppression.py | Tier 1 deterministic discriminator evaluation (evaluate_suppression, build_audit_event_payload). Defines CustomerDiscriminators and SanctionedRecordDiscriminators |
sanctions_suppression_service.py | Tier 2/3 SanctionsSuppressionService: record_dismissal, revoke_rule, check_active_rule, list_rules, flag_rules_for_review, plus the tenant-salted HMAC discriminator hash |
sanctions_suppression_integration.py | Adapter wiring Tier 0 + Tier 1 + Tier 2 over a hit list (partition_hits); returns the three-bucket PartitionedScreening |
sanctions_matcher_service.py | Local EU sanctions list matching with three tiers: exact (normalized name), fuzzy (Jaro-Winkler), and LLM resolution for the 0.80–0.95 ambiguous zone |
sanctions_feed_service.py | EU Consolidated Financial Sanctions List ingestion — fetches/parses the EU XML to produce sanctioned country codes, Redis-cached 24h, with hardcoded fallback |
sanctions_constants.py | Sanctioned and high-risk country sets (EU 833/2014, UN consolidated, OFAC SDN informational) for per-shipment compliance checks |
portfolio_service.py | Rate-limited parallel Tier 1 batch scans across an entity portfolio (PortfolioService.batch_scan), persisting a Portfolio graph node with CONTAINS edges |
API
app/api/sanctions.py— screening endpoints (screen, results)app/api/sanctions_suppression.py— suppression-rule CRUD under/api/sanctions/suppression-rules(record dismissal, list by status bucket, revoke)
Related ADRs
- ADR-0045 — Sanctions false-positive suppression (3-tier engine, evidence-based, always visible)
- ADR-0063 — Typed persisted
ScreeningResultevidence trail (issue #45) - ADR-0066 — Live risk-paced UBO/director re-screening + general-population monitoring
- ADR-0127 — Sanctioned-ownership/control test (AMLR Art. 20(1)(d), EU 50%-rule; individual/collective thresholds, verified vs name-only two-lane attribution, CRITICAL floor,
sanctioned_ownership_control_enabled)