Skip to main content

Person & UBO Verification

Verifying who a beneficial owner or subject person actually is — and proving it from independent evidence — is the highest-stakes part of KYB. This page documents the four gates Trust Relay enforces over person and UBO identity: a 1-to-many provenance model, a minimum-two-independent-source gate, a central-register cross-check rule, and a hard, fail-closed block on approval when a UBO or identity discrepancy is still open.

These gates are enforcement points, not scores. A weak identity does not merely lower a confidence number that an officer can approve over — it produces a blocking gap that must be closed (or explicitly, auditably overridden) before the case advances.

Why these gates exist

AMLR §6 requires that identity be verified from reliable and independent sources, and treats the central beneficial-ownership / Transparency Register as a cross-check — never the sole primary source, because a central register frequently just mirrors data the subject self-declared. Before this sprint, Trust Relay had three concrete gaps against that mandate:

Pre-sprint gapConsequenceFixed by
Verification was advisory — diversity only fed a compute_source_diversity scoreA single-source attribute passed silently with a lower scoreADR-0057
Central registers counted as authoritative on their own (SOURCE_TYPE_POINTS lumped the BO register with KBO and the gazette)A UBO could be "verified" entirely from the register that mirrored its own filingADR-0058
An open UBO discrepancy did not block approval — discrepancies only deducted a score, never checked at decision timeAn officer could approve a case over an open CRITICAL UBO conflictADR-0059

The unifying principle (see the project's regulatory memory): the system can always add scrutiny but may never silently suppress a risk signal. Every gate below is fail-closed and every override is audited.

The 1-to-many provenance model (ADR-0056)

Company facts already used a strong 1-asserted-to-many-verified shape (CompanyProfile.facts: dict[str, list[SourcedFact]]). Person and UBO identity — the highest-stakes attributes — used a flatter single-row PersonVerification ORM record. ADR-0056 brings persons up to the same provenance standard with a dedicated VerificationRecord (defined in trustrelay-models/person_verification.py):

FieldMeaning
valueThe verified attribute value (e.g. a date of birth)
sourceThe originating source — independence is measured by distinct source
methodHow it was verified (registry, eID, document scan, …)
assurance_levellow / substantial / high — a registry scrape is not an eID-Easy "high" check
collected_atWhen this record was captured
evidence_refPointer into the evidence bundle / Trust Capsule
is_central_registerMarks central BO/Transparency-register origin (cross-check only)

PersonVerificationProfile aggregates these per person as attributes: dict[str, list[VerificationRecord]], keyed by the gated attributes name, date_of_birth, nationality, residential_address, and ownership_percentage, alongside any cross-source discrepancies.

The 1-to-many list is exactly the shape that lets the platform express "two independent sources agree on this nationality" — a single wide flat row cannot. A new VerificationRecord (rather than reusing SourcedFact) is deliberate: method, assurance_level, and is_central_register are precisely the discriminators the downstream gates enforce against, and they do not belong on the company contract.

Min-2-independent-source gate (ADR-0057)

verification_gate.py enforces a gate, not a score. An attribute reaches verified only when ≥ 2 records from distinct sources agree. Independence is by distinct source, case-insensitive — two pulls from one provider count once.

count_independent_sources(records):
return |{ r.source.lower() for r in records if r.source }|

attribute_verification_status(attr, records, min_independent=2):
n = count_independent_sources(records)
non_central = count_non_central_sources(records)
if n < min_independent: status = "insufficient_sources" # blocking
elif non_central < 1: status = "central_register_only" # blocking (see ADR-0058)
else: status = "verified"

evaluate_profile_gates runs this over every gated attribute, present or not — a gated-but-absent attribute is itself a blocking gap (zero sources), because AMLR requires it verified and it is not. The result exposes:

  • blocking_gaps — the AttributeGateResult list for every unmet attribute.
  • all_verified — the pass condition (True iff there are no blocking gaps).

These two signals are precisely what the approval-block rule (ADR-0059) consumes. The default minimum (DEFAULT_MIN_INDEPENDENT_SOURCES = 2) and the GATED_ATTRIBUTES set are configurable, ready to plug into the per-segment risk-config layer.

Counting records instead of distinct sources was rejected: two pulls from the same provider are not two independent verifications.

Central-register cross-check rule (ADR-0058)

A central register (UBO register, Transparency Register, RBE, …) counts only as a cross-check. The gate therefore requires ≥ 1 non-central source in addition to the min-2 rule: an attribute backed only by central registers returns the dedicated status central_register_only and blocks. Two central registers still block.

is_central(record): # belt-and-suspenders
return record.is_central_register or record.source.lower() in CENTRAL_REGISTER_SOURCE_NAMES

count_non_central_sources(records):
return |{ r.source.lower() for r in records if r.source and not is_central(r) }|

is_central trusts the explicit is_central_register flag first, then falls back to a recognised central-register source name (CENTRAL_REGISTER_SOURCE_NAMES) so that a producer which forgets to set the flag cannot let a central-only attribute slip through. The insufficient_sources check takes precedence over central_register_only, so a single central source reads as "insufficient", not "central-only".

This directly counters the audit's core finding — register-declared UBOs treated as a primary source — by making them self-insufficient.

Block approval on open UBO discrepancy (ADR-0059)

discrepancy_approval_gate.py is a hard, fail-closed gate evaluated on the approve and approve_with_restrictions decision paths only. evaluate_approval_block blocks when any discrepancy is open and is either a UBO/identity field or CRITICAL severity (any field):

UBO_IDENTITY_FIELDS = { ubo_ownership, ubo, beneficial_owner, directors, legal_form,
registered_address, identity, name, date_of_birth, nationality }

is_ubo_identity_discrepancy(d):
return field(d) in UBO_IDENTITY_FIELDS or severity(d) == "critical"

evaluate_approval_block(discrepancies):
blocking = [ d for d in discrepancies
if status(d) == "open" and is_ubo_identity_discrepancy(d) ]
return ApprovalBlockResult(blocked = bool(blocking), blocking = blocking, ...)

The gate is dict/object-tolerant: it reads field/severity/status via a defensive accessor and falls back to the legacy resolved bool when no status is present, so it works on both the rich Discrepancy model and the cross-reference FieldDiscrepancy shape.

Discrepancy lifecycle

A discrepancy now carries a lifecycle status: open | resolved | escalated | reported plus a sar_reference. reported is the SAR terminal state — the resolve endpoint enforces that a reported discrepancy carries its SAR reference, making the reportable AMLR outcome representable and linkable.

Reconciliation (id OR field)

evaluate_open_discrepancy_block fetches the case's discrepancies from cross_reference_result, then reconciles each against the discrepancy_resolutions table. Because the cross-reference FieldDiscrepancy shape has no id — only a field name — a stored discrepancy_resolutions.discrepancy_id may be either the discrepancy id or its field. Reconciliation matches on both. The reconciliation step runs outside the fetch's try so that a bug in the pure gate logic surfaces loudly rather than failing open.

Fail-CLOSED, audited override

  • Blocked + no overrideHTTP 409; the Temporal workflow is never signalled.
  • Blocked + override_open_discrepancies + a reason → emit a non-suppressible approval_override_open_discrepancy audit signal, then proceed.
  • Override without a reason → HTTP 400.
  • A DB/fetch failure in the gate blocks (overridable) — it never fails open.

The fail-closed posture is the whole point: a query bug must not silently unblock an open UBO discrepancy. Failing open on a compliance gate is exactly the suppression the architecture forbids (no-suppression principle; EU AI Act Art. 14 robustness). The audited, reasoned override is the safety valve.

Components

ModulePurpose
app/services/verification_gate.pyMin-2-independent-source gate (evaluate_profile_gates, attribute_verification_status), central-register cross-check (is_central, count_non_central_sources), blocking_gaps / all_verified
app/services/discrepancy_approval_gate.pyevaluate_approval_block — hard, fail-closed block on open UBO/identity or CRITICAL discrepancies
app/services/case_decisions_service.pyevaluate_open_discrepancy_block — fetches discrepancies, reconciles resolution state (id OR field), fails closed
app/api/case_decisions.pysubmit_decision wiring — 409 / 400 enforcement and the non-suppressible override audit signal
trustrelay-models/person_verification.pyVerificationRecord, PersonVerificationProfile, AttributeGateResult, ProfileGateResult
trustrelay-models/discrepancy_gate.pyApprovalBlockResult, BlockingDiscrepancy
  • ADR-0056 — 1-to-many person/UBO verification model (VerificationRecord + PersonVerificationProfile).
  • ADR-0057 — min-2-independent-source verification gate (a gate, not a score; independence by distinct source).
  • ADR-0058 — central register = cross-check only (require ≥ 1 non-central source; central_register_only blocks).
  • ADR-0059 — block approval on open UBO discrepancy (hard, fail-closed gate, audited override) + discrepancy SAR lifecycle.
  • UBO Determination — how beneficial owners are computed before they are verified here.
  • Sanctions Screening — the complementary always-visible, evidence-based screening gate.