Audit Chain v2 & Evidence
Migration 023 adds a second, independent audit chain. The v1 audit_events chain is untouched — it stays readable and verifiable via GET /api/v1/audit/verify.
audit_events_v2 (chain)
Each row: sequence (UNIQUE, allocated by counter-row CAS UPDATE counters ... RETURNING), audit_version=2, hash_algorithm='HMAC-SHA256', key_id, prev_sequence, prev_hash, integrity_hash, event_type, actor_id, correlation_id, operation_id, payload_json (canonical JSON), occurred_at.
integrity_hash= RFC 2104 HMAC-SHA256(key, canonical fields) — fixes v1's non-standard construction and plain-SHA-256 fallback.- Link model: a writer waits briefly for
sequence-1, then links to the largest committed row below it and records that inprev_sequence. A crashed writer that burned a sequence cannot wedge the chain; links are explicit and re-computable. Sequence gaps (sequence_gaps) are reported, not hidden. - Missing key policy:
NOUGAT_AUDIT_HMAC_KEYabsent → production-config envs fail closed withAUDIT_KEY_MISSING(writes are refused before money moves —preflightat handler entry). Non-prod envs skip emission with a warn log — no weakened rows ever enter the chain. Key rotation is tracked per-row bykey_id(NOUGAT_AUDIT_HMAC_KEY_ID, defaultv1). - Append-only: no UPDATE/DELETE paths exist.
GET /api/v1/admin/audit/v2/events # recent events
GET /api/v1/admin/audit/v2/verify # recompute HMAC + links → {ok,checked,first_mismatch,link_breaks,sequence_gaps}2
verify needs the configured key; missing → 500 AUDIT_KEY_MISSING; a row with a different key_id → AUDIT_KEY_ID_MISMATCH.
audit_evidence (verifiable records)
One record per domain event — not a log line: evidence_id, event_type, refs_json (order/attempt/op/settlement/license/run ids), payload_json (redacted), payload_hash (sha256 of canonical payload), audit_event_id + audit_sequence (the chain row sealing it).
Emitted on: payment.capture, refund.attempt, refund.apply, settlement.commit, fee_rate.change, license.revoke, recovery.execute, recovery.verify, migration.apply (observed via schema_version on cron), alert:recon_failures.
Redaction: values under keys containing email|card|secret|token|password|authorization|api_key|payment_key|license_key|name|phone|address are replaced with [REDACTED] before hashing/storage — no PII, card data, or secrets in evidence.
GET /api/v1/admin/audit/evidence[?event_type=]audit_checkpoints
Daily segment seals (scope='daily', scope_key='YYYY-MM-DD'): first/last_sequence, last_event_id, event_count, root_hash — sequential fold sha256 over the segment's integrity_hash values, deterministic for a given segment. Append-only rows; latest generated_at wins.
- Cron generates yesterday's checkpoint;
POST /api/v1/admin/audit/checkpoints {scope_key?}triggers manually. storage_reference='NOT_CONFIGURED'— object-storage (R2) export is PLANNED/NOT_VERIFIED, pending approval; no upload code exists.
Scheduled assurance (cron)
Per 5-min tick: recon-streak alert evidence (alert:recon_failures ≥3 consecutive failed/degraded runs; external notification PLANNED), migration.apply observation evidence, yesterday's checkpoint.
NOT_VERIFIED / PLANNED
- External alert delivery (email/Slack/webhook) — PLANNED, audit record only today.
- Checkpoint export to object storage — PLANNED (
NOT_CONFIGURED). key_idmulti-key verify — current key only; rotated-key verification needs the matching secret per id.yp integrityCLI covers recon summary/report/v1-verify only; v2/evidence surfaces are admin-API-only today.