ADR-0101: Natural-person KYC onboarding with a deterministic person risk verdict (v1)
Date: 2026-07-06 Status: Accepted Deciders: Adrian (Soft4U), Claude Opus 4.8
Decision context:
- Latency: one deterministic scoring pass (pure rules, no LLM) + the existing person screening in the KYC workflow fork (off any user request). No new hot path.
- Dependency surface: no new packages, no migration — reuses
cases.additional_data(JSONB),company_name(already holds the person's legal name in the fork), the existingscreen_opensanctions(entity_type="person")screener, and the person models. One new pure moduleperson_risk.py; edits to the KYC fork,create_case, andCreateCaseDialog. - Debuggability: a natural-person case gains a real, auditable
risk_assessment(composite_score + level + factor list) instead of a mock 0.1/0.8; a missing screen is an explicit NOT-ASSESSED/WARNING, not a benign default. - Reversibility: additive — a new engine + a fork branch + an API relaxation + a dialog toggle. Reverting re-hides the picker and restores the mock score. ~5-file change; no schema to unwind.
- Blast radius: the KYB path is untouched (the fork already branches on
template_id); changes are scoped to the KYC branch, the create-case guard, and the dialog. The company EBA matrix is not modified (a person uses its own engine). - Alternative considered: force-fit the business EBA matrix onto a person — rejected (its dominant drivers are ownership/financial/MCC facts a person doesn't have; it would produce a meaningless baseline/low tier and hide real person risk).
Context
Trust Relay onboards companies. Natural persons are first-class KYC subjects in high-value-goods
verticals (Belgian AML Art. 26, AWDC FAQ Q9). A backend skeleton exists — the kyc_natural_person
workflow fork (compliance_case.py:1028/1480/1761), a DB-seeded template (person questions + docs +
a 6-step verification chain + 9 red-flag rules, migration 041f35765a6e), portal rendering of those,
a real OpenSanctions Person screener (opensanctions.py:77), and the IndividualProfile model —
but the vertical is unusable and unsafe because: (1) the fork produces a mock 0.1/0.8 risk score
and defaults the tier to CDD, never scoring occupation / source-of-wealth / nationality / PEP-tier and
never baselining the person (compliance_case.py:1160); (2) POST /cases 422s a subject without a
registration/VAT number; and (3) the picker hides the template. Gap (1) — no coherent person risk
verdict — is the real compliance hole this ADR closes.
Decision (v1)
person_risk.py— a deterministic person risk engine producing anEBARiskResult-compatible verdict from the screening result + portal answers, with person-appropriate deterministic floors (sanctions → CRITICAL, criminal/wanted → CRITICAL, PEP → ≥ HIGH/EDD, serious adverse-media → HIGH) and high-risk-nationality/residence, source-of-wealth and expected-volume factors. Fail-closed: missing/indeterminate screening → NOT-ASSESSED/WARNING, never a benign clear.- Wire it into
_run_kyc_investigation: writeinvestigation_result["risk_assessment"]so the display ratchet, tier→cadence mapping and decision gates operate on a real person tier; baseline the person on the national-register-number / a stable person key. - Real person screening in the fork via
screen_opensanctions(...entity_type="person"...)(deterministic in mock/local mode) using the portal answers. - Relax
create_caseforentity_type == "individual"(skip reg/VAT; persistadditional_data.entity_type; person name incompany_name) — no migration. - Frontend: un-hide
kyc_natural_person+ an entity-type toggle inCreateCaseDialog; the portal questionnaire keeps collecting DOB/NRN/IBAN.
Consequences
Positive
- Natural-person onboarding works end-to-end with a real, auditable, deterministic person risk verdict — the vertical Trust Relay could not previously serve (diamonds/art/precious-metals KYC).
- The mock 0.1/0.8 score + CDD default is replaced by a floored person tier that drives cadence, gates and the display honestly (sanctions → CRITICAL, PEP → EDD).
- Reuses the whole downstream spine (display ratchet, gates, monitoring cadence, decision flow) unchanged.
Negative
- Two risk engines now exist (business EBA matrix + person engine); a future "person who is also a sole
trader" case must pick one — v1 keys on
template_id/entity_type, a coarse switch. - v1's person risk factors are rule-based and coarser than the multi-dimension EBA matrix; occupation/ source-of-wealth risk is a small curated table, not a calibrated model — accepted as the honest v1.
- Real person screening recall/precision is the OpenSanctions screener's (identity disambiguation on birth-year/country); a common-name person can raise a candidate that needs officer adjudication.
Neutral
- No migration —
entity_typerides inadditional_data; a real column is deferred until cases must be queried by person attributes. individual_kyc.pystays the future typed intake home; v1 runs onadditional_data+ template questions.- EUDI-wallet / real eID / Kimberley / min-2-source person gate are deferred (unchanged vendor-gating).
Alternatives Considered
Alternative 1: force-fit the business EBA matrix onto a person
- Feed a person through
compute_eba_risk. - Why rejected: its dominant drivers (ownership layers, UBO countries, MCC, financials) are company facts a person lacks; the result is a meaningless low/CDD baseline that hides real person risk (PEP, sanctions, high-risk residence, unclear source of wealth) — the exact honesty failure the Calibration checklist guards against.
Alternative 2: keep the mock screening + CDD default (skeleton only) and just un-hide the picker
- Ship the intake without a real person risk verdict.
- Why rejected: it would present a natural-person case with a fabricated 0.1/0.8 score and a CDD tier regardless of PEP/sanctions/residence — a presence-≠-evidence / false-reassurance defect on the load-bearing risk verdict.