Skip to main content

ADR-0127: Sanctioned-ownership/control test — the EU 50%-rule over the ownership graph (AMLR Art. 20(1)(d))

Date: 2026-07-24 Status: Accepted Deciders: Adrian (Soft4U BV), Claude (Opus 4.8) Extends: ADR-0024 (entity matching / survivorship), ADR-0053/0054 (UBO ownership + control engine), ADR-0020/0045 (deterministic risk floors + sanctions FP suppression), ADR-0073 (R9 name-collision guard), ADR-0078 (verified-identifier vs name-only two-lane attribution) Builds on: #513 (structured sanctions_matches carrying identifiers), #530 (typed-absence not_assessed), ADR-0067 (fail-closed compliance outputs) Pattern: ADR-0074 (phased/log-first dark launch) Issue: #535 (epic #528, AMLR monitoring/screening framework)

Context

Today the sanctions test is a flat name-screen: an entity or person matches a sanctions list and a finding is raised. AMLR Art. 20(1)(d) requires more — the obliged entity must establish whether a sanctioned person controls, holds >50% of the proprietary rights, or a majority interest in the customer, individually or collectively. This is the EU/OFAC "50%-rule": an entity is itself frozen when

50% owned or controlled by sanctioned persons in aggregate, even when the entity's own name is on no list. A flat name-screen never asks that question, so a subject majority- owned by a sanctioned party through the corporate structure can screen "clean".

Two facts made this computable now:

  • #513 gave the investigation a structured investigation_result["sanctions_matches"] — each record carries matched_name, list_type, match_type, identifiers (reg_no, birth_date, countries) and opensanctions_id. The identifiers are the join key a sanctioned-ownership cross needs.
  • The ownership/control graph already exists (ADR-0053/0054): UBOComputationEngine computes multiply-along-path × sum-across-paths ownership fractions and traverses ControlEdges + majority (>50%) control over an OwnershipGraph. But that arithmetic was wired only to natural-person UBO qualification, never asked of a sanctioned node.

The load-bearing risk is attribution (ADR-0073 R9 / ADR-0078). A person in the graph is keyed by name only (person:<name>); a name match with no corroborating identifier is not evidence that this graph node is that sanctioned party. Get it wrong in one direction and you MISS the sanction (never-suppress violation); wrong in the other and you FABRICATE a sanctioned owner and freeze a clean customer (presence-≠-evidence violation).

Decision

A deterministic, pure compute_sanctioned_ownership_control(graph, sanctions_matches) pass (app/services/sanctioned_ownership.py) that joins the sanctions matches to the ownership/control graph and returns a typed SanctionedOwnershipControlResult.

  1. Filter to sanctions. Only list_type == "sanctions" records are considered (reuse the #558 filter). PEP / watchlist matches are NOT sanctions and never enter this test; a missing list_type defaults to sanctions (fail-toward-scrutiny).

  2. Two-lane join (the crux, ADR-0073 R9 / ADR-0078).

    • Verified-identifier lane: the record's identifiers.reg_no (normalized) matches a company graph node's registration number (a company node's id is its registration number). This is a CONFIRMED sanctioned party in the ownership structure and MAY floor subject risk. Persons in the graph are name-keyed and carry no registration number, so a person match can never enter the verified lane — exactly the intended guard.
    • Name-only lane: a name match (exact normalized equality against a node name; the OpenSanctions caption parenthetical is stripped as a deterministic transform — no fuzzy matching) with NO corroborating identifier → a labelled unverified candidate. It is surfaced for officer review (a sanctioned_name_candidate MEDIUM finding), never folded into the subject's confirmed sanctioned-ownership on name alone, and never dropped (never-suppress).
  3. Compute over the verified lane (the EU 50%-rule). For each verified sanctioned node, UBOComputationEngine.node_holding reuses the same enumeration to compute its effective ownership of the subject (sum of KNOWN path products) and whether it controls it (ControlEdge chain OR >50% ownership ⇒ de jure control). The subject is confirmed when a sanctioned party individually controls or holds >50%, OR when the verified parties' holdings collectively exceed 50% (aggregate before the test — the coalition rule).

  4. Fail-closed (#530). An ownership path crossing an unknown-weight edge is not_assessed, never a silent 0: its known contribution is a floor (there may be more we could not weigh), and a not_assessed party (with nothing else confirming) yields a not_assessed determination + a HIGH coverage-gap finding — never a false clear and never a false confirm. A confirmed party always wins over a separate party's gap (never-suppress).

  5. Surface + score. A confirmed verified determination emits a CRITICAL sanctioned_ownership_control finding (regulatory_basis AMLR Art. 20(1)(d)) and sets a new deterministic EBA escalator SANCTIONED_OWNERSHIP_CONFIRMED that floors the AUTHORITATIVE overall score to CRITICAL (90) — parity with NETWORK_SANCTIONS_CONFIRMED (ADR-0020/0045), applied via the auto-escalation mechanism AND an unconditional floor so a custom config can never wash it out. The name-only lane emits its lower-severity candidate finding and NEVER touches the subject floor.

  6. Wiring. The pass runs in the reassess_risk activity, AFTER populate_knowledge_graph (where the ownership graph AND sanctions_matches both exist): it fetches the graph, runs the pure function, stashes the result at investigation["sanctioned_ownership_control"] and appends its findings to the case. _extract_eba_input_from_investigation reads that block and sets EBARiskInput.sanctioned_ownership_confirmed.

  7. Dark-launch + monotonic (sanctioned_ownership_control_enabled=False, ADR-0074 log-first). Flag OFF: the pass does not run and the score is byte-identical. Flag ON (Calibration-Review-gated): the pass runs, findings surface, and a confirmed verified party floors the score. It can only RAISE risk, never lower it — name-only candidates never floor.

Decision context:

  • Latency: one Neo4j ownership-graph read + a pure in-memory DFS on the reassess activity (single case, post-OSINT), only when the flag is on. No batch path.
  • Dependency surface: zero new packages, zero migrations. One pure service module, one Pydantic model module, one EBARiskInput field + escalator, one config flag, a small additive refactor of UBOComputationEngine (extracted adjacency builders + node_holding, behaviour byte-identical — the 32-test engine suite is unchanged).
  • Debuggability: every determination logs a greppable sanctioned_ownership_control.computed line and carries typed provenance (basis, verified/candidate counts, collective %, majority threshold); every finding names AMLR Art. 20(1)(d).
  • Reversibility: a single flag restores the prior behaviour; flag-off is byte-identical.
  • Blast radius: additive; the deterministic UBO engine and existing floors are untouched.

Consequences

Positive

  • The sanctions test is no longer flat: a subject majority-owned/controlled by a sanctioned party THROUGH the corporate structure is now caught and floored to CRITICAL (Art. 20(1)(d)).
  • Attribution is disciplined by construction: only a reg-no-verified party floors subject risk; a name match is a labelled candidate, never a fabricated sanctioned owner.
  • Fail-closed: an unweighable ownership edge is a loud not_assessed gap, never a silent 0.
  • One arithmetic source of truth: the same UBOComputationEngine path/control enumeration scores UBO qualification AND sanctioned holdings — no second, drifting implementation.

Negative / limitations (deferred)

  • The verified lane depends on identifiers.reg_no being populated. The current #513 builder emits reg_no=None (the simple OpenSanctions table carries no reg-no yet), so in production most sanctions matches route to the labelled name-only candidate lane until the OpenSanctions entity registration number is populated. The function is correct and ready for that identifier; wiring the reg-no through the builder is the tracked follow-up.
  • Collective aggregation sums individual effective holdings. A nested sanctioned-within- sanctioned chain can over-count — this is fail-toward-scrutiny (only RAISES, never suppresses), acceptable under never-suppress; full coalition-dedup is deferred.
  • Person-node matches are name-only by construction (the graph stores no person identifier); a person birth-date/identifier join is deferred to the person-verification surface.

Neutral

  • The escalator joins the existing family of deterministic CRITICAL floors (NETWORK_SANCTIONS_CONFIRMED, ENTITY_CRIMINAL_INVESTIGATION, PAYMENT_ACCOUNT_OUTSIDE_LICENCE) — same posture, same machinery, no new pattern.
  • Officer-overridability of the composite tier is provided by the existing audited four-eyes downward-override path (ADR-0100); the escalator itself is intentionally not reject-filtered (a legally-weighted floor), consistent with the other deterministic floors.

Alternatives Considered

Alternative 1: Score any name match as sanctioned ownership

  • Treat a name match on a graph node as a confirmed sanctioned owner.
  • Why rejected: presence-≠-evidence (ADR-0073 R9). A common name would fabricate a sanctioned owner and freeze a clean customer. Only a corroborating identifier confirms; name alone is a labelled candidate.

Alternative 2: Drop unweighable edges (treat unknown as 0%)

  • Skip an ownership edge whose percentage is unknown.
  • Why rejected: a silent 0 hides a possible >50% holding behind a false clear (the #530 defect class). An unknown edge is a loud not_assessed gap.

Alternative 3: A second, sanctions-specific graph traversal

  • Re-implement ownership/control arithmetic inside the sanctions module.
  • Why rejected: two implementations drift (the exact failure ADR-0122 records for the citation registry). The pass reuses UBOComputationEngine so UBO qualification and sanctioned holdings can never disagree on the same graph.