Audit trail — export format (v2.1)
Stratify stores the authoritative audit chain in Supabase Postgres (audit_log), append-only at the DB layer (two BEFORE UPDATE/DELETE triggers reject mutations). Chain implementation: apps/web/lib/audit/chain.ts. Test coverage: apps/web/lib/audit/__tests__/chain.test.ts.
Retention: 2 years (Phase A, newsletter-grade). Flippable to 7 years at FMA license grant — change the partial archive policy in supabase/migrations/ and the bucket lifecycle on the storage backup (Supabase Storage today, S3 later if cost demands).
Canonical row (JSON)
One object per line (NDJSON) when exporting large volumes. Order is ascending by id — required for chain reverification.
{
"id": 184726354,
"occurredAt": "2026-05-19T12:34:56.789Z",
"actorId": null,
"actorRole": "system",
"eventType": "signal.published",
"subjectType": "signal",
"subjectId": "uuid",
"payload": { "mandateId": "koalitionsmix-eu", "action": "rebalance", "legCount": 6 },
"prevHash": "sha256-hex",
"hash": "sha256-hex"
}| Field | Notes |
|---|---|
prevHash | Previous row hash; null on the genesis row |
hash | chainHash(prev, canonical(payload-row)) — see below |
payload | JSONB; canonicalised before hashing |
Hash construction
canonical(obj) = JSON with keys sorted recursively, undefined removed
hash(row) = SHA-256( prevHash_bytes || canonical({
actorId, actorRole, eventType, subjectType, subjectId, payload
}) )Re-implementable in any language. The canonicaliser is intentionally simple (no key escaping beyond JSON's defaults) — see chain.ts:canonical.
Verification procedure
- Sort rows ascending by
id. - Start with
prev = null. - For each row:
- Assert
row.prevHash === prev(skip on genesis). - Compute
expected = chainHash(prev, canonical(rowMinusHashes)). - Assert
expected === row.hash. - Set
prev = row.hash.
apps/web/lib/audit/chain.ts → verifyChain() implements this in ~30 lines and returns the first divergent id if any.
B2B audit export endpoint
GET /v1/audit-log/{partnerId}?from=...&to=... returns the partner's slice (rows where subject_id = partnerId or payload->>'partnerId' = partnerId) along with the global chainVerified flag. Partners may re-verify the slice locally — the bare row hashes are sufficient since each row contains its own prevHash.
Tampering response
If verifyChain returns ok: false, the system is in a P0 compliance state:
- Snapshot the table to a hold-bucket immediately.
- Quarantine writers (revoke service-role rotation).
- Open a STRIDE incident; notify FMA workstream (Antonios) within 24 h once
we are post-license.