Audit & Evidence Design¶
Most AML fines involve one of two failures:
- The control existed on paper but its implementation had drifted, or
- The control ran but the institution couldn't prove what it had done when the regulator asked.
This framework fixes (1) with spec-driven generation and (2) with a deterministic, append-only evidence ledger.
What gets recorded per run¶
Every aml run produces an evidence bundle containing:
| File | Contents |
|---|---|
manifest.json |
spec git SHA, spec content hash, engine version, timestamps |
spec_snapshot.yaml |
byte-exact copy of the spec used |
input_manifest.json |
per-contract: row count, column checksum, earliest/latest ts |
rules/<rule_id>.sql |
the SQL actually executed for that rule |
alerts/<rule_id>.jsonl |
one JSON line per alert, deterministic ordering |
alerts/<rule_id>.hash |
SHA-256 of the JSONL; the "output hash" |
cases/<case_id>.json |
case file: triggering txns, KYC snapshot, prior-alert history |
decisions.jsonl |
reviewer decisions appended over time, never mutated |
dq_exceptions.jsonl |
per-run data-quality violations (always present, may be empty); each record carries severity (critical / high / medium / low / info, default high) threaded from the originating quality_checks entry (PR-B5 / #370) so investigators triage critical contract breaks separately from cosmetic format defects |
field_lineage.jsonl |
alert-field → (source contract, column, transform) mapping |
sla_report.json |
Pillar-6 SLA-monitor report: alert-disposition breaches + batch-lateness signal (always written; empty when program.sla is unset) |
run_cost_volume.json |
row counts per table + wall-clock / peak-memory / per-rule timing |
monitoring_digest.json |
post-run rollup: alerts per rule/queue/severity, top-3 firers, DQ totals, per-rule diff vs prior run (hash pinned on the manifest) |
defect_log.jsonl |
Pillar-2 defect tickets: severity + 11-category classifier + data/rule/mapping triage + lifecycle (open/acknowledged/resolved/closed/wont_fix); always written, hash pinned on the manifest |
reconciliation_report.json |
Pillar-4 row-count survival per contract across bronze→silver→gold→alert + signed drop attribution (hash pinned on the manifest) |
case_links.jsonl |
fraud↔AML cross-program case links (#523): one record per customer with open cases in BOTH the fraud and AML domains (aml_priority-driven); always written (empty when nothing links), customer_id masked under AML_PII_MASKING, hash pinned on the manifest |
signatures/manifest.sig |
detached signature over manifest.json (if signing key set) |
Granular evidence exports (PR-D4, #377)¶
A full-run aml audit-pack ZIP can be tens of megabytes. When a regulator
asks for evidence on one alert or a hand-picked batch, ship the subset:
aml export-case spec.yaml <run-dir> <case_id> # one case
aml export-batch spec.yaml <run-dir> --cases c1,c2,c3 # multi-case batch
Both commands produce deterministic ZIPs containing only the requested
case file(s), the rule SQL that produced each alert, the canonical alert
attached to each case, the per-case decision sub-chain, lineage
(rule_version, matched_row_ids, source input_files), and the spec
snapshot. Pass --signing-key (or set AML_CASE_PACK_SIGNING_KEY) to
attach an HMAC-SHA256 signature over the bundle hash to manifest.json.
Missing case ids fail loudly — export-batch refuses to ship a partial
pack. When the run was produced with AML_PII_MASKING=1, the pack
re-applies the run's pii_map.jsonl so plaintext PII never leaves the
masking boundary; the manifest records pii_masked: true in that case.
Determinism properties¶
- Ordered input. Rows going into any rule are sorted by the rule's natural key before hashing. Same input set → same input hash regardless of upstream order.
- Ordered output. Alerts are emitted sorted by
(rule_id, entity_id, window_end). Same alerts → same output hash. - Pinned spec. The spec snapshot is stored verbatim in the bundle. A reviewer can diff two bundles' snapshots to see the exact policy change between runs.
Re-execution check¶
Replays the bundled spec against the bundled input snapshot and asserts the recomputed output hash equals the stored one. A mismatch is a reportable control-integrity failure.
Immutability model¶
The on-disk ledger is append-only by convention (no update/delete code path), but file-system immutability is not enough for audit — a bad actor with shell access could rewrite history. Production deployments should:
- Stream
manifest.json+ hashes to WORM-class storage (S3 Object Lock, Azure Immutable Blob, on-prem WORM). - Sign each manifest with an offline key; rotate per retention policy.
- Forward decision events to a SIEM with its own retention guarantee.
The framework provides the artifacts; the institution provides the tamper-evident substrate.
What an auditor can answer with a bundle¶
- "Which exact rule wording produced alert A-1234?" —
spec_snapshot.yaml+alerts/<rule_id>.jsonl. - "What regulation clause was this control designed to satisfy?" —
spec_snapshot.yaml -> rules[*].regulation_refs. - "Who approved the threshold change last quarter?" — git log of
aml.yamlplus PR review history. - "Can we reproduce last April's numbers?" —
aml replayagainst the April bundle. - "Was this customer alerted before?" —
cases/prior-alert history field, cross-referenced to earlier bundles bycustomer_id.