Skip to main content

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)
qualified = aggregated >= threshold - EPSILON # inclusive comparator (AMLR)
# or aggregated > threshold + EPSILON 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. A THRESHOLD_EPSILON of 1e-9 is applied at the boundary so that, at an exact 25% holding, the engine errs toward flagging a UBO (the no-suppression principle). Each qualifying person carries a PathTrace — the contributing paths, the per-edge percentages, and the product — so the determination is self-explaining.

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 ControlEdgemajority_voting, appoint_remove_board, veto_rights, control_profit_distribution, or other_dominant_influence; or
  • an OwnershipEdge with fraction > 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:

DimensionMeaning
valueThe ownership threshold (e.g. 25.0; a high_risk_override entry at 15.0 exists but applies_by_default: false)
inclusivetrue ⇒ 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.

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 OwnershipGraph to engine.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:

FieldAudit purpose
qualifiedThe determination — always check this first
qualified_viaThe basis(es) that conferred status: ownership, control, smo_fallback, arrangement_role
reason_codeThe truthful, threshold-aware label (ownership_25, ownership_15, ownership_25+control, control, smo_fallback, arrangement_trustee, …)
threshold_pctThe applied jurisdiction threshold — which rule ran
path_traces / control_pathsThe contributing ownership paths and control chains, edge by edge
audit_noteThe explainability note (e.g. why the SMO fallback fired)
truncatedSet 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.

Components

ModulePurpose
app/services/ubo_engine.pyThe 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.pyresolve_ubo_threshold(country)(fraction, inclusive, legal_basis) from ubo_thresholds.json; AMLR 25%/inclusive default; loud on dataset-load failure
app/services/ubo_service.pyUBOComputationService.compute — fetch ownership graph → resolve threshold → run engine → append-only persist to ubo_computations (or persist=False for coverage reads)
app/services/arrangement_ubo.pycompute_arrangement_ubos — role-based UBO determination for trusts/foundations (no ownership %)
packages/trustrelay-models/.../ubo.pyDomain models: OwnershipEdge/OwnershipGraph, PathEdge/PathTrace, ControlEdge/ControlTrace, SmoCandidate, BeneficialOwnerResult
  • 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)