Skip to main content

ADR-0130: Decimal ownership arithmetic + AMLR Art. 54 two-limb identification + rights-type ownership

Date: 2026-07-24 Status: Accepted Deciders: Adrian (Soft4U BV), Claude Opus 4.8 (implementation agent)

Decision context:

  • Latency: not measured — the UBO engine is pure in-memory arithmetic over a bounded graph (MAX_DEPTH=10, MAX_PATHS_PER_PERSON=10_000). Decimal at prec=50 is a small constant-factor slower than IEEE-754 float per multiply, negligible against the Neo4j graph read that dominates UBOComputationService.compute. The Art. 54 pass adds two bounded DFS traversals over the same graph. No user-visible latency change.
  • Dependency surface: zero new packages — decimal is stdlib. The only type change ripples through the trustrelay-models package (OwnershipEdge.fraction, PathTrace.productDecimal; new OwnershipRightsType enum; BeneficialOwnerResult.aggregated_fraction), which is editable-installed, so consumers pick it up with no reinstall.
  • Debuggability: an exact Decimal aggregate is far easier to reason about at 3am than a float that reads 0.30000000000000004. A field_validator coerces at the model boundary via Decimal(str(v)), so a wrong value is caught where it enters, not deep in the sum. Art. 54 identifications carry a distinct qualified_via marker (art54_control_of_direct_owner / art54_ownership_of_controller), so the basis is legible in the stored result and the audit trail.
  • Reversibility: the Decimal migration is a type change across ~4 files + the package model — a substantive but mechanical revert. Art. 54 is a single additive method (_apply_art54) gated on the presence of explicit control edges; deleting the one call site reverts it. Rights-type is one enum + two optional fields, default "shares" — removing them is byte-safe.
  • Blast radius: additive-with-a-type-change. The COMPUTE and threshold COMPARE become Decimal; presentation fields (aggregated_pct, PathEdge.percentage) stay float. Consumers that read aggregated_pct/qualified/qualified_via are unchanged; ubo_service persistence switches to model_dump(mode="json") to serialise the Decimal fields; amlr_section_c._EVIDENCED_BASES gains the two Art. 54 markers; the graph read boundary converts Decimal(str(pct))/Decimal(100).
  • Alternative considered: keep float and enlarge THRESHOLD_EPSILON — rejected because an epsilon papers over non-reproducibility without removing it (two runs of the same graph can still differ in the last bit), and "byte-identical" was an explicit acceptance requirement (#540).

Context

Issue #540 (AMLR readiness epic #528, Wave 2) makes three corrections to the pure UBO ownership engine (app/services/ubo_engine.py + the trustrelay-models UBO models), all grounded in Regulation (EU) 2024/1624 (AMLR) Art. 52(1) (definition of an ownership interest) and Art. 54 (coexistence of ownership and control):

  1. Float arithmetic is not reproducible. The engine multiplied float edge fractions along a path and summed the products across paths, qualifying a UBO at aggregated >= threshold - 1e-9. The THRESHOLD_EPSILON = 1e-9 existed precisely because IEEE-754 makes 0.15 + 0.15 and 25 × 0.01 land near — not on — the boundary, and because two runs of an identical graph could differ in the last bit. For a compliance engine whose determinations must be reproducible and auditable (EU AI Act Art. 12; the ADR-0124 hash-audited-scoring reproducibility boundary), "near the threshold" is not good enough — an identical ownership graph must produce a byte-identical result.

  2. AMLR Art. 54 was not implemented. Art. 54 ("Coexistence of ownership interest and control in the ownership structure") states that where corporate entities are owned through a multi-layered structure and, in one or more chains, ownership interest and control coexist in different layers, the beneficial owners are (a) the natural persons who control the legal entities that have a direct ownership interest in the customer, and (b) the natural persons who have an ownership interest in the legal entity that controls the customer. Art. 52(1) explicitly carves this out of the plain multiply-and-sum ("...by adding together the results from those various chains, unless Article 54 applies"). The plain Art. 52 sum and Art. 53 majority-control pass therefore miss the ultimate natural persons in a genuine coexistence structure — e.g. a person who controls (via other means) a holdco that directly owns the customer but holds only 30% of it: 0% indirect ownership, no majority-control chain to the customer, yet a beneficial owner under Art. 54(a).

  3. Only shareholding was modelled. Art. 52(1) defines an "ownership interest" as "25% or more of the shares or voting rights or other ownership interest ... including rights to a share of profits, other internal resources or liquidation balance." The engine modelled only a share percentage, so a person holding a 30% profit-share right (and 0% shares) was invisible to the ownership computation.

The already-correct typed-absence handling (unknown-weight edge → Outcome.NOT_ASSESSED, never a silent 0; ADR-0126/#530) must be preserved unchanged.

Decision

1 — Decimal ownership arithmetic

Make OwnershipEdge.fraction and PathTrace.product decimal.Decimal in the trustrelay-models package, and carry the exact 0–1 aggregate on a new BeneficialOwnerResult.aggregated_fraction: Decimal | None. aggregated_pct (0–100) and PathEdge.percentage (0–100) stay presentation floats; the compute and the threshold compare are Decimal.

  • The conversion discipline lives in the model. A field_validator(mode="before") on fraction (and product) coerces any int/float/str via Decimal(str(v)) — never Decimal(float), which would re-introduce the IEEE-754 representation error. So no caller can inject float error regardless of how an edge is constructed (tests pass float literals; the graph reader passes a Decimal division). Decimal(str(0.15)) == Decimal("0.15") exactly, because str(float) uses the shortest round-tripping repr.
  • The graph-read boundary converts deterministically. graph_service.fetch_ownership_graph now emits fraction=Decimal(str(pct)) / Decimal(100) (e.g. pct=15 → Decimal("0.15")), and Decimal(0) for the unknown-weight placeholder.
  • A fixed decimal context bounds the arithmetic. All regulated arithmetic in compute and node_holding runs inside localcontext(Context(prec=50, rounding=ROUND_HALF_EVEN)). prec=50 gives comfortable headroom over any realistic graph (≤10 hops of 2–4-dp fractions), so no product is ever rounded — the aggregate is exact and independent of the ambient thread-local context. ROUND_HALF_EVEN makes the (unreached, in practice) rounding path deterministic too.
  • THRESHOLD_EPSILON is dropped. With exact Decimals the boundary is compared exactly: inclusive aggregated >= threshold (AMLR "25% or more"), exclusive > (UK "more than 25%"). The inclusive/exclusive semantics are unchanged.
  • Typed absence preserved. The unknown-weight guard (new_product = product if edge_unknown else product * edge.fraction) is unchanged — an unknown edge still taints the path not_assessed and is excluded from the sum, never folded in as a Decimal 0.

ubo_service persistence switches to r.model_dump(mode="json") so the Decimal fields serialise to JSONB (the monitoring read-back reads only aggregated_pct/qualified, which stay JSON numbers/bools).

2 — AMLR Art. 54 two-limb branch

A distinct pass, UBOComputationEngine._apply_art54, runs after the ownership/control passes and before the SMO fallback. It computes the two limbs separately, each tagged with its own qualified_via marker so the basis is never conflated with the plain ownership sum:

  • Limb (a) — art54_control_of_direct_owner: natural persons who control the legal-entity direct owners of the subject (a company with an ownership edge into the subject). A natural-person direct owner is already caught by the plain ownership pass, so only corporate direct owners are traced.
  • Limb (b) — art54_ownership_of_controller: natural persons who hold ownership >= the Art. 52 threshold in a legal-entity controller of the subject.

Scope decision (documented interpretation): Art. 54's control dimension uses explicit control edges only (control "via other means", Art. 53(3)) — _build_explicit_control_adjacency, which deliberately excludes the >50%-ownership-derived control hops that the subject-level _build_control_adjacency adds. Rationale: a pure majority-ownership chain is already fully identified by the Art. 52 ownership sum (as an owner) and the Art. 53 majority-control pass (as a controller). Re-deriving those persons under Art. 54 would (a) conflate Art. 54 with the plain passes and (b) wrongly fire on a pure-ownership chain that has no genuine coexistence-in-different-layers. Art. 54 exists to catch the persons the plain passes MISS — those linked through control via other means. This is the conservative, defensible line; it never suppresses a signal (a person controlling an intermediate via majority ownership is still surfaced as an Art. 53 controller of that intermediate and, if applicable, as a below-threshold Art. 52 owner) — it only declines to elevate a below-threshold owner to BO on a majority-of-an-intermediate basis. Broadening limb (a)/(b) to majority-ownership control is a tracked follow-up.

The pass only ever ADDS a beneficial owner: a person the plain passes already identified keeps their basis and gains the Art. 54 marker; a missed person becomes a new qualified result (aggregated_pct=0.0, since their status is by the Art. 54 rule, not a %-of-subject). An Art. 54 BO suppresses the SMO fallback (a real natural-person BO exists). Both DFS traversals are cycle-safe (per-path visited set) and depth-capped at MAX_DEPTH.

3 — Rights-type ownership

Add OwnershipRightsType(str, Enum)shares (default), voting_rights, profit_share, internal_resources, liquidation_balance — the forms of "ownership interest" Art. 52(1) enumerates. OwnershipEdge and PathEdge carry a rights_type field. A rights-type holding counts toward the ownership threshold exactly like a shareholding (they are all "ownership interest" under Art. 52(1)) and the type is propagated onto the contributing PathEdge so the basis is labelled for audit. _EVIDENCED_BASES in amlr_section_c gains the two Art. 54 markers so §2(c)/§2(e) count an Art. 54 BO as a genuine natural-person beneficial owner.

Consequences

Positive

  • Identical ownership graphs produce byte-identical Decimal aggregates; the exact 25% boundary (a single 25% edge, or 25 disjoint 1% paths) qualifies with no float epsilon.
  • The engine now identifies Art. 54 coexistence beneficial owners the plain passes missed, with a distinct, auditable basis marker per limb.
  • Profit-share / internal-resources / liquidation-balance rights count toward the threshold and are labelled by type — closing the "shareholding only" gap.
  • The Decimal(str(v)) model validator makes the "never Decimal(float)" rule structural, not a convention a future caller can forget.

Negative

  • A type change across the trustrelay-models UBO models: fraction/product are now Decimal. Direct float comparisons on these fields break (abs(e.fraction - 0.6) raises TypeError; Decimal("0.15") != 0.15) — three existing test assertions were updated to compare against Decimal. Any future consumer doing float arithmetic on fraction/ product must convert with float(...) at the boundary.
  • ubo_service persistence now uses model_dump(mode="json"), so PathTrace.product is stored as a JSON string in the append-only ubo_computations.results JSONB (older rows, which stored a float, remain valid — the field is read nowhere that parses it back).
  • The Art. 54 explicit-control scoping is a documented, conservative interpretation, not the broadest possible reading; the majority-ownership-coexistence extension is deferred.

Neutral

  • aggregated_pct and PathEdge.percentage remain presentation floats — the display contract (round(..., 2)) is unchanged.
  • NodeHolding.aggregated_fraction (the #535 sanctioned-ownership consumer) is now a Decimal; sanctioned_ownership coerces it to float via the SanctionedParty.aggregated_pct field, so its behaviour is unchanged.

Alternatives Considered

Alternative 1: Keep float, widen THRESHOLD_EPSILON

  • Leave the arithmetic in IEEE-754 float and grow the epsilon so boundary cases qualify.
  • Why rejected: an epsilon hides non-reproducibility, it does not remove it. Two runs of an identical graph can still differ in the last bit, and "byte-identical result for identical input" was an explicit #540 acceptance criterion (and the ADR-0124 reproducibility boundary). The epsilon also silently shifts the true comparison threshold, muddying the inclusive/exclusive AMLR-vs-UK semantics.

Alternative 2: Convert to Decimal inside the engine (leave the model float)

  • Keep OwnershipEdge.fraction: float and convert to Decimal at each read inside the engine via Decimal(str(edge.fraction)).
  • Why rejected: it puts the determinism guarantee at the mercy of every read site (miss one and float error leaks back in), and it cannot convert at the original precision — by the time the engine reads a float, pct/100 has already been computed in float. Converting at the model boundary (validator) and the graph-read boundary (Decimal(str(pct))/Decimal(100)) captures the clean decimal at the source and makes the guarantee structural.

Alternative 3: Fold Art. 54 into the existing ownership sum

  • Add the Art. 54-identified persons into the plain ownership results without a distinct marker.
  • Why rejected: Art. 52(1) and Art. 54 are distinct identification methods with distinct legal bases; conflating them would make the audit trail unable to answer "why is this person a beneficial owner?" — a direct EU AI Act Art. 12 traceability failure. The two limbs are also distinct from each other and must be legible separately.

Round-2 review refinements (Codex, PR #569)

  • Art. 54(b) fail-closed for unknown-weight paths. A person whose only Art. 54(b) link is an unknown-weight ownership path to a controller cannot be weighed — the aggregate is not_assessed, not zero. The initial implementation discarded the unknown flag and treated the zero aggregate as a completed below-threshold check, so the person vanished (SMO fallback) instead of surfacing the ownership dimension. Fixed to retain an unassessed Art. 54(b) candidate (qualified=False, ownership_outcome=not_assessed, art54_ownership_of_controller marker) — an unknown weight is never a confirmed BO and never a silent clear (ADR-0067 never-suppress). Test: test_art54_limb_b_unknown_weight_path_surfaces_not_assessed.
  • Rights-type carried from the persisted graph, not only test graphs. The production graph read (graph_service.fetch_ownership_graph) now maps the persisted interest_types relationship property (written by graph_etl) onto OwnershipEdge.rights_type for both the entity (IS_SUBSIDIARY_OF) and person (HAS_UBO) edges, so a voting/profit/liquidation holding on real data is no longer read under the shares audit label. Test: test_interest_types_map_to_rights_type.