Stratify
Legal and compliance

Audit Trail Export Format

docs/compliance/audit-trail-export-format.md

Source updated 03. Aug. 2026

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"
}
FieldNotes
prevHashPrevious row hash; null on the genesis row
hashchainHash(prev, canonical(payload-row)) — see below
payloadJSONB; 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

  1. Sort rows ascending by id.
  2. Start with prev = null.
  3. 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:

  1. Snapshot the table to a hold-bucket immediately.
  2. Quarantine writers (revoke service-role rotation).
  3. Open a STRIDE incident; notify FMA workstream (Antonios) within 24 h once

we are post-license.