Skip to content

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Counterparty queue rows. When entity_type=COUNTERPARTY, party context (cdd_tier, jurisdiction, primary_name) is null. Analyst UI handles this gracefully.