Skip to content

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)

  1. Unicode NFKD decomposition + strip combining marks (Zoë → Zoe)
  2. Lowercase
  3. Remove apostrophes / periods (no space) — O'Brien → obrien
  4. Replace true separators (commas, hyphens, slashes) with space
  5. Collapse whitespace, sort tokens

Token-sorted output makes matching order-insensitive (Tane JaneJane 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

  1. Detail-type for sanctions match — bank.kyc.sanctions_match_found (catalogue) wins over FR-453's party.sanctions_match.confirmed.
  2. Detail-type for false positive — bank.kyc.false_positive_recorded (catalogue) wins over FR-452's sanctions.false_positive_recorded.
  3. MOD-013 owns FR-452 API + row write + false-positive event; MOD-015 owns workflow/queue (when it lands).
  4. No local sanctions list cache — pluggable HTTP providers per request (latency feasible within FR-093 500 ms).
  5. PEP write-through — regulatory.party_regulatory_profiles.pep_flag UPDATE (or INSERT if no row).
  6. Triggers wired today: identity_verified, identity_failed/PENDING_EDD, scheduled sweep. Forward-compatible with MOD-014's list_updated.
  7. External provider stubs — Refinitiv (primary, sanctions + PEP), Dow Jones (cross-check, sanctions only).
  8. Provider secrets — bank-sanctions/{env}/{refinitiv,dow-jones}-api-key provisioned in MOD-013's IaC.
  9. identity_failed/PENDING_EDD parties get screened (PEP/sanctions exposure must be assessed regardless of confidence score).
  10. 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.