MOD-010 — CDD tier assignment engine (technical design)¶
System: SD02 Customer Identity & KYC Platform Repo: bank-kyc Phase: 3 Status: Built (pending verification + deploy) Authoritative spec: https://bank-wiki.pages.dev/systems/SD02-kyc-platform/modules/MOD-010-cdd-tier-assignment/ Related ADRs: ADR-018 (KYC architecture), ADR-025 (SST v3 Ion), ADR-029 (EventBridge), ADR-031 (Observability), ADR-042 (Single-stack, runtime jurisdiction), ADR-043 (Repo structure), ADR-048 (Database-enforced invariants)
ADR-048 compliance¶
V002 migration adds trg_cdd_tier_assignments_immutable (canonical,
shared kyc.fn_immutable_row()) and the CHECK
(new_tier IN ('SIMPLIFIED','STANDARD','ENHANCED')) constraint.
Lambda code unchanged. Negative integration tests in
tests/integration/infra/cdd-assignments-immutability.test.ts cover
trigger presence + tx-rolled-back violating UPDATE.
1. Purpose¶
MOD-010 is the authoritative writer of cdd_tier for every party. Event-driven: it consumes bank.kyc.identity_verified from MOD-009 (and bank.kyc.identity_failed for PENDING_EDD outcomes), reads the party's current risk profile, runs a configurable weighted-scoring rule engine, derives SIMPLIFIED / STANDARD / ENHANCED, writes an append-only audit row, and publishes bank.kyc.cdd_tier_assigned within 10 s of the trigger. PEP detection raises an AML-004 senior-management alert. Risk scores above the Risk Appetite hard limit refuse activation (GOV-002). Manual override by a compliance officer is supported via a private API endpoint (FR-449), with a mandatory ≤ 90-day re-evaluation timer.
2. Architecture¶
bank.kyc.identity_verified ───┐
bank.kyc.identity_failed ─────┤ EventBridge rules
bank.kyc.sanctions_match_found┤ on bank-kyc bus
│
└─> MOD-010 Lambda (cdd-handler)
├─> read kyc.kyc_checks
├─> read regulatory.party_regulatory_profiles
├─> read banking.customer_relationships
├─> read kyc.sanctions_results
│
├─> rule engine (pure)
│ ├─> document
│ ├─> bureau
│ ├─> pep (AML-004 hard outcome)
│ ├─> sanctions (GOV-002 hard outcome)
│ ├─> source_of_funds
│ ├─> product
│ └─> jurisdiction
│
├─> routing → TierDecision
│
├─> INSERT kyc.cdd_tier_assignments (append-only)
│ UPDATE banking.customer_relationships.cdd_tier (single tx)
│
├─> PutEvents bank-kyc bus
│ bank.kyc.cdd_tier_assigned (v1)
│
└─> AML-004 alert: SNS publish to alarm-intake topic
POST /kyc/cdd/override (IAM auth) ──> same handler, override path
Trigger model¶
| Trigger | Action |
|---|---|
bank.kyc.identity_verified |
Run engine, write tier, publish event |
bank.kyc.identity_failed (kyc_status=PENDING_EDD) |
Same — typically lands ENHANCED |
bank.kyc.identity_failed (kyc_status=FAILED) |
Ignored — no relationship exists |
bank.kyc.sanctions_match_found (entity_type=CUSTOMER) |
Re-evaluate (FR-082) |
POST /kyc/cdd/override |
Manual override path (FR-449) |
3. Rule engine (FR-446)¶
Each rule contributes a numeric score (additive) and may surface a hard_outcome to short-circuit routing.
| Factor | Source | Scale | Hard outcome? |
|---|---|---|---|
| document | kyc.kyc_checks.status + score + jurisdiction-match |
0–6 | — |
| bureau | kyc.kyc_checks.score |
0–4 | — |
| pep | regulatory.party_regulatory_profiles.pep_flag |
0/5 | ENHANCED (AML-004) |
| sanctions | kyc.sanctions_results.result_status (latest) |
0/3/10 | AUTO_DECLINE (GOV-002, on CONFIRMED_MATCH) |
| source_of_funds | banking.customer_relationships.source_of_funds |
0/1 | — |
| product | banking.customer_relationships.relationship_type |
0/1/2 | — |
| jurisdiction | banking.customer_relationships.aml_risk_rating |
0–5 | — |
Routing thresholds (env-overridable)¶
| Score range | Tier | Activation |
|---|---|---|
score ≥ SCORE_AUTO_DECLINE (default 9) |
ENHANCED | refused (GOV-002) |
hard_outcome=ENHANCED (PEP) |
ENHANCED | gated by edd_completed_at |
government_agency_flag=true AND score ≤ SCORE_SIMPLIFIED_MAX (1) AND no PEP/sanctions |
SIMPLIFIED | permitted |
score ≤ SCORE_STANDARD_MAX (4) |
STANDARD | permitted |
score ≤ SCORE_ENHANCED_MAX (8) |
ENHANCED | gated by edd_completed_at |
4. Data plane¶
Tables written (single transaction)¶
| Table | Action | Notes |
|---|---|---|
kyc.cdd_tier_assignments |
INSERT | One row per assignment/change. policy_ref ∈ {AML-002, AML-004, GOV-002}. assigned_by='MOD-010' for automated, staff_id for override. risk_factors jsonb carries the per-factor breakdown + risk_score + activation flags + trigger_event_id. Append-only (NFR-024). |
banking.customer_relationships.cdd_tier |
UPDATE | Denormalised current tier. |
banking.customer_relationships.cdd_tier_set_at |
UPDATE | Timestamp. |
Tables read¶
| Table | Purpose |
|---|---|
kyc.kyc_checks (latest INITIAL_EIDV per party) |
Document + bureau scores |
regulatory.party_regulatory_profiles |
pep_flag, government_agency_flag |
banking.customer_relationships |
jurisdiction, relationship_type, source_of_funds, aml_risk_rating, current cdd_tier, edd_completed_at |
kyc.sanctions_results (latest per party) |
result_status — falls back to CLEAR when no rows exist (drift item #4) |
Migration¶
migrations/V001__add_edd_completed_at.up.sql adds
banking.customer_relationships.edd_completed_at timestamptz NULL plus a
partial index for ENHANCED-tier parties pending EDD. Migration is owned
by MOD-103's pipeline; this directory is the source of truth for the SQL.
5. Events (catalogue cbff34e + drift corrections)¶
bank.kyc.cdd_tier_assigned v1¶
{
"party_id": "uuid",
"cdd_tier": "SIMPLIFIED|STANDARD|ENHANCED",
"previous_tier": "SIMPLIFIED|STANDARD|ENHANCED (omitted on initial)",
"tier_rationale": "string",
"risk_factors": { "document": {...}, ... },
"risk_score": 0,
"sanctions_check_status": "CLEAR|MATCH_PENDING|CONFIRMED_MATCH|FALSE_POSITIVE",
"account_activation_permitted": true|false,
"senior_management_notification_required": true|false,
"effective_at": "ISO 8601 UTC",
"trigger_event_id": "string",
"idempotency_key": "string",
"trace_id": "string"
}
Drift corrections from catalogue (orchestrator-confirmed):
- party_id (not customer_id)
- cdd_tier: SIMPLIFIED|STANDARD|ENHANCED (not LOW|MEDIUM|HIGH|ENHANCED)
- detail-type bank.kyc.cdd_tier_assigned (not cdd.tier_assigned)
Consumers: MOD-007 (account state machine — activation gate), MOD-039 (customer risk score), MOD-016 (typology engine), MOD-012 (KYC audit trail).
6. SSM outputs¶
| Path | Type | Consumer | Purpose |
|---|---|---|---|
/bank/{env}/kyc/cdd/function-arn |
String | MOD-007 / ops | Lambda ARN |
/bank/{env}/kyc/cdd/function-name |
String | ops | CloudWatch log group lookup |
/bank/{env}/kyc/cdd/override-api-endpoint |
String | back-office | Manual override endpoint |
/bank/{env}/kyc/events/cdd-tier-assigned/schema-arn |
String | MOD-007, MOD-039, MOD-016, MOD-012 | Schema reference |
/bank/{env}/kyc/tables/cdd-tier-assignments/name |
String | MOD-012 | FQN kyc.cdd_tier_assignments |
SSM / Secrets read (upstream)¶
| Resource | Path | Owner |
|---|---|---|
| BankKycRole ARN | /bank/{env}/iam/lambda/bank-kyc/arn |
MOD-104 |
| bank-kyc bus ARN | /bank/{env}/eventbridge/bank-kyc/arn |
MOD-104 |
| Schema registry name | /bank/{env}/mod043/schema-registry/name |
MOD-043 |
| PII KMS key | /bank/{env}/kms/pii/arn |
MOD-104 |
| Financial KMS key | /bank/{env}/kms/financial/arn |
MOD-104 |
Neon bank_kyc/app_user |
Secrets Manager bank-neon/{env}/bank_kyc/app_user |
MOD-103 |
| ADOT layer | /bank/{env}/observability/adot-layer-arn |
MOD-076 |
| Parameters Extension layer (arm64) | /bank/{env}/observability/parameters-extension-layer-arm64-arn |
MOD-076 |
| Alarm intake topic | /bank/{env}/observability/alarm-intake-topic-arn |
MOD-076 |
| MOD-009 verified schema ARN | /bank/{env}/kyc/events/identity-verified/schema-arn |
MOD-009 |
| MOD-009 failed schema ARN | /bank/{env}/kyc/events/identity-failed/schema-arn |
MOD-009 |
7. Policies satisfied¶
| Code | Mode | Test type | File |
|---|---|---|---|
| AML-002 | AUTO | Determinism + agent-free + no demographic branching | tests/integration/policy/aml-002-auto.test.ts |
| AML-004 | ALERT | PEP triggers SNS notification within FR-447 SLA | tests/integration/policy/aml-004-alert.test.ts |
| GOV-002 | GATE | Negative — no bypass of RAF hard limit; tier-down refused above appetite | tests/integration/policy/gov-002-gate.test.ts |
8. Observability + idempotency + error handling¶
Same contracts as MOD-009. Mandatory log fields per ADR-031. Idempotency keyed on ${trigger_kind}:${trigger_event_id}:${party_id} (events) and idempotency_key (override). Errors classified VALIDATION_FAILURE / TRANSIENT_INFRA / PROVIDER_ERROR / COMPLIANCE_BLOCK with documented HTTP status mapping.
9. FR-449 manual override semantics¶
POST /kyc/cdd/override (IAM-authed)
{
party_id, requested_tier, staff_id,
justification (≥ 20 chars),
review_date (ISO YYYY-MM-DD, ≤ 90 days from now),
idempotency_key
}
Tier-up overrides are always allowed. Tier-down overrides are refused if any of: pep_flag, sanctions_status ∈ {CONFIRMED_MATCH, MATCH_PENDING}, risk_score ≥ scoreAutoDecline, or requested_tier=SIMPLIFIED && !government_agency_flag. The override row carries assigned_by=staff_id (vs MOD-010 for automated) so the audit trail distinguishes them. The 90-day cap is enforced at parse time.
10. Drift items resolved against orchestrator (recap)¶
- CDD tier enum —
SIMPLIFIED/STANDARD/ENHANCED(catalogue'sLOW/MEDIUM/HIGH/ENHANCEDsuperseded) - Event payload identifier —
party_id(notcustomer_id) - Detail-type —
bank.kyc.cdd_tier_assigned(notcdd.tier_assigned) - PEP/sanctions input fall-back —
false/CLEARwhen MOD-013 hasn't published yet - EDD-readiness — new column
banking.customer_relationships.edd_completed_at(migration V001) - Override API location — in MOD-010 (POST /kyc/cdd/override)
- Shared upstream-lookup helper — copied per-module for now (lift to shared package when 3rd consumer arrives)
kyc_statusevent-payload enum — handler recognisesVERIFIED/PENDING_EDD, ignoresFAILED
11. Deployment¶
pnpm install
pnpm typecheck
pnpm test:unit
sst deploy --stage dev
RUN_INTEGRATION=1 STAGE=dev pnpm test:integration
Migration runs via MOD-103's pipeline before Lambda deploy.