Skip to content

MOD-014 — List Change Propagation

System: SD02 Customer Identity & KYC Platform Repo: bank-kyc Phase: 4 Status: Built (2026-04-30) Module type: hybrid (IaC + application Lambda)


Purpose

Sanctions / PEP / adverse-media lists change continuously: providers publish new versions multiple times per day. MOD-014 owns the bank's fresh-list guarantee:

  • Polls Refinitiv WorldCheck and Dow Jones Risk & Compliance feeds on a 15-minute schedule (production target — FR-097).
  • Validates each ingested payload structurally + by signature (FR-098). Any failure leaves the list as REJECTED and the previous version remains ACTIVE.
  • Persists each version into kyc.sanctions_lists (header) and kyc.sanctions_list_entries (rows) — append-only, immutable except for the documented status mutations.
  • Diffs against the previous active version (added / removed / modified counts) and publishes bank.kyc.list_updated v1 to the bank-kyc EventBridge bus within 60s (FR-100).
  • Retains the previous version for ≥48h to allow a rollback should a corrupted or incorrect list reach ACTIVE (FR-099).
  • Exposes two IAM-authed admin APIs:
  • POST /kyc/lists/refresh — force-refresh a single source
  • POST /kyc/lists/rollback — re-activate a previous version inside the 48h window
  • Raises an AML-006 SNS alert to the alarm-intake topic when ingestion movement (added + removed + modified) ÷ entry_count exceeds 25% — a signal that a corrupt feed has been published.

Downstream MOD-013 subscribes to bank.kyc.list_updated and re-screens its MATCH_PENDING + active populations against the new list.

Architectural fit

Reuses every pattern from MOD-009/010/013:

  • SST v3 Ion + Pulumi caret-pinned ^3.3.0
  • Per-module sst.config.ts (name: "bank-kyc-mod-014"); SCP-required defaultTags (tenant_id/module_id/environment)
  • Lambda runs as MOD-104 BankKycRole (iam:CreateRole denied to cicd by SCP)
  • Defensive upstream lookups via infra/lib/upstream.ts (requireSsm / requireSecretArn)
  • MOD-043 schema-registry redirect — registers bank.kyc.list_updated in bank-events-{env}
  • Append-only Postgres via Flyway migration in migrations/V001
  • arm64 Parameters & Secrets Lambda Extension layer for cached secrets
  • OpenTelemetry/ADOT layer for traces; structured ADR-031 logger with PII redaction (extends to raw_payload + aliases keys)

Data model

kyc.sanctions_lists

column type role
id uuid PK row identifier — emitted as list_id in events
list_source varchar(32) OFAC / UN / MFAT / DFAT / REFINITIV / DOW_JONES
list_version varchar(64) provider-stamped version identifier
previous_version varchar(64) NULL previous active version, NULL on first ingest
status varchar(16) PENDING / ACTIVE / RETIRED / REJECTED
entry_count integer rows persisted in this version
added_count integer diff vs previous
removed_count integer diff vs previous
modified_count integer diff vs previous
signature_status varchar(16) UNVERIFIED / VALID / INVALID / SKIPPED
signature_algorithm varchar(32) NULL provider-supplied algorithm
signature_value text NULL the signature bytes / hex
payload_sha256 varchar(64) NULL canonical-payload digest
fetched_at timestamptz wall-clock at provider fetch
activated_at timestamptz NULL populated on PENDING → ACTIVE
retired_at timestamptz NULL populated on ACTIVE → RETIRED
rollback_window_expires_at timestamptz NULL retired_at + 48h
ingestion_status varchar(16) NULL ACTIVATED / ROLLED_BACK
ingested_by varchar(32) always 'MOD-014'
trigger_event_id varchar(128) propagation root (idempotency)
trace_id varchar(128) otel trace
created_at timestamptz row insert wall-clock

Trigger kyc.fn_sanctions_lists_append_only permits only the mutations on status, activated_at, retired_at, rollback_window_expires_at, ingestion_status — every other column is immutable. DELETE refused absolutely.

kyc.sanctions_list_entries

column type role
id uuid PK row identifier
list_id uuid FK → kyc.sanctions_lists parent list
entry_id varchar(128) provider-stamped stable id (e.g. OFAC SDN-12345)
primary_name text canonical name to screen against
aliases jsonb array of alias / AKA strings
date_of_birth date NULL YYYY-MM-DD when known
citizenship varchar(8) NULL ISO-3166-1 alpha-2/3
raw_payload jsonb full provider payload for the audit trail
created_at timestamptz row insert wall-clock

Append-only — both UPDATE and DELETE refused by trigger kyc.fn_sanctions_list_entries_append_only.

Trigger surfaces

Trigger Source Effect
scheduled.list-poll EventBridge default bus (rate(15 minutes) prod, rate(24 hours) non-prod) Fan out POLLED_SOURCES through the relevant provider; ingest if changed
POST /kyc/lists/refresh API Gateway (IAM-auth) Force-refresh a single list_source
POST /kyc/lists/rollback API Gateway (IAM-auth) Re-activate a RETIRED version inside the 48h window

Ingestion pipeline

                  +------------------------+
                  |  ListProvider.fetch()  |
                  +-----------+------------+
                              |
                              v
                  +------------------------+
                  |  validateList          |
                  |  (FR-098 structural    |
                  |   + signature check)   |
                  +-----------+------------+
                              | throws
                              | VALIDATION_FAILURE
                              | on bad payload
                              v
                  +------------------------+
                  | persistence.loadActive |
                  +-----------+------------+
                              |
                              v
                  +------------------------+
                  |  diffEntries           |
                  |  (count add/rem/mod)   |
                  +-----------+------------+
                              v
                  +------------------------+
                  | persistPending         |
                  | (insert sanctions_list |
                  |  + bulk entries; tx)   |
                  +-----------+------------+
                              v
                  +------------------------+
                  | activate               |
                  | (flip new → ACTIVE,    |
                  |  retire previous + 48h)|
                  +-----------+------------+
                              v
                  +------------------------+
                  | events.publishUpdated  |
                  | (validate v1 schema +  |
                  |  PutEvents + maybe SNS |
                  |  AML-006 alert)        |
                  +------------------------+

A short-circuit at loadActive: if the provider returns the same list_version the active row carries, the pipeline returns ListUnchanged and the handler records status: UNCHANGED in idempotency without writing.

SSM outputs

Path Value
/bank/{stage}/kyc/lists/function-arn Lambda ARN
/bank/{stage}/kyc/lists/function-name Lambda name
/bank/{stage}/kyc/lists/refresh-api-endpoint API URL
/bank/{stage}/kyc/lists/rollback-api-endpoint API URL
/bank/{stage}/kyc/events/list-updated/schema-arn Schema ARN
/bank/{stage}/kyc/tables/sanctions-lists/name kyc.sanctions_lists
/bank/{stage}/kyc/tables/sanctions-list-entries/name kyc.sanctions_list_entries

Secrets provisioned

ARN suffix Purpose
bank-sanctions-lists/{stage}/refinitiv-feed-key Refinitiv WorldCheck list-feed credentials
bank-sanctions-lists/{stage}/dow-jones-feed-key Dow Jones Risk & Compliance list-feed credentials

dev/uat receive placeholder JSON; prod values rotate via orchestrator.

Events published

  • bank.kyc.list_updated v1 — every successful activation or rollback. Detail-type follows the SD02 catalogue convention bank.{domain}.{noun}_{verb}.

FR coverage

FR Mechanism
FR-097 (≤15min detection) EventBridge schedule rule lists-scheduled-poll (rate(15 minutes) prod)
FR-098 (validation) validateList enforces structural + signature checks; failures throw VALIDATION_FAILURE
FR-099 (≥48h rollback window) rollback_window_expires_at = retired_at + 48h; rollback API refuses outside the window
FR-100 (≤60s event publication) Publisher measures elapsed_ms; warns when over the budget

Policy satisfaction

Policy Mode Mechanism
AML-007 AUTO The only paths to ACTIVE are through validateList + activate; no force-clear paths. Negative source-level checks in tests/integration/policy/aml-007-auto.test.ts.
AML-006 ALERT Anomalous-movement detection raises an SNS alert with envelope AML_006_LIST_INGESTION_ANOMALY to the alarm-intake topic; first-ingestion never alerts.

Quality gates met

  • Unit tests: 91 passing
  • Coverage: 85.23% lines / 92.95% funcs / 79.69% branches / 85.23% statements (thresholds: 80 / 80 / 75 / 80)
  • Typecheck: clean
  • Integration tests: 1 per FR + 1 per policy + 7 infra tests + NFR-019 idempotency
  • NFR-024 immutability: trigger refuses UPDATE on immutable columns (list_version, entry_count, etc.) + DELETE on both tables; asserted in tests/integration/infra/sanctions-lists-immutability.test.ts

Out-of-scope / drift items

  1. Provider transports. RefinitivListProvider and DowJonesListProvider throw by default. Real HTTPS clients land in a follow-up; the engine is fully wired today against the stub transports.
  2. PEP / adverse-media list ingestion. MOD-014 v1 ingests sanctions lists only. PEP and adverse-media lists are sourced live by MOD-013 per-screen (SLA-bound), not periodically. If batch ingestion is later needed, a new ListSource enum value + provider config is the only change.
  3. MOD-013 list_updated event contract. MOD-013 already wires a forward-compatible rule on the bank-kyc bus. The shape published here matches what MOD-013's parseEventTrigger expects: list_source ∈ {OFAC, UN, MFAT, DFAT, REFINITIV, DOW_JONES} and list_version string.
  4. Anomaly threshold 25%. Default per orchestrator drift-item ruling. Configurable via DEFAULT_ANOMALY_THRESHOLD_RATIO constant if ops tuning needed.