Skip to content

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)

  1. CDD tier enum — SIMPLIFIED/STANDARD/ENHANCED (catalogue's LOW/MEDIUM/HIGH/ENHANCED superseded)
  2. Event payload identifier — party_id (not customer_id)
  3. Detail-type — bank.kyc.cdd_tier_assigned (not cdd.tier_assigned)
  4. PEP/sanctions input fall-back — false/CLEAR when MOD-013 hasn't published yet
  5. EDD-readiness — new column banking.customer_relationships.edd_completed_at (migration V001)
  6. Override API location — in MOD-010 (POST /kyc/cdd/override)
  7. Shared upstream-lookup helper — copied per-module for now (lift to shared package when 3rd consumer arrives)
  8. kyc_status event-payload enum — handler recognises VERIFIED / PENDING_EDD, ignores FAILED

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.