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-requireddefaultTags - 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¶
- No events published. MOD-012 is a pure sink. No catalogue additions; no schemas registered in MOD-043's registry.
- Catch-all EB pattern. Forward-compatible — MOD-015's
sanctions_match_cleared(when it ships) and any future bank.kyc.* events are captured automatically. ThemoduleForDetailTypeswitch maps known event types to their owning module; unknown types fall through to"UNKNOWN"and the row is still written. - System pseudo-party. Events without a customer scope (e.g.
bank.kyc.list_updated) are recorded under00000000-0000-0000-0000-000000000000so the per-party hash chain stays per-key consistent. - 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).
- Query/verify APIs return 200 even when chain is broken. The body's
ok: falseflag is the regulator-facing signal; the HTTP status reflects "the verify request itself succeeded".