MOD-013 — Real-time sanctions screener (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-013-sanctions-screener/ 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¶
V001 migration (first-ever for MOD-013) adds the canonical
trg_sanctions_results_immutable trigger via shared
kyc.fn_immutable_row() plus the
CHECK (result_status IN ('CLEAR','MATCH_PENDING','CONFIRMED_MATCH','FALSE_POSITIVE'))
constraint on kyc.sanctions_results. Lambda code unchanged. Negative
integration tests in tests/integration/infra/sanctions-results-immutability.test.ts
cover trigger presence + tx-rolled-back violating UPDATE.
1. Purpose¶
MOD-013 screens every customer at onboarding and every payment counterparty on demand against four sanctions lists (UNSC, OFAC, AU DFAT, NZ MFAT) plus PEP databases, applying transliteration-normalised fuzzy name matching with a configurable similarity threshold (default 0.85). On a confirmed match, MOD-013 (a) writes kyc.sanctions_results with full audit detail, (b) publishes bank.kyc.sanctions_match_found to the bank-kyc bus within 60 s — consumed by MOD-007 (account restriction), MOD-020 (payment block), MOD-018 (case creation) — and (c) raises an SNS senior-management alert containing a SAR/STR draft envelope (AML-006). PEP matches don't block: they flip regulatory.party_regulatory_profiles.pep_flag and route to MOD-010's ENHANCED CDD path. Compliance officers adjudicate MATCH_PENDING results via a private FR-452 API; this writes kyc.false_positive_decisions and emits bank.kyc.false_positive_recorded. A scheduled re-screen Lambda (FR-095) sweeps stale MATCH_PENDING results and (when MOD-014 lands) re-screens the active population on list updates.
2. Architecture¶
bank.kyc.identity_verified ─────┐
bank.kyc.identity_failed/PENDING ┤ EventBridge rules on bank-kyc bus
bank.kyc.list_updated ───────────┤
│
scheduled.rescreen-sweep ────────┤ EventBridge schedule rule (default bus)
│
└─> MOD-013 Lambda (sanctions-handler)
├─> read party.parties / person_profiles / party_identifiers
│ (CUSTOMER screens)
├─> read kyc.sanctions_results (FR-095 priority queue)
│
├─> screening engine (pure)
│ ├─> transliterate (NFKD + apostrophe-aware + token sort)
│ ├─> fuzzy-match (max(jaccard, levSim, perTokenLevSim))
│ ├─> sanctions: provider Refinitiv + Dow Jones
│ └─> PEP: provider Refinitiv
│
├─> INSERT kyc.sanctions_results (append-only)
│ UPDATE regulatory.party_regulatory_profiles.pep_flag (PEP only)
│ (single transaction)
│
├─> PutEvents bank-kyc bus
│ bank.kyc.sanctions_match_found (CONFIRMED_MATCH or MATCH_PENDING)
│
└─> SNS publish to alarm-intake topic
(CONFIRMED_MATCH only — AML-006 ALERT + SAR/STR draft)
POST /kyc/sanctions/screen ──> same handler, counterparty/manual screen
POST /kyc/sanctions/false-positive ─> same handler, FR-452 adjudication
INSERT kyc.false_positive_decisions
PutEvents bank.kyc.false_positive_recorded
3. Screening pipeline¶
Transliteration (FR-450)¶
- Unicode NFKD decomposition + strip combining marks (
Zoë → Zoe) - Lowercase
- Remove apostrophes / periods (no space) —
O'Brien → obrien - Replace true separators (commas, hyphens, slashes) with space
- Collapse whitespace, sort tokens
Token-sorted output makes matching order-insensitive (Tane Jane ≡ Jane Tane).
Fuzzy match (FR-450)¶
For each candidate's primary name + every alias, compute the strongest of three signals:
- Jaccard on token sets — robust to missing/extra tokens
- Full-string Levenshtein similarity — robust to single-character typos within tokens
- Per-token best-match Levenshtein — robust to token-level typos with reordering
score = max(jaccard, levSim, perTokenLevSim). Score in [0, 1], rounded to 4 dp.
Classification¶
| Score | Match type | Result |
|---|---|---|
score = 1 |
EXACT | CONFIRMED_MATCH |
score ≥ confirmThreshold (0.95) |
EXACT or FUZZY | CONFIRMED_MATCH |
score ≥ alertThreshold (0.85) |
FUZZY or ALIAS | MATCH_PENDING |
score < 0.85 |
— | CLEAR |
PEP detection is a separate dimension — never overrides sanctions classification. PEP=true sets senior_management_notification_required=true and flips pep_flag on the regulatory profile; sanctions status remains whatever the engine derived.
4. Data plane¶
Writes (single transaction per screen)¶
| Table | Action | Notes |
|---|---|---|
kyc.sanctions_results |
INSERT | One row per screen attempt. screened_by='MOD-013'. result_status ∈ {CLEAR, MATCH_PENDING, CONFIRMED_MATCH}. match_details jsonb carries per-candidate (list_source, entry_id, matched_name, match_score, match_type). Append-only (NFR-024). |
regulatory.party_regulatory_profiles.pep_flag |
UPDATE (or INSERT) | Flipped to true on confirmed PEP match. Flips back to false only via the regulatory-team workflow (out of scope for MOD-013). |
Writes (FR-452 adjudication path)¶
| Table | Action | Notes |
|---|---|---|
kyc.false_positive_decisions |
INSERT | One row per adjudication. policy_ref='AML-007'. Decision ∈ {FALSE_POSITIVE, CONFIRMED_MATCH, ESCALATED}. decision_rationale ≥ 20 chars (FR-452). Append-only. |
Reads¶
| Source | Purpose |
|---|---|
party.parties + party.person_profiles + party.party_identifiers |
Assemble screening name + aliases for a customer |
banking.customer_relationships |
Filter active population for FR-095 sweep; carry jurisdiction |
kyc.sanctions_results |
FR-095 priority queue (MATCH_PENDING first) |
| Refinitiv WorldCheck (HTTPS) | Sanctions list candidates + PEP query |
| Dow Jones Risk & Compliance (HTTPS) | Sanctions list candidates (cross-check) |
5. Events (catalogue cbff34e + FR drift corrections)¶
bank.kyc.sanctions_match_found v1¶
{
"screening_id": "uuid",
"entity_type": "CUSTOMER|COUNTERPARTY",
"entity_id": "party_id or counterparty ref",
"list_source": "OFAC|UN|MFAT|DFAT",
"match_score": "0.92",
"match_type": "EXACT|FUZZY|ALIAS",
"triggering_context": "ONBOARDING|PAYMENT|LIST_UPDATE|PERIODIC_REVIEW|MANUAL",
"result_status": "MATCH_PENDING|CONFIRMED_MATCH",
"matched_entry_id": "OFAC-12345",
"screened_at": "ISO 8601 UTC",
"idempotency_key": "string",
"trace_id": "string"
}
CLEAR results never publish — schema rejects result_status='CLEAR'.
Detail-type drift correction: FR-453 says party.sanctions_match.confirmed; catalogue + this implementation use bank.kyc.sanctions_match_found per the bank.{domain}.{noun}_{verb} standard.
bank.kyc.false_positive_recorded v1¶
{
"decision_id": "uuid",
"screening_id": "uuid",
"party_id": "uuid",
"matched_entry_id": "OFAC-12345",
"list_source": "OFAC|UN|MFAT|DFAT",
"decided_by": "compliance-officer-staff-id",
"rationale": "≥20 chars",
"decision": "FALSE_POSITIVE|CONFIRMED_MATCH|ESCALATED",
"suppress_until": "YYYY-MM-DD (optional)",
"decided_at": "ISO 8601 UTC",
"idempotency_key": "string",
"trace_id": "string"
}
Ownership drift correction (resolved with orchestrator): Catalogue says MOD-015 owns this event; FR-452 puts the API in MOD-013. MOD-013 owns the FR-452 API + row write + event publish; MOD-015 owns the workflow/queue layer (case routing, suppression list maintenance) when it lands.
6. SSM outputs¶
| Path | Type | Consumer | Purpose |
|---|---|---|---|
/bank/{env}/kyc/sanctions/function-arn |
String | ops | Lambda ARN |
/bank/{env}/kyc/sanctions/function-name |
String | ops | Log group lookup |
/bank/{env}/kyc/sanctions/screen-api-endpoint |
String | bank-payments | Synchronous screen API |
/bank/{env}/kyc/sanctions/false-positive-api-endpoint |
String | back-office | FR-452 adjudication |
/bank/{env}/kyc/events/sanctions-match-found/schema-arn |
String | MOD-007, MOD-018, MOD-020, MOD-022, MOD-010 | Schema reference |
/bank/{env}/kyc/events/false-positive-recorded/schema-arn |
String | MOD-012, MOD-037 | Schema reference |
/bank/{env}/kyc/tables/sanctions-results/name |
String | MOD-012, MOD-014 | FQN kyc.sanctions_results |
/bank/{env}/kyc/tables/false-positive-decisions/name |
String | MOD-012 | FQN kyc.false_positive_decisions |
SSM / Secrets reads (upstream)¶
| Resource | Path | Owner |
|---|---|---|
| BankKycRole | /bank/{env}/iam/lambda/bank-kyc/arn |
MOD-104 |
| bank-kyc bus | /bank/{env}/eventbridge/bank-kyc/arn |
MOD-104 |
| Schema registry name | /bank/{env}/mod043/schema-registry/name |
MOD-043 |
| PII / financial KMS keys | /bank/{env}/kms/{pii,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 | /bank/{env}/kyc/events/identity-verified/schema-arn |
MOD-009 |
MOD-013-owned secrets (provisioned by this module's IaC)¶
- Secrets Manager
bank-sanctions/{env}/refinitiv-api-key - Secrets Manager
bank-sanctions/{env}/dow-jones-api-key
dev/uat ship placeholder JSON; orchestrator rotates real values.
7. Policies satisfied¶
| Code | Mode | Test type | File |
|---|---|---|---|
| AML-007 | GATE | Negative — no bypass for confirmed matches | tests/integration/policy/aml-007-gate.test.ts |
| PAY-001 | GATE | Negative — payment screening blocks counterparties | tests/integration/policy/pay-001-gate.test.ts |
| AML-006 | ALERT | SAR/STR draft + senior-management notification within SLA | tests/integration/policy/aml-006-alert.test.ts |
| GOV-002 | AUTO | Sanctions exposure structurally zero — every customer screened | tests/integration/policy/gov-002-auto.test.ts |
8. Trigger model¶
| Trigger | Action |
|---|---|
bank.kyc.identity_verified |
Onboarding screen (FR-093) |
bank.kyc.identity_failed (PENDING_EDD) |
Onboarding screen (FR-093 every-customer rule) |
bank.kyc.identity_failed (FAILED) |
Ignored — no relationship row |
bank.kyc.list_updated |
Re-screen sweep (FR-095) — wired forward-compatible for MOD-014 |
scheduled.rescreen-sweep (cron) |
FR-095 safety net; sweeps stale MATCH_PENDING results |
POST /kyc/sanctions/screen |
Counterparty / manual screen (used by bank-payments pre-validation) |
POST /kyc/sanctions/false-positive |
FR-452 adjudication |
9. Observability + idempotency + error handling¶
Same contracts as MOD-009/010. Mandatory log fields per ADR-031, with entity_name/primary_name/matched_name/aliases added to the PII redaction list. Idempotency keyed on ${trigger_kind}:${event_id}:${party_id} (events) or idempotency_key (API). Errors classified VALIDATION_FAILURE / TRANSIENT_INFRA / PROVIDER_ERROR / COMPLIANCE_BLOCK with documented HTTP status mapping.
10. Drift items resolved with orchestrator¶
- Detail-type for sanctions match —
bank.kyc.sanctions_match_found(catalogue) wins over FR-453'sparty.sanctions_match.confirmed. - Detail-type for false positive —
bank.kyc.false_positive_recorded(catalogue) wins over FR-452'ssanctions.false_positive_recorded. - MOD-013 owns FR-452 API + row write + false-positive event; MOD-015 owns workflow/queue (when it lands).
- No local sanctions list cache — pluggable HTTP providers per request (latency feasible within FR-093 500 ms).
- PEP write-through —
regulatory.party_regulatory_profiles.pep_flagUPDATE (or INSERT if no row). - Triggers wired today: identity_verified, identity_failed/PENDING_EDD, scheduled sweep. Forward-compatible with MOD-014's list_updated.
- External provider stubs — Refinitiv (primary, sanctions + PEP), Dow Jones (cross-check, sanctions only).
- Provider secrets —
bank-sanctions/{env}/{refinitiv,dow-jones}-api-keyprovisioned in MOD-013's IaC. identity_failed/PENDING_EDD parties get screened (PEP/sanctions exposure must be assessed regardless of confidence score).- Module dir name —
MOD-013-realtime-sanctions-screener/.
11. Deployment¶
pnpm install
pnpm typecheck
pnpm test:unit
sst deploy --stage dev
RUN_INTEGRATION=1 STAGE=dev pnpm test:integration
CUST-D010's seed kyc.sanctions_results row is the canonical CONFIRMED_MATCH baseline for FR-094 / FR-453 integration tests.