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
REJECTEDand the previous version remains ACTIVE. - Persists each version into
kyc.sanctions_lists(header) andkyc.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_updatedv1 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 sourcePOST /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-requireddefaultTags(tenant_id/module_id/environment) - Lambda runs as MOD-104 BankKycRole (
iam:CreateRoledenied to cicd by SCP) - Defensive upstream lookups via
infra/lib/upstream.ts(requireSsm/requireSecretArn) - MOD-043 schema-registry redirect — registers
bank.kyc.list_updatedinbank-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+aliaseskeys)
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_updatedv1 — every successful activation or rollback. Detail-type follows the SD02 catalogue conventionbank.{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 intests/integration/infra/sanctions-lists-immutability.test.ts
Out-of-scope / drift items¶
- Provider transports.
RefinitivListProviderandDowJonesListProviderthrow by default. Real HTTPS clients land in a follow-up; the engine is fully wired today against the stub transports. - 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.
- 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
parseEventTriggerexpects:list_source∈ {OFAC, UN, MFAT, DFAT, REFINITIV, DOW_JONES} andlist_versionstring. - Anomaly threshold 25%. Default per orchestrator drift-item
ruling. Configurable via
DEFAULT_ANOMALY_THRESHOLD_RATIOconstant if ops tuning needed.