Audit logs are append-only by convention, but a privileged DB user can still DELETE rows or rewrite history. SOC 2 auditors want cryptographic integrity — and the typical answer is "ship logs to immutable storage." That's the right answer for the long term, but it introduces a network dependency on every mutation that has to be in the same transaction as the audit row.
The design
Every row carries:
- `prev_hash` — the row_hash of the previous row in this tenant's chain - `row_hash` — sha256(prev_hash || canonical_json(row))
To verify the chain you walk it from the genesis row and recompute. Tampering shows up as either a missing link or a hash mismatch.
Concurrency
Two concurrent mutations on the same tenant would race to claim the same prev_hash. We serialise on a per-tenant pg_advisory_xact_lock:
lockKey := "audit:" + tenantID
tx.Exec(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", lockKey)This gives us a tenant-scoped critical section without a global table lock — busy tenants only serialise against themselves.
What we didn't do
We considered logical-replication-to-Kafka and signing batches with a TPM. Both add operational surface area we didn't want at this stage. The chain runs entirely in Postgres; a future migration to a Merkle tree or external attestor is one column and a backfill away.