Skip to content

MOD-012 — KYC Audit Trail Store

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


Purpose

Owns the bank's immutable record of every KYC action. Subscribes to all bank.kyc.* EventBridge events on the bank-kyc bus via a catch-all rule, persists each into kyc.kyc_audit_events (append-only) within 1 second of receipt (FR-089), and links each event into a per-party SHA-256 hash chain (FR-092) so any insertion / modification / deletion is detectable. Two IAM-authed query APIs serve compliance officers (/query for filtered retrieval, /verify for tamper detection) and back regulator-evidence requests for AML-001, AML-002, GOV-006, and PRI-005.

FR coverage

FR Mechanism
FR-089 EventBridge → Lambda → DB INSERT in a single transaction with per-party advisory lock; handler measures elapsed_ms vs INGEST_DEADLINE_MS.
FR-090 Trigger refuses DELETE absolutely; no application path issues DELETE. Retention is enforced by not deleting.
FR-091 idx_audit_events_party_occurred covers the typical query shape; pagination cap 1000 rows; handler measures elapsed_ms vs QUERY_DEADLINE_MS.
FR-092 Per-party hash chain this_hash = sha256(prev_hash \|\| canonical_payload \|\| sequence_no \|\| occurred_at_iso). Verify API walks chain, recomputes, returns { ok: false, broken_at_sequence, expected_hash, actual_hash } on mismatch.
NFR-013 Indexed party+occurred lookup is single-index-scan; sub-5ms for the row-fetch portion.
NFR-019 EventBridge at-least-once delivery + idempotency on (source_module, source_event_id). RTO/RPO inherited from Neon PITR.
NFR-024 Trigger kyc.fn_kyc_audit_events_append_only blanket-denies UPDATE and DELETE.

Architectural fit

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

  • SST v3 Ion + Pulumi (^3.3.0)
  • Per-module sst.config.ts (name: "bank-kyc-mod-012"); SCP-required defaultTags
  • Lambda runs as MOD-104 BankKycRole
  • Defensive upstream lookups via infra/lib/upstream.ts
  • arm64 Parameters & Secrets Lambda Extension layer
  • OpenTelemetry/ADOT layer for traces
  • Structured ADR-031 logger with extra PRI-005 redaction (payload, canonical_payload, detail) — the audit table itself is the canonical record, never the structured log

Triggers

Trigger Source Effect
audit-ingest-all-kyc bank-kyc bus, {source: ["bank.kyc"]} (catch-all, no detail-type filter) Persist event to kyc.kyc_audit_events with per-party hash chain
POST /kyc/audit/query API Gateway (IAM-auth) Filtered retrieval — party_id + date range + event_types, paginated
POST /kyc/audit/verify API Gateway (IAM-auth) Walk chain, return {ok:true,length} or {ok:false,broken_at_sequence,...}

Data model

kyc.kyc_audit_events (MOD-012-owned)

column type role
id uuid PK row identifier
party_id uuid UUID of the customer (or 00000000-... system pseudo-party for non-customer events)
sequence_no bigint monotonic per party_id (advisory-locked allocation)
event_type varchar(128) upstream EventBridge detail-type
source_module varchar(32) derived owning module (MOD-009..014 + UNKNOWN forward-compat)
source_event_id varchar(128) upstream EventBridge id
payload jsonb upstream detail payload (verbatim)
canonical_payload text sorted-key serialisation of payload, hash input
prev_hash varchar(64) previous row's this_hash for same party (empty for first row)
this_hash varchar(64) sha256(prev_hash || canonical_payload || sequence_no || occurred_at_iso)
occurred_at timestamptz upstream EventBridge time
recorded_at timestamptz row insert wall-clock
trace_id varchar(128) NULL propagated from upstream detail.trace_id
correlation_id varchar(128) NULL propagated from upstream detail.correlation_id

Trigger kyc.fn_kyc_audit_events_append_only refuses DELETE and UPDATE absolutely — every column is immutable from INSERT onwards.

Indexes: * uniq_audit_events_party_sequence (party_id, sequence_no) — guarantees no duplicate sequence_no per party * uniq_audit_events_source (source_module, source_event_id) — idempotency belt-and-braces * idx_audit_events_party_occurred (party_id, occurred_at DESC) — primary query path * idx_audit_events_type_occurred (event_type, occurred_at DESC) — secondary

Hash chain algorithm

canonical_payload   = canonicalise(detail)         // sorted keys, no whitespace
prev_hash on row 1  = ""                           // empty string
prev_hash on row N  = this_hash on row N-1 (same party_id)
this_hash           = sha256(
                        prev_hash ||
                        "|" ||
                        canonical_payload ||
                        "|" ||
                        sequence_no ||
                        "|" ||
                        occurred_at.toISOString()
                      ) hex

canonicalise is a pure function — re-running it during verify reproduces the exact bytes used at write time.

Idempotency

Replay-safe on (source_module, source_event_id). The handler checks the in-memory store first (24h TTL) and the DB unique index uniq_audit_events_source is the durable belt-and-braces guarantee. Replays return without re-writing or re-publishing.

SSM outputs

Path Value
/bank/{stage}/kyc/audit/function-arn Lambda ARN
/bank/{stage}/kyc/audit/function-name Lambda name
/bank/{stage}/kyc/audit/query-api-endpoint Query API URL
/bank/{stage}/kyc/audit/verify-api-endpoint Verify API URL
/bank/{stage}/kyc/tables/kyc-audit-events/name kyc.kyc_audit_events

Policy satisfaction

Policy Mode Mechanism
AML-001 LOG Catch-all subscription + immutability trigger — every catalogue and forward-compat event is captured. Tested in policy/aml-001-log.test.ts.
AML-002 LOG Reconstructability: a 4-event onboarding chain round-trips through canonicalise + hash chain + verify and returns ok=true. Tested in policy/aml-002-log.test.ts.
GOV-006 LOG Verify path uses only audit-row columns + canonicalise + sha256 — no upstream module dependencies. Tested in policy/gov-006-log.test.ts.
PRI-005 LOG Bounded collection: ingest stores detail verbatim; no enrichment from party.parties or banking.customer_relationships. Tested in policy/pri-005-log.test.ts.

Quality gates met

  • Unit tests: 79 passing
  • Coverage: 90.74% lines / 96.07% funcs / 91.3% branches / 90.74% statements (thresholds 80/80/75/80)
  • Typecheck: clean
  • Integration tests: 1 per FR (4) + 1 per policy (4) + 4 infra + 2 NFR
  • NFR-024 immutability: trigger refuses UPDATE and DELETE outright

Out-of-scope / drift items

  1. No events published. MOD-012 is a pure sink. No catalogue additions; no schemas registered in MOD-043's registry.
  2. Catch-all EB pattern. Forward-compatible — MOD-015's sanctions_match_cleared (when it ships) and any future bank.kyc.* events are captured automatically. The moduleForDetailType switch maps known event types to their owning module; unknown types fall through to "UNKNOWN" and the row is still written.
  3. System pseudo-party. Events without a customer scope (e.g. bank.kyc.list_updated) are recorded under 00000000-0000-0000-0000-000000000000 so the per-party hash chain stays per-key consistent.
  4. 7-year retention via no-delete. Future archival to S3 cold storage is a follow-up — out of scope for FR-090 today (the trigger guarantees persistence; storage size is a Neon ops concern).
  5. Query/verify APIs return 200 even when chain is broken. The body's ok: false flag is the regulator-facing signal; the HTTP status reflects "the verify request itself succeeded".