UBO Determination
Under Regulation (EU) 2024/1624 (the AML Regulation, AMLR) Art. 51–53, a beneficial owner is any natural person who ultimately owns or controls the subject entity. Crucially, directors are not automatically UBOs — a director with no ownership and no control is not a beneficial owner, and a 0%-ownership person who appoints the board is. Determining beneficial ownership is the single most important obligation in customer due diligence, and it is a computation, not a lookup.
This page documents the engine that performs that computation: a four-stage determination chain (ownership → control → senior-managing-official fallback → role-based arrangements), with a jurisdiction-configurable threshold and a complete path-level audit trail.
Why this exists
Before this sprint, "UBO determination" did not determine anything. The platform counted pre-declared HAS_UBO register edges and set ubo_identified = ubo_count > 0. The only path arithmetic multiplied fractions along a single path and discarded alternates via a global node-dedup, and it drove the network-graph UI rather than UBO flagging. Two failures followed directly:
- Indirect ownership was invisible. A person owning the subject through two disjoint 15% chains (summed 30% ≥ 25%) is a UBO, but no single path reaches the threshold. The count-based flag and the single-path arithmetic both miss it.
- Control was absent. A person exercising control — majority voting, board appointment, veto rights — with 0% ownership conferred no UBO status at all.
The engine (ADR-0053, extended by ADR-0054, ADR-0055, ADR-0061) replaces counting with a real AMLR Art. 51–53 determination.
The determination chain
Each stage is an independent basis for beneficial ownership. The engine evaluates ownership and control per natural person, then falls through to the SMO fallback only when neither yields a qualified UBO. Role-based arrangements are a structurally separate path.
1. Ownership — multi-path summation (ADR-0053)
For each natural person, the engine DFS-enumerates every simple path from that person to the subject, multiplies the ownership fractions along each path, and sums the products across distinct paths. A UBO qualifies when the summed fraction meets the configured threshold.
The DFS uses a per-path visited set (frozenset), not a global one. This is the load-bearing correction over the old single-path code: it is simultaneously cycle-safe (a node already on the current path is never revisited) and alternate-path-preserving (a node reachable via two disjoint chains contributes both). The canonical 15% + 15% = 30% case is now flagged correctly.
for person in graph.person_ids:
traces = enumerate_paths(person, subject) # every simple path, per-path visited set
aggregated = sum(product(edge.fraction for edge in path) for path in traces) # exact Decimal
qualified = aggregated >= threshold # inclusive comparator (AMLR)
# or aggregated > threshold when inclusive=False (e.g. UK PSC)
Depth (MAX_DEPTH = 10) and path-count (MAX_PATHS_PER_PERSON = 10_000) caps bound the exponential worst case on dense graphs; hitting either sets a truncated flag rather than silently dropping paths. Each qualifying person carries a PathTrace — the contributing paths, the per-edge percentages, and the product — so the determination is self-explaining.
Decimal, exact-boundary arithmetic (ADR-0130, #540). As of AMLR Wave 2 the engine computes in
decimal.Decimalunder a fixed context (prec=50,ROUND_HALF_EVEN), not IEEE-754 float, so identical graphs yield byte-identical aggregates and a reproduciblegolden_record_hash. Consequently the historicalTHRESHOLD_EPSILONfudge (1e-9) is dropped: the boundary is an exact>=(inclusive) />(exclusive) comparison, and a person holding exactly 25% qualifies under AMLR by exact arithmetic, not by an epsilon nudge. Afield_validator(Decimal(str(v)))at the model boundary makes the "neverDecimal(float)" rule structural. A newBeneficialOwnerResult.aggregated_fractioncarries the exact 0–1 aggregate; typed-absence (an unknown-weight edge →not_assessed, never a silent 0) is preserved.
2. Control — binary reachability (ADR-0054)
AMLR Art. 52 treats control as a test independent of ownership: a person who exercises control is a beneficial owner regardless of ownership percentage. The engine therefore runs a second DFS, _enumerate_control, over control hops. A control hop is either:
- an explicit
ControlEdge—majority_voting,appoint_remove_board,veto_rights,control_profit_distribution, orother_dominant_influence; or - an
OwnershipEdgewithfraction > 0.50— strictly more than half is de jure control, so control propagates through majority-owned intermediates.
Control is binary reachability, not arithmetic: reaching the subject through any control chain confers UBO status. Control is deliberately not folded into the percentage summation — two veto rights do not sum to 200%, and control through a 30%-owned intermediate still confers full control. The > 0.50 comparator is strict (a 50/50 split gives neither holder control). Each control-qualified person carries control_paths (the ordered ControlTrace chains) for audit, mirroring path_traces for ownership.
A person can qualify on both bases, so qualified_via is a list (["ownership"], ["control"], or ["ownership","control"]) and the reason_code encodes both where applicable (e.g. ownership_25+control).
3. SMO fallback — UBO of last resort (ADR-0055)
When no natural person qualifies via ownership or control, AMLR requires a UBO of last resort: the senior managing official(s) are designated as beneficial owner. Returning "0 UBOs" for an entity with directors is non-compliant.
A post-pass fires only when not any(r.qualified for r in results) and graph.smo_candidates. It elects all active directors (sourced from HAS_DIRECTOR edges, invalid_at IS NULL) — not a single "most senior", because title seniority is not reliably orderable across jurisdictions (Gérant, Geschäftsführer, Bestuurder, Managing Director). Each SMO result is qualified=True, qualified_via=["smo_fallback"], reason_code="smo_fallback", aggregated_pct=0.0, with an audit_note recording that the ownership (Art. 51) and control (Art. 52) bases were exhausted.
The guard keys on qualified: a below-threshold near-miss (emitted unqualified) does not suppress the fallback, but a control-only or ownership UBO does.
4. Arrangements — role-based UBOs (ADR-0061)
Trusts and foundations have no ownership percentage — a trustee or settlor holding 0% is a beneficial owner by role. They cannot be mis-shaped into the percentage graph without corrupting its summation arithmetic, so they take a structurally separate path (arrangement_ubo.compute_arrangement_ubos). Every listed ArrangementParty — settlor, trustee, protector, beneficiary (or class of beneficiaries), founder, governing body — becomes a BeneficialOwnerResult (qualified=True, qualified_via=["arrangement_role"], reason_code="arrangement_<role>", aggregated_pct=0.0) under AMLR Art. 58. Reusing BeneficialOwnerResult means every downstream consumer treats arrangement UBOs uniformly alongside ownership/control/SMO UBOs.
Jurisdiction-configurable threshold
The 25% figure is the AMLR/EU baseline only. The threshold and its comparator are resolved per jurisdiction by resolve_ubo_threshold(country) from the sourced ubo_thresholds.json reference dataset, and the applied value plus its legal_basis are recorded on every result (threshold_pct) and persisted row — so the audit label always states which rule ran.
Two dimensions are encoded per jurisdiction, not hard-coded:
| Dimension | Meaning |
|---|---|
value | The ownership threshold (e.g. 25.0; a high_risk_override entry at 15.0 exists but applies_by_default: false) |
inclusive | true ⇒ qualify at ≥ threshold ("25% or more", AMLR/EU/CH). false ⇒ qualify at > threshold ("more than 25%", UK PSC regime) |
The inclusive-vs-exclusive distinction is consequential at the exact boundary: under AMLR a person holding exactly 25% is a UBO; under the UK PSC regime they are not. Hard-coding either comparator would silently produce wrong determinations for the other regime. The resolver falls back to the AMLR 25% / inclusive default for unknown or None countries, and surfaces a genuine dataset-load failure as a warning rather than masking it. An explicit ubo_threshold override (e.g. a high-risk 15%) is honoured directly and defaults to inclusive unless the caller also specifies the comparator — the country-resolved comparator is not coupled to an override threshold.
Effective-dated + category-aware threshold snapshot (ADR-0133, #542).
resolve_threshold_snapshot(country, *, as_of, high_risk)is now the single source of truth (the legacyresolve_ubo_thresholdis a backward-compatible wrapper delegating to it). It applies the rule in force as of the computation date: because the AMLR-harmonised threshold is not in force until 2027-07-10, a computation dated today does not silently apply the not-yet-in-force AMLR value — it falls back to the contemporaneous AMLD (Directive (EU) 2015/849 Art. 3(6)) predecessor (the same 25% inclusive, honestly citedrule="pre_effective_predecessor");effective_date=nullnational regimes (CH/GB) are never routed through it. A genuinely high-risk case may apply the 15% override, but only when it lowers the threshold (a misconfigured higher override is refused and logged — never-suppress) and only when the override's owneffective_dateis in force. A typedThresholdSnapshot(value, exact-Decimal fraction, inclusive, legal_basis, effective_date,high_risk_override_applied,as_of,rule) is stamped onto everyBeneficialOwnerResultand persisted in the append-onlyubo_computations.resultsJSONB — the row records which rule ran. No migration.
Pure-engine architecture
The regulated arithmetic lives in one pure module, UBOComputationEngine (ubo_engine.py): no Neo4j, no database, no config imports — only trustrelay-models, the standard library, and structlog. This separation is deliberate and matters for a regulated decision:
- Testability. The entire UBO determination is exercised by feeding an in-memory
OwnershipGraphtoengine.compute(graph)and asserting on the results — no driver, no fixtures, no database. The 15%+15% case, the strict>50%control boundary, the SMO guard, and cycle-safety are all unit-tested in isolation. - Versionability. EU AI Act Art. 12 requires the decision logic to be a versionable artefact. Typed Python is auditable and diffable; the alternative — computing the summation in a Cypher query — would bury the threshold logic, reason codes, and path assembly in opaque, hard-to-test, hard-to-version query strings. Neo4j is the data source, never the decision-maker.
UBOComputationService (ubo_service.py) is the thin orchestration layer: fetch graph → run engine → persist. It calls GraphService.fetch_ownership_graph (a Neo4j-read-only adapter that builds the in-memory graph from IS_SUBSIDIARY_OF, HAS_UBO, explicit CONTROLS, and HAS_DIRECTOR edges), resolves the jurisdiction threshold, constructs a per-call engine, and writes the result. A persist=False flag lets coverage reads (e.g. get_amlr_coverage) compute without writing an audit row.
Auditability
Every deliberate computation run is persisted append-only to the tenant-scoped, FORCE-ROW-LEVEL-SECURITY ubo_computations table (Alembic 063, ADR-0023) — the regulator-facing record of how and under which rule each determination was reached. Each BeneficialOwnerResult carries:
| Field | Audit purpose |
|---|---|
qualified | The determination — always check this first |
qualified_via | The basis(es) that conferred status: ownership, control, smo_fallback, arrangement_role |
reason_code | The truthful, threshold-aware label (ownership_25, ownership_15, ownership_25+control, control, smo_fallback, arrangement_trustee, …) |
threshold_pct | The applied jurisdiction threshold — which rule ran |
path_traces / control_paths | The contributing ownership paths and control chains, edge by edge |
audit_note | The explainability note (e.g. why the SMO fallback fired) |
truncated | Set when a depth/path cap was hit, so a bounded result is never mistaken for a complete one |
This satisfies EU AI Act Art. 12 traceability and the project's foundational principle that every AI-driven determination be fully traceable, retrievable, and auditable. The ubo_computations table is also the seam for DB-enforced audit immutability. A BeneficialOwnerResult may carry qualified_via=["ownership"] even when qualified is False — that reflects the basis reached (a below-threshold near-miss), not UBO status, which is why qualified is the authoritative field.
AMLR Wave-2 / Wave-3 extensions
The AMLR-readiness programme (epic #528) extended the four-stage engine above with the deeper Art. 52–63 mechanisms. Each is additive-with-defaults (the base determination is byte-unchanged) and rides the existing append-only ubo_computations.results JSONB — no migration. The full programme-level map, with the honest live / dark-launched / deferred status of each, is on AMLR Readiness; the beneficial-ownership pieces are:
- Control-via-other-means (ADR-0134, #538) — Live (engine); graph population deferred #630. Replaces the flattened control schema and the single ">50% ownership implies control" shortcut with four legible mechanisms named per person on
BeneficialOwnerResult.control_bases:veto_rights/appoint_remove_board/ … , acting-in-concert (Art. 53(3) — a declaredConcertPartygroup whose members' combined exact-Decimal ownership strictly exceeds 50% confers control on each natural-person member; a company member counts toward the sum only; exactly-50% does not confer — never inferred, never a fabricated BO), and a first-class nominee (Art. 53(4) — the nominee's holding/control is attributed to the nominator, revealing the hidden principal = net more scrutiny). 50%+1 is modelled as a distinct third threshold (majority_control_via_ownership) reported separately from the 25%ownership_interest_metinterest test and from the sanctions >50% test below. - Art. 54 two-limb coexistence + rights-type (ADR-0130, #540) — Live. A distinct
_apply_art54pass reports natural persons who both control a direct owner (limb (a)) or own the controller (limb (b)), each with its ownqualified_viamarker (never conflated with the plain Art. 52 sum), scoped to explicit control-via-other-means only. A non-share holding (OwnershipRightsType: voting rights, profit share, internal resources, liquidation balance — Art. 52(1)) counts toward the threshold and is labelled for audit. - Look-through legal-arrangement regimes + Art. 60 state machine (ADR-0135, #539) — Live (wired into
UBOComputationService.compute); graph population deferred #632. The role-based arrangement compute (previously dead code) is now concatenated first-class alongside ownership/control/SMO.ArrangementTypeselects the regime (trust → Art. 58 / foundation → Art. 59 / CIU → Art. 61 lex specialis). A fail-closed Art. 60 discretionary state machine (active/potential/selected/default_active) raises on an illegal transition and surfaces a still-potentialobject-of-power/default-taker asqualified=False+not_assessed(not-yet-excluded, never dropped). Corporate parties resolve through via depth-bounded, cycle-guarded recursion; an unenumerable beneficiary class becomes one explicitnot_assessedgap row naming the class, never a clean "no BO". - Art. 62 regulated-dataset shape + data-currency job (ADR-0136, #541) — the Art. 62 dataset shape (direct/indirect split, qualifying mechanisms, mandatory provenance, disclosed gaps) is stamped on every determination (Live), but the 28-day / annual currency monitoring check is dark-launched (
bo_dataset_currency_monitoring_enabled=false; a never-computed dataset is fail-closed OVERDUE). Identity-field population (DOB / nationality / residence / TIN) is a disclosed honest gap deferred #634. - Sanctioned-ownership / control — the EU 50%-rule (ADR-0127, #535) — Dark-launched (
sanctioned_ownership_control_enabled=false).compute_sanctioned_ownership_control(graph, sanctions_matches)runs the same multiply-along-path × sum-across-paths arithmetic (UBOComputationEngine.node_holding) to test whether sanctioned persons individually or collectively control / hold >50% of a graph node (an entity is itself frozen when so owned, Art. 20(1)(d)). Two-lane attribution (ADR-0073 R9 / ADR-0078): areg_nomatch is the verified lane (CONFIRMED → CRITICAL finding +SANCTIONED_OWNERSHIP_CONFIRMEDescalator flooring the score to 90); a name-only match is a labelledsanctioned_name_candidatenever floored on name alone, never dropped. Fail-closed: an unknown-weight edge →not_assessed+ HIGH gap, never a silent 0. - Nine-measure CDD register + identity dataset (ADR-0137 / ADR-0138, #544 / #543) — the typed Art. 20(1)(a)–(i) CDD register and the per-actor Art. 22(1) identity dataset are covered on AMLR Readiness → §3;
_partition_ubosinamlr_section_c.pyis the single evidenced/SMO/traced partition both the register and the SMO record below reuse.
AMLR Art. 22(2) — no-BO record, SMO verification, and tipping-off abstention
Electing an SMO of last resort is only half of AMLR Art. 22(2). The Regulation requires that where, after exhausting all possible means, no natural person is identified as beneficial owner, the obliged entity must (1) record — as an immutable, timestamped state — that no BO was identified and that the means were exhausted; (2) identify AND verify every senior managing official; and (3) where verifying the SMOs would tip off the customer to the BO doubts, abstain from that verification and instead record the steps taken and the difficulties encountered. smo_fallback_record.py (ADR-0126) implements all three, fail-closed (add scrutiny, never suppress a signal).
The exhausted-means record — honest completeness, never assumed
build_no_bo_identified_record(investigation_result, ubo_results) reuses amlr_section_c._partition_ubos (the single evidenced/SMO/traced partition) and returns a structured record only when the ownership/control computation reached no natural-person beneficial owner. It carries a typed reason (smo_fallback_only vs no_natural_person_owner_evidenced), the identification means attempted (only steps with concrete evidence — presence ≠ evidence), the SMO election, and an all_possible_means_exhausted claim.
That completeness claim is never assumed from a non-empty result. assess_means_exhaustion withholds it whenever the determination carried a data gap: a traced person with a typed not_assessed ownership outcome (an unknown-weight edge, issue #530), a not_assessed control outcome (no control signal at all), or a truncated traversal. In that case all_possible_means_exhausted is false and the unassessed dimensions are listed in unassessed_dimensions — the record states honestly that the means were not exhausted rather than overstating a control's completeness over a gap (ADR-0067). A thin async writer stamps a timestamp and writes it immutably to audit_events (event_type="no_bo_identified", ADR-0064 immutability + ADR-0109 hash chain) via the record_no_bo_identified Temporal activity, which runs after populate_knowledge_graph so the ownership structure is populated.
SMO election routes into the verification gate
ubo_engine.compute stamps requires_verification=True on every SMO election. That flag is read by build_smo_verification_gate, which routes each flagged SMO through the min-2-independent-source verification gate (evaluate_profile_gates, ADR-0057/0058) — reusing the director-profile builder so an SMO that is also a resolved director is gated on its real, per-source, central-tagged evidence, and an SMO with no matching director data yields a name-only profile that is honestly under-verified. The per-SMO gate outcome (gated_attributes / persons / summary) is embedded in the same immutable no_bo_identified record. This closes the "elected but unverified" gap: it surfaces an under-verified SMO, it does not auto-block.
Tipping-off abstention — precondition-gated, immutable, fail-closed reads
app/api/smo_abstention.py exposes an append-only, tenant-scoped officer surface:
| Endpoint | Purpose |
|---|---|
POST /api/cases/{workflow_id}/smo-abstention (CASE_DECIDE) | Record an Art. 22(2) tipping-off abstention: a required steps-taken + difficulties-encountered narrative (jointly ≥ 50 chars, mirroring the ADR-0097 rationale bar; 422 otherwise), written immutably to audit_events (event_type="smo_verification_abstained_tipoff"). |
GET /api/cases/{workflow_id}/smo-abstention (CASE_READ) | List both Art. 22(2) record types for the case (retrievable audit surface). |
Two safeguards keep the immutable trail truthful. Precondition — the write is refused (409) unless the case genuinely carries a recorded no_bo_identified determination that elected an SMO, so a natural-person KYC case or a KYB case with a real evidenced owner can never mint a legally-false abstention record. Fail-closed reads — both endpoints raise 503 (never a silent empty list, never a permissive write) when the audit trail cannot be read, so an unavailable trail is never indistinguishable from a case that genuinely has no Art. 22(2) records (claim-vs-check / ADR-0067).
Components
| Module | Purpose |
|---|---|
app/services/ubo_engine.py | The pure UBOComputationEngine: multi-path ownership DFS with per-path visited sets and product summation, control-reachability enumeration, the SMO post-pass, the inclusive/exclusive comparator, and THRESHOLD_EPSILON. No DB/Neo4j/config imports |
app/services/ubo_threshold_resolver.py | resolve_ubo_threshold(country) → (fraction, inclusive, legal_basis) from ubo_thresholds.json; AMLR 25%/inclusive default; loud on dataset-load failure |
app/services/ubo_service.py | UBOComputationService.compute — fetch ownership graph → resolve threshold → run engine → append-only persist to ubo_computations (or persist=False for coverage reads) |
app/services/arrangement_ubo.py | compute_arrangement_ubos — role-based UBO determination for trusts/foundations (no ownership %) |
app/services/smo_fallback_record.py | AMLR Art. 22(2): build_no_bo_identified_record (honest exhausted-means, assess_means_exhaustion), build_smo_verification_gate (routes the requires_verification SMO election through the gate), and the immutable writers |
app/api/smo_abstention.py | Officer tipping-off abstention endpoints (POST/GET) — precondition-gated write, fail-closed reads, immutable audit_events |
packages/trustrelay-models/.../ubo.py | Domain models: OwnershipEdge/OwnershipGraph, PathEdge/PathTrace, ControlEdge/ControlTrace, SmoCandidate, BeneficialOwnerResult (incl. requires_verification) |
Related
- AMLR Readiness — the programme-level map of the AMLR uplift (live / dark-launched / deferred status of every determination)
- Person Verification — verifying the identity of each determined UBO
- Continuous Monitoring — risk-paced re-screening of UBOs over the relationship lifetime
- Sanctions Screening — screening each determined UBO against sanctions lists
- ADRs: ADR-0053 (ownership computation engine), ADR-0054 (control dimension), ADR-0055 (SMO fallback), ADR-0061 (role-based legal arrangements), ADR-0126 (Art. 22(2) exhausted-means record + SMO verification + tipping-off abstention), ADR-0127 (sanctioned-ownership 50%-rule, dark-launched), ADR-0130 (Decimal arithmetic + Art. 54 two-limb + rights-type), ADR-0133 (effective-dated threshold snapshot), ADR-0134 (control-via-other-means), ADR-0135 (look-through arrangements + Art. 60), ADR-0136 (Art. 62 dataset + data-currency), ADR-0137/0138 (nine-measure CDD register + identity dataset)