Skip to content
Data Modelstable

Append-only audit log model

A tamper-evident record of who did what, when, and to what — append-only, hash-chained, and enforced by the database so the log cannot be quietly edited or deleted after the fact.

An audit log is only worth having if it cannot be altered by the same access that alters the data it audits. This spec defines a log that is append-only at the database level and tamper-evident by hash chaining, so a missing or edited entry is detectable rather than silent. It is the evidentiary backbone regulated systems are asked to produce.

Entity model

Entity-relationship model

Each event records the actor, the action, the entity affected, and the full before/after state, plus the hash of the previous event and its own hash. Events form a chain per tenant: every entry commits to the one before it.

Audit log ERD
Audit log ERD

Isolation policy

Append-only enforcement

Append-only is a database guarantee, not an application convention. The application role is granted INSERT and SELECT only; UPDATE and DELETE are revoked, and a trigger rejects them outright so even a privileged path cannot rewrite history. The log can grow and be read — never modified.

Revoke mutation; reject it at the trigger
REVOKE UPDATE, DELETE ON audit_events FROM app_role;

CREATE OR REPLACE FUNCTION reject_mutation() RETURNS trigger AS $body$
BEGIN
    RAISE EXCEPTION 'audit_events is append-only';
END;
$body$ LANGUAGE plpgsql;

CREATE TRIGGER audit_no_mutation
    BEFORE UPDATE OR DELETE ON audit_events
    FOR EACH ROW EXECUTE FUNCTION reject_mutation();

Definition

Tamper-evidence via hash chaining

Each event's hash is computed over the previous event's hash and this event's canonical payload. A single altered or removed row breaks the chain from that point forward, so tampering is detectable by re-walking the chain — you cannot change one entry without recomputing every entry after it.

Chain verification — must find no break
-- Recompute each row's hash from the prior hash + payload and
-- assert it matches the stored hash. Any mismatch = tampering.
WITH ordered AS (
    SELECT id, prev_hash, hash,
           digest(COALESCE(prev_hash, '') ||
                  entity_type || entity_id::text ||
                  COALESCE(before::text,'') || COALESCE(after::text,''),
                  'sha256') AS recomputed
    FROM audit_events
    ORDER BY created_at
)
SELECT id FROM ordered WHERE hash <> recomputed;   -- expect zero rows

Contract

What every event must capture

FieldRequirementWhy
actor_idAlways set (or a system sentinel)Every change is attributable
actioncreate / update / deleteThe nature of the change
before / afterFull state, both sidesReconstruct exactly what changed
hash chainprev_hash + hashTamper-evidence
created_atServer time, immutableOrdering and evidentiary timeline
Required fields on every audit event

Invariants this spec guarantees

  • The log is append-only: UPDATE and DELETE are rejected at the database, not just discouraged in code.
  • Every event commits to the previous one by hash, so altering or removing any entry is detectable.
  • Every event is attributable to an actor and records full before/after state.
  • The chain is verifiable at any time by re-walking it; a break localises the tampering.

Want this specified for your system?

We turn definitions like these into the actual schema, policies, and contracts your system runs on. Fixed scope, fixed price, defined delivery date.

Request a Fixed-Scope Architecture Blueprint