Skip to main content

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:

TierModuleBehavior
Tier 0 — name-token pre-filtersanctions_suppression_integration.pyRuns 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-dismissalsanctions_fp_suppression.pyDeterministic, 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 rulessanctions_suppression_service.pyPersists 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-checksanctions_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

BasisConditionResult
subject_directly_sanctionedthe subject entity is itself a sanctions matchCONFIRMED
individual_majoritya verified-lane party holds >50% of the subject's proprietary rights (MAJORITY_CONTROL_FRACTION)CONFIRMED
individual_controla verified-lane party controls the subject (control edge / de-jure >50%)CONFIRMED
collective_majoritythe summed KNOWN verified-lane holdings exceed 50% in aggregateCONFIRMED

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_no matches 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 to matched_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 is not_assessed (HIGH finding) — it adds scrutiny, never a false clear.
  • Dedup before the collective sum (Codex #3): two sanctions_matches identifying 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_risk activity and copied onto s.investigation_results[-1] by compliance_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

ModulePurpose
sanctioned_ownership.pyDeterministic 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.pyBuilds 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.pyOrchestrates 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.pyTier 1 deterministic discriminator evaluation (evaluate_suppression, build_audit_event_payload). Defines CustomerDiscriminators and SanctionedRecordDiscriminators
sanctions_suppression_service.pyTier 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.pyAdapter wiring Tier 0 + Tier 1 + Tier 2 over a hit list (partition_hits); returns the three-bucket PartitionedScreening
sanctions_matcher_service.pyLocal 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.pyEU Consolidated Financial Sanctions List ingestion — fetches/parses the EU XML to produce sanctioned country codes, Redis-cached 24h, with hardcoded fallback
sanctions_constants.pySanctioned and high-risk country sets (EU 833/2014, UN consolidated, OFAC SDN informational) for per-shipment compliance checks
portfolio_service.pyRate-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)
  • ADR-0045 — Sanctions false-positive suppression (3-tier engine, evidence-based, always visible)
  • ADR-0063 — Typed persisted ScreeningResult evidence 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)