Skip to main content

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 existing screen_opensanctions(entity_type="person") screener, and the person models. One new pure module person_risk.py; edits to the KYC fork, create_case, and CreateCaseDialog.
  • 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)

  1. person_risk.py — a deterministic person risk engine producing an EBARiskResult-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.
  2. Wire it into _run_kyc_investigation: write investigation_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.
  3. Real person screening in the fork via screen_opensanctions(...entity_type="person"...) (deterministic in mock/local mode) using the portal answers.
  4. Relax create_case for entity_type == "individual" (skip reg/VAT; persist additional_data.entity_type; person name in company_name) — no migration.
  5. Frontend: un-hide kyc_natural_person + an entity-type toggle in CreateCaseDialog; 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_type rides in additional_data; a real column is deferred until cases must be queried by person attributes.
  • individual_kyc.py stays the future typed intake home; v1 runs on additional_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.