MOD-009 — eIDV & document verification (technical design)¶
System: SD02 Customer Identity & KYC Platform Repo: bank-kyc Phase: 2 Status: Built (pending verification + deploy) Authoritative spec: https://bank-wiki.pages.dev/systems/SD02-kyc-platform/modules/MOD-009-eidv-document-verification/ Related ADRs: ADR-018 (KYC architecture), ADR-025 (SST v3 Ion), ADR-029 (EventBridge), ADR-030 (Secrets Manager), ADR-031 (Observability), ADR-042 (Single-stack, runtime jurisdiction), ADR-048 (Database-enforced invariants)
ADR-048 compliance¶
V002 migration adds the canonical trg_kyc_checks_immutable trigger
(via shared kyc.fn_immutable_row()) plus CHECK constraints on
kyc.kyc_checks.{check_type,status,score} and
kyc.identity_documents.document_type. Lambda code unchanged — DB
enforcement is defence in depth. Negative integration tests in
tests/integration/infra/kyc-checks-immutability.test.ts assert
trigger presence + violating-UPDATE rejection inside a tx.
1. Purpose¶
MOD-009 is the front gate of the KYC pipeline. A customer submits identity
data, a document image, and a selfie during onboarding; this module routes
the document to the jurisdiction-appropriate verification service, runs
biometric liveness, confirms name/DOB against a credit bureau, computes a
composite identity confidence score, writes an append-only kyc.kyc_checks
row, updates banking.customer_relationships.kyc_status, assigns an initial
CDD tier from the score, and publishes a kyc.identity_verified or
kyc.identity_failed event to the bank-kyc EventBridge bus. Until this
module returns VERIFIED, MOD-007 will not transition the account from
Pending → Active.
The GATE on MOD-009 (AML-003) is structural. There is no override flag, no
admin bypass, and no test-mode shortcut. Customers below the verification
threshold are routed to EDD (PENDING_EDD) or hard-failed (FAILED).
2. Architecture¶
Mobile app ── POST /kyc/eidv/verify ──> API Gateway ──> MOD-009 Lambda ──> Providers
│ (DIA/NZTA/DVS,
│ Onfido ×2,
│ Centrix/Equifax)
│
├──> INSERT kyc.kyc_checks
│ INSERT kyc.identity_documents
│ UPDATE banking.customer_relationships.kyc_status
│ (single transaction)
│
├──> PutEvents bank-kyc bus
│ bank.kyc.identity_verified (v1)
│ bank.kyc.identity_failed (v1)
│
└──> synchronous response to caller
Invocation model¶
Synchronous API Gateway → Lambda. FR-077 requires p99 ≤ 8 s from submission to response — async fan-out would violate that SLO. The caller waits for the outcome.
Jurisdiction¶
Jurisdiction is a runtime value read from banking.customer_relationships.jurisdiction
per ADR-042. One deployed stack serves both NZ and AU. There is no per-
jurisdiction stack split.
3. Pipeline and scoring¶
Providers (FR-442)¶
| Jurisdiction | Document type | Provider |
|---|---|---|
| NZ | PASSPORT, NATIONAL_ID | DIA API |
| NZ | DRIVERS_LICENCE | NZTA |
| AU | PASSPORT, DRIVERS_LICENCE, NATIONAL_ID | DVS gateway (Home Affairs) |
Liveness: Onfido (two instances — primary and fallback — backed by
different credentials / regional endpoints).
Bureau: Centrix (NZ), Equifax (AU).
Composite score (FR-443)¶
Weights reflect relative authority: government document checks are the strongest single attestation; liveness defeats replay / synthetic-identity attacks; bureau is confirmatory. Values are clamped to [0, 1] and rounded to three decimals.
Routing¶
| Composite | Liveness ≥ 0.92? | Outcome | banking.customer_relationships.kyc_status |
cdd_tier (event) |
|---|---|---|---|---|
| ≥ 0.90 | yes | VERIFIED | VERIFIED |
STANDARD |
| 0.70–0.89 | yes | VERIFIED | VERIFIED |
STANDARD (flagged for review at 365d) |
| 0.50–0.69 | yes | PENDING_EDD | PENDING_EDD |
ENHANCED |
| < 0.50 | yes | FAILED | FAILED |
(omitted) |
| any | no | FAILED | FAILED — BIOMETRIC_MISMATCH |
(omitted) |
| any | provider exhausted (FR-445) | PENDING_EDD | PENDING_EDD |
ENHANCED |
Failure reason enum (for identity_failed event): DOCUMENT_REJECTED,
BIOMETRIC_MISMATCH, WATCHLIST_HIT, UNSUPPORTED_DOCUMENT, EXPIRED_DOCUMENT.
4. Data plane¶
Tables written¶
All three in a single Postgres transaction:
| Table | Action | Notes |
|---|---|---|
kyc.kyc_checks |
INSERT | One row. check_type='INITIAL_EIDV', check_source='MOD-009', policy_refs=["AML-003","AML-002","PRI-001","CON-001"]. Immutable once written. expires_at populated from routing cadence. |
kyc.identity_documents |
INSERT | One row per submitted document. verification_method in {DVS,DIA}. retention_delete_at = now + 7 years. |
banking.customer_relationships.kyc_status |
UPDATE | Set to the outcome's kyc_status (VERIFIED / PENDING_EDD / FAILED). MOD-009 is the writer of this column (per Step 3 default confirmed by orchestrator). |
Tables read¶
| Table | Purpose |
|---|---|
party.parties |
party existence, legal_name (reference only) |
party.person_profiles |
date_of_birth (reference only) |
party.addresses |
residential address (reference only) |
banking.customer_relationships |
jurisdiction, row existence (pre-condition) |
All reads route via the bank_kyc_app role. Raw PII values never leave the
provider client scope — the rest of the module operates on reference keys
(e.g., s3_key, provider_reference).
5. Events (catalogue cbff34e)¶
bank.kyc.identity_verified v1¶
{
"party_id": "uuid",
"jurisdiction": "NZ|AU",
"kyc_status": "VERIFIED",
"cdd_tier": "SIMPLIFIED|STANDARD|ENHANCED",
"confidence_score": 0.95,
"verified_at": "2026-04-22T10:00:00.000Z",
"trace_id": "…"
}
Consumed by: MOD-007 (account state machine — gates Pending → Active),
MOD-010 (CDD tier assignment engine — may adjust tier and writes
kyc.cdd_tier_assignments).
bank.kyc.identity_failed v1¶
{
"party_id": "uuid",
"jurisdiction": "NZ|AU",
"kyc_status": "FAILED|PENDING_EDD",
"confidence_score": 0.3,
"failure_reason": "DOCUMENT_REJECTED|BIOMETRIC_MISMATCH|WATCHLIST_HIT|UNSUPPORTED_DOCUMENT|EXPIRED_DOCUMENT",
"verified_at": "2026-04-22T10:00:00.000Z",
"trace_id": "…"
}
Consumed by: MOD-007 (holds account Pending / routes to EDD queue).
Both schemas are registered in EventBridge Schema Registry bank-events and
validated on every publish. provider_reference, correlation_id, and
occurred_at are intentionally NOT part of v1 — the internal provider_ref
lives on kyc.kyc_checks; correlation_id is per-Lambda only.
6. SSM outputs¶
Downstream modules reference MOD-009 only through these SSM parameter paths. No direct ARN or endpoint hardcoding is permitted.
| Path | Type | Consumed by | Purpose |
|---|---|---|---|
/bank/{env}/kyc/eidv/function-arn |
String | MOD-007, ops | Lambda ARN for debugging / invoke |
/bank/{env}/kyc/eidv/function-name |
String | ops | CloudWatch log group lookup |
/bank/{env}/kyc/eidv/api-endpoint |
String | MOD-153, bank-app | Mobile onboarding target |
/bank/{env}/kyc/events/identity-verified/schema-arn |
String | MOD-010, MOD-007 | Schema registry reference |
/bank/{env}/kyc/events/identity-failed/schema-arn |
String | MOD-007 | Schema registry reference |
/bank/{env}/kyc/tables/kyc-checks/name |
String | MOD-011, MOD-012 | FQN kyc.kyc_checks |
/bank/{env}/kyc/tables/identity-documents/name |
String | MOD-012 | FQN kyc.identity_documents |
Upstream contract this module consumes¶
All upstream lookups go through infra/lib/upstream.ts defensive helpers
that resolve eagerly at program-evaluation time. A missing value throws
with the path and the owning upstream module — see
infra/eidv.ts for the full list.
| Resource | Path / Identifier | Owner | Helper |
|---|---|---|---|
| BankKycRole ARN | /bank/{env}/iam/lambda/bank-kyc/arn |
MOD-104 | requireSsm |
bank-kyc event bus ARN |
/bank/{env}/eventbridge/bank-kyc/arn |
MOD-104 | requireSsm |
Schema registry name (per-env, e.g. bank-events-dev) |
/bank/{env}/mod043/schema-registry/name |
MOD-043 | requireSsm |
| PII KMS key ARN | /bank/{env}/kms/pii/arn |
MOD-104 | requireSsm |
| Financial KMS key ARN | /bank/{env}/kms/financial/arn |
MOD-104 | requireSsm |
| Documents bucket name | /bank/{env}/s3/documents/name |
MOD-104 | requireSsm |
Neon bank_kyc/app_user secret |
Secrets Manager bank-neon/{env}/bank_kyc/app_user |
MOD-103 | requireSecretArn |
| ADOT Lambda layer | /bank/{env}/observability/adot-layer-arn |
MOD-076 | requireSsm |
| Parameters & Secrets Extension layer (arm64) | /bank/{env}/observability/parameters-extension-layer-arm64-arn |
MOD-076 | requireSsm |
The bus name bank-kyc is derived as a constant — MOD-104 does not
publish a separate name parameter; the convention is enforced by the
event-bus naming standard.
MOD-009 does not create its own IAM role. SCP p-sp55pf4f denies
iam:CreateRole for the cicd execution role. The Lambda is bound to
BankKycRole, which MOD-104 provisions and policies. If a future MOD-009
permission isn't covered by BankKycRole, the addition lives in MOD-104.
eIDV provider secrets (owned and provisioned by MOD-009)¶
These are KYC-domain credentials, so MOD-009's own IaC creates the
Secrets Manager entries. bank-platform owns generic key infrastructure
(KMS, the bank-secrets-read managed policy); BankKycRole's policy
already permits Get/Describe on bank-eidv/* and KMS decrypt on the
pii + financial CMKs.
| Provider | Secret name | KMS key |
|---|---|---|
| Onfido (liveness) | bank-eidv/{env}/onfido-api-key |
PII |
| DVS (AU document verification) | bank-eidv/{env}/dvs-credentials |
PII |
| DIA (NZ document verification) | bank-eidv/{env}/dia-credentials |
PII |
| NZTA (NZ driver-licence verification) | bank-eidv/{env}/nzta-credentials |
PII |
| Equifax AU (bureau) | bank-eidv/{env}/equifax-credentials |
PII |
| Centrix NZ (bureau) | bank-eidv/{env}/centrix-credentials |
PII |
In dev/uat the IaC writes a placeholder JSON value
({"placeholder": true, ...}) so the contract resolves and CI integration
tests don't 404. Production values are rotated in by the orchestrator (or
a future MOD-045 rotation Lambda); IaC never sets prod values.
7. Policies satisfied¶
| Code | Mode | Test type | File |
|---|---|---|---|
| AML-003 | GATE | Negative — no-bypass proof (3 parts) | test/integration/policy/aml-003-gate.test.ts |
| AML-002 | AUTO | Tier auto-assignment across bands | test/integration/policy/aml-002-auto.test.ts |
| PRI-001 | AUTO | Purpose limitation (drop / redact) | test/integration/policy/pri-001-auto.test.ts |
| CON-001 | AUTO | Consistency across demographics | test/integration/policy/con-001-auto.test.ts |
8. Observability¶
Every log line emitted by this module is structured JSON with the mandatory
fields trace_id, correlation_id, module_id, jurisdiction,
event_type, party_id, duration_ms, level. The observability.ts
Logger class enforces this and redacts PII field names at any depth. The
structured-log format test (test/unit/observability.test.ts) is a build
acceptance criterion.
Metrics emitted (via EMF): eidv_request_count, eidv_request_latency_ms,
eidv_provider_latency_ms{provider=*}, eidv_outcome_count{status=*},
eidv_provider_exhausted_count{provider=*}.
9. Idempotency¶
Per the platform idempotency standard. Submitting the same
idempotency_key twice within 24 hours returns the stored response without
writing a second kyc_checks row or publishing a second event. In dev/test,
the store is in-memory; prod uses the MOD-104-provisioned DynamoDB table.
10. Error handling¶
All errors are classified at throw time:
| Kind | HTTP | Example |
|---|---|---|
VALIDATION_FAILURE |
422 | bad document_type, missing party, relationship row not pre-created |
TRANSIENT_INFRA |
503 | Postgres connection error |
PROVIDER_ERROR |
503 | DVS 5xx |
COMPLIANCE_BLOCK |
403 | (reserved; currently unused) |
Unclassified errors → 500. After retry exhaustion of a provider
(TRANSIENT_INFRA / PROVIDER_ERROR retryable) the fallback policy (FR-445)
kicks in — the outcome becomes PENDING_EDD, not FAILED.
11. Dev provider stubs¶
For deployed-dev integration tests, provider requests for the following seed parties are routed through a stub that returns deterministic scores:
| Party ID | Intended composite | Purpose |
|---|---|---|
e0000001-…-000000000095 |
0.95 | FR-443 VERIFIED/STANDARD |
e0000001-…-000000000080 |
0.80 | FR-443 VERIFIED/STANDARD (flagged) |
e0000001-…-000000000060 |
0.60 | FR-443 PENDING_EDD/ENHANCED |
e0000001-…-000000000030 |
0.30 | FR-443 FAILED |
aaaaaaaa-…-aaaaaaaaaaaa |
low liveness | FR-078 BIOMETRIC_MISMATCH |
f0000000-…-000000000000 |
provider outage | FR-445 fallback |
Seed data scripts are in ../../scripts/dev-seed/ (owned by bank-platform,
not this repo — flagged as a delivery follow-up).
12. Deployment¶
npm install
npm run typecheck
npm run test:unit # local, no AWS
sst deploy --stage dev
RUN_INTEGRATION=1 STAGE=dev npm run test:integration
Prod deploys are gated behind a CI approval and require IaC drift detection (phase-gate criterion).