Skip to content

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)

composite = 0.45 · document_score + 0.35 · liveness_score + 0.20 · bureau_score

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 FAILEDBIOMETRIC_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).