ADR-0056: 1-to-many person/UBO verification model
Status: Accepted Date: 2026-06-15 Supersedes: none Superseded by: none Deciders: Adrian (Soft4U), Claude Opus 4.8
Decision context:
- Latency: none — pure in-memory model + builder; no I/O.
- Dependency surface: no new packages. Adds
VerificationRecord+PersonVerificationProfiletotrustrelay-modelsand a pureperson_verification_service.add_verificationbuilder. - Debuggability: each verification carries source + method + assurance_level + timestamp + evidence_ref;
cross-source conflicts surface as
Discrepancyobjects (reused from the company path). - Reversibility: branch revert; purely additive (new module + exports), no existing model changed.
- Blast radius: additive. The flat
PersonVerificationORM row and/identityAPI are untouched. - Alternative considered: reuse
SourcedFactfor persons (rejected — lacks method/assurance/central-register).
Context
Company facts use a strong 1-asserted-to-many-verified provenance model
(CompanyProfile.facts: dict[str, list[SourcedFact]]). UBO/person identity — the highest-stakes
attributes — used the flatter PersonVerification ORM row (one row per person, generic checks JSONB,
single status/verification_method/assurance_level). So the cleanest, most defensible part of the
provenance architecture did not cover beneficial owners, and no VerificationRecord model existed (it
was the deferred reference in ubo.py evidence_ref).
The downstream AMLR rules require this: min-2-source verification (#34) must count independent records; the central-register-cross-check rule (#35) must require ≥1 non-central source; the block-on-discrepancy rule (#36) acts on cross-source conflicts. None of these can be expressed against a single flat row.
Decision
Add a person analogue of the company SourcedFact pattern, richer by method and
assurance_level (and an is_central_register flag):
VerificationRecord(trustrelay-models/person_verification.py):value, source, method, assurance_level, collected_at, evidence_ref, confidence, is_central_register— one independent, method-tagged verification of one attribute.PersonVerificationProfile:attributes: dict[str, list[VerificationRecord]](keys: name, date_of_birth, nationality, residential_address, ownership_percentage) +discrepancies+ identifiers — one asserted attribute → many records.person_verification_service.add_verification: appends a record and detects cross-source value discrepancies (same attribute, different value, different source → aDiscrepancy, reused from the investigation/company model), mirroringcompany_profile_service.add_facts.
A new VerificationRecord (not an extension of SourcedFact) is deliberate: method,
assurance_level, and is_central_register are exactly the fields #34/#35 enforce against, and adding
them to SourcedFact would change the company contract.
Consequences
Positive
- Persons get the same auditable 1-to-many provenance as companies.
- #34 (min-2-source), #35 (central-register cross-check), #36 (block-on-discrepancy) have a concrete
model to enforce against;
assurance_level+is_central_registerare first-class. - Purely additive — no existing model or API breaks.
Negative
- A second provenance model (
VerificationRecordalongsideSourcedFact) — an intentional divergence carrying the extra fields companies don't need. - The model is in-memory only here; persistence (and idempotency on re-add) is deferred to the enforcement/persistence issues.
Neutral
- The flat
PersonVerificationORM row +/identityAPI remain; repointing them to this model is a separate persistence step.
Alternatives Considered
Alternative 1: Reuse SourcedFact for person attributes
- Why rejected:
SourcedFactlacksmethod,assurance_level, andis_central_register— the precise discriminators #34/#35 enforce against. Adding them toSourcedFactwould pollute the company contract for fields companies don't use.
Alternative 2: Add columns to the flat PersonVerification ORM row
- Why rejected: that keeps the one-row-per-person shape, which is exactly the flatness this ADR removes. The 1-attribute→many-records list is the point; a wider flat row cannot express "two independent sources verified the nationality."