Skip to main content

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 + PersonVerificationProfile to trustrelay-models and a pure person_verification_service.add_verification builder.
  • Debuggability: each verification carries source + method + assurance_level + timestamp + evidence_ref; cross-source conflicts surface as Discrepancy objects (reused from the company path).
  • Reversibility: branch revert; purely additive (new module + exports), no existing model changed.
  • Blast radius: additive. The flat PersonVerification ORM row and /identity API are untouched.
  • Alternative considered: reuse SourcedFact for 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):

  1. 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.
  2. PersonVerificationProfile: attributes: dict[str, list[VerificationRecord]] (keys: name, date_of_birth, nationality, residential_address, ownership_percentage) + discrepancies + identifiers — one asserted attribute → many records.
  3. person_verification_service.add_verification: appends a record and detects cross-source value discrepancies (same attribute, different value, different source → a Discrepancy, reused from the investigation/company model), mirroring company_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_register are first-class.
  • Purely additive — no existing model or API breaks.

Negative

  • A second provenance model (VerificationRecord alongside SourcedFact) — 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 PersonVerification ORM row + /identity API remain; repointing them to this model is a separate persistence step.

Alternatives Considered

Alternative 1: Reuse SourcedFact for person attributes

  • Why rejected: SourcedFact lacks method, assurance_level, and is_central_register — the precise discriminators #34/#35 enforce against. Adding them to SourcedFact would 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."