MOD-015 — False Positive Management
System: SD02 Customer Identity & KYC Platform
Repo: bank-kyc
Phase: 4
Status: Built (2026-04-30)
Module type: hybrid (IaC + application Lambda)
Related ADRs: ADR-048 (Database-enforced invariants)
ADR-048 compliance
V001 created trg_false_positive_decisions_append_only using a
module-private function. V002 replaces this with the canonical
trg_false_positive_decisions_immutable (sharing
kyc.fn_immutable_row() with the other three new ADR-048 triggers in
the kyc schema). Behaviour is identical (blanket-deny UPDATE+DELETE);
only the names change. The V001 module-private function is dropped.
Negative integration tests in
tests/integration/infra/fp-tables-immutability.test.ts assert the
canonical trigger is present and the V001 names are gone.
The other two MOD-015 triggers — the lifecycle-mutation-only trigger
on kyc.fp_review_queue and the append-only trigger on
kyc.fp_metrics_monthly — are explicitly accepted by ADR-048 (the
SD02 spec lists them as "already deployed by MOD-015 ✅"). They retain
their V001 names and module-private functions.
Purpose
Analyst-facing workflow layer on top of MOD-013's sanctions screening
primitives. Consumes bank.kyc.sanctions_match_found events with
result_status=MATCH_PENDING, persists them into a structured review
queue (kyc.fp_review_queue), serves analyst APIs to list and
adjudicate items, and emits the catalogue's bank.kyc.sanctions_match_cleared
event when an adjudication confirms a false positive (lifting account /
payment holds in MOD-007 / MOD-020). A daily 24h sweep escalates
inactive items to senior compliance (FR-103). A monthly job computes
false-positive rates per list × screening rule for tuning analytics
(FR-104).
FR coverage
| FR |
Mechanism |
| FR-101 |
EB consumer rule on sanctions_match_found (filtered to MATCH_PENDING) → kyc.fp_review_queue row carrying matched_name, list_source, match_score, match_type, cdd_tier, jurisdiction, primary_name. POST /kyc/fp/queue returns these fields. |
| FR-102 |
POST /kyc/fp/adjudicate parser requires decided_by + rationale (≥20 chars) + decision. On FALSE_POSITIVE, writes kyc.false_positive_decisions with suppress_until (default today + 365d) and emits bank.kyc.sanctions_match_cleared. The DB-tier suppression index idx_false_positive_decisions_suppress (party_id, match_entry_id, suppress_until) WHERE decision = 'FALSE_POSITIVE' is the primary lookup for MOD-013's screening engine to consult. |
| FR-103 |
Hourly (prod) scheduled rule walks PENDING queue rows older than ESCALATION_AGE_HOURS (24); flips to ESCALATED + raises an SNS alert to alarm-intake with envelope AML_007_FP_ESCALATED. |
| FR-104 |
Monthly (prod) cron(0 2 1 * ? *) job aggregates MATCH_PENDING + decision counts per (list_source, source_module) over the trailing 30d window and INSERTs to kyc.fp_metrics_monthly. |
| NFR-024 |
kyc.fp_review_queue permits UPDATE only on lifecycle columns; kyc.false_positive_decisions and kyc.fp_metrics_monthly are blanket-deny UPDATE+DELETE. |
Triggers
| Trigger |
Source |
Effect |
bank.kyc.sanctions_match_found (filtered MATCH_PENDING) |
bank-kyc bus |
Insert kyc.fp_review_queue row |
scheduled.fp-escalation |
default bus, rate(1 hour) prod / rate(24 hours) non-prod |
24h escalation sweep |
scheduled.fp-metrics |
default bus, cron(0 2 1 * ? *) prod / rate(7 days) non-prod |
Monthly FP-rate aggregation |
POST /kyc/fp/queue |
API Gateway (IAM) |
List queue items |
POST /kyc/fp/adjudicate |
API Gateway (IAM) |
Record decision; emits 1–2 events |
Data model
kyc.fp_review_queue (MOD-015-owned)
| column |
type |
role |
| id |
uuid PK |
row identifier |
| screening_id |
uuid |
references original kyc.sanctions_results.id |
| sanctions_result_id |
uuid NULL |
denormalised lookup |
| entity_id |
text |
UUID for CUSTOMER, opaque for COUNTERPARTY |
| entity_type |
varchar(16) |
CUSTOMER / COUNTERPARTY |
| party_id |
uuid NULL |
populated for CUSTOMER entries |
| list_source |
varchar(16) |
OFAC/UN/MFAT/DFAT/REFINITIV/DOW_JONES |
| matched_entry_id |
text NULL |
provider list entry id |
| matched_name |
text NULL |
from kyc.sanctions_results.match_details[0].matched_name |
| match_score |
varchar(16) NULL |
decimal string |
| match_type |
varchar(16) NULL |
EXACT / FUZZY / ALIAS |
| triggering_context |
varchar(32) |
ONBOARDING / PAYMENT / LIST_UPDATE / PERIODIC_REVIEW / MANUAL |
| cdd_tier |
varchar(16) NULL |
from banking.customer_relationships |
| jurisdiction |
varchar(8) NULL |
from banking.customer_relationships |
| primary_name |
text NULL |
from party.parties.legal_name |
| status |
varchar(16) |
PENDING → RESOLVED | ESCALATED |
| queued_at |
timestamptz |
EventBridge time |
| resolved_at |
timestamptz NULL |
populated on adjudication |
| resolved_by |
text NULL |
reviewer staff_id |
| escalated_at |
timestamptz NULL |
populated by sweep or ESCALATED decision |
| source_event_id |
varchar(128) |
EventBridge id (idempotency) |
| trace_id |
varchar(128) |
otel propagation |
| created_at / updated_at |
timestamptz |
row lifecycle |
kyc.false_positive_decisions (shared with MOD-013, idempotent V001)
Created IF NOT EXISTS by MOD-015's V001 — MOD-013's FR-452 API also
writes here. Schema matches the SD02 data model. Append-only trigger
refuses UPDATE and DELETE outright.
kyc.fp_metrics_monthly (MOD-015-owned)
| column |
type |
role |
| id |
uuid PK |
row identifier |
| year_month |
varchar(7) |
'YYYY-MM' |
| list_source |
varchar(16) |
bucket key |
| source_module |
varchar(32) |
bucket key (from kyc.sanctions_results.screened_by) |
| match_pending_count |
integer |
numerator denominator |
| false_positive_count |
integer |
FP count |
| confirmed_count |
integer |
confirmed-match count |
| escalated_count |
integer |
ESCALATED count |
| fp_rate |
numeric(5,4) |
false_positive_count / match_pending_count (0..1) |
| window_start / window_end |
timestamptz |
trailing-30d boundaries |
| computed_at |
timestamptz |
row insert wall-clock |
Events
| Event |
Direction |
Notes |
bank.kyc.sanctions_match_found v1 |
consumed (MOD-013, filtered) |
Queue ingest |
bank.kyc.false_positive_recorded v1 |
published |
Schema registered by MOD-013; MOD-015 reuses the registration. Same shape as MOD-013's. |
bank.kyc.sanctions_match_cleared v1 |
published |
NEW, MOD-015-owned. Emitted only on FALSE_POSITIVE decision. Consumers: MOD-007, MOD-020, MOD-012 (catch-all). |
SSM outputs
| Path |
Value |
/bank/{stage}/kyc/fp/function-arn |
Lambda ARN |
/bank/{stage}/kyc/fp/function-name |
Lambda name |
/bank/{stage}/kyc/fp/queue-api-endpoint |
Queue API URL |
/bank/{stage}/kyc/fp/adjudicate-api-endpoint |
Adjudicate API URL |
/bank/{stage}/kyc/events/sanctions-match-cleared/schema-arn |
NEW v1 schema ARN |
/bank/{stage}/kyc/tables/fp-review-queue/name |
kyc.fp_review_queue |
/bank/{stage}/kyc/tables/fp-metrics-monthly/name |
kyc.fp_metrics_monthly |
Idempotency
| Trigger |
Key shape |
| Queue ingest |
ingest:${source_event_id} (+ DB unique index on source_event_id) |
| Adjudicate API |
caller-supplied idempotency_key (queue status guard backstop) |
| Escalation sweep |
escalate:${trigger_event_id}:${queue_id} |
| Monthly metrics |
metrics:${trigger_event_id}:${year_month} (+ unique index on (year_month, list_source, source_module)) |
Policy satisfaction
| Policy |
Mode |
Mechanism |
| AML-007 |
LOG |
Adjudicate parser refuses missing decided_by / rationale < 20 chars / unknown decision; trigger refuses UPDATE/DELETE on kyc.false_positive_decisions. |
| GOV-006 |
LOG |
Metrics computation reads only kyc.sanctions_results + kyc.false_positive_decisions; no upstream-module dependencies. Adjudicate API writes a decision row AND emits the recorded event so QA sampling can compare both surfaces. |
Quality gates met
- Unit tests: 91 passing
- Coverage: 86.91% lines / 94.8% funcs / 79.42% branches / 86.91% statements (thresholds: 80 / 80 / 75 / 80)
- Typecheck: clean
- Integration tests: 1 per FR (4) + 1 per policy (2) + 5 infra + NFR-019
- NFR-024 immutability: trigger asserted via
infra/fp-tables-immutability.test.ts
Out-of-scope / drift items
- MOD-013 retains its FR-452 API. MOD-013's
POST /kyc/sanctions/false-positive stays as a back-office direct path for engineering / compatibility. Both modules write to kyc.false_positive_decisions (single source of truth). The wiki note "maintained by MOD-015" is honoured by MOD-015's V001 idempotent table creation; MOD-013's writes are reads from MOD-015's perspective.
- REFINITIV / DOW_JONES list_source projection. MOD-013's
false_positive_recorded v1 schema enum is OFAC/UN/MFAT/DFAT only. When an analyst adjudicates a REFINITIV / DOW_JONES match, we project to OFAC for the recorded event so the existing schema accepts it. The sanctions_match_cleared event (MOD-015-owned) accepts the full enum. A v2 of false_positive_recorded could widen this; deferred.
- MOD-013 suppression-list reading. MOD-013's screening engine doesn't yet read
kyc.false_positive_decisions for suppression-window short-circuits. The DB-tier index is present; wiring is a MOD-013 follow-up.
- Escalation alert delivery. SNS alert targets the alarm-intake topic; downstream notification routing (PagerDuty? email?) is owned by ops infra. We send the structured envelope; routing rules live elsewhere.
- Counterparty queue rows. When
entity_type=COUNTERPARTY, party context (cdd_tier, jurisdiction, primary_name) is null. Analyst UI handles this gracefully.