Theory

Append-only, the polymorphic actor, and FK-free

Every state change in this app — upload, send, view, sign, complete, delete, a rejected token, a tamper detection — deserves a permanent record: who did what, when. This lesson builds that record as an append-only audit trail, and the interesting parts are all about making "append-only" and "who" true, not just intended.

Append-only, enforced by the database — with a trigger. A trail you can edit is not a trail. So the rule lives in Postgres, not in Go:

CREATE FUNCTION audit_events_append_only() RETURNS trigger AS $$
BEGIN
    RAISE EXCEPTION 'audit_events is append-only: % is not permitted', TG_OP;
END; $$ LANGUAGE plpgsql;

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

Why a trigger, specifically? Two rejected alternatives, and the reason each fails:

  • A rewrite RULE could turn UPDATE/DELETE into a no-op — but silently. The write appears to succeed and simply does nothing. Silent is worse than forbidden: you would think you deleted a row and you did not. The trigger RAISEs — it fails loudly, with an error.
  • REVOKE UPDATE, DELETE grants look right, but the app connects as a superuser in dev, and superusers bypass table grants. The trigger applies to everyone, superuser included.

And the honest limit, stated plainly: a superuser could still DROP the trigger. True immutability against a hostile DBA needs off-box log shipping — writing the events somewhere the database admin can't reach. This trigger stops the application — and any ordinary role — from ever rewriting history. That is the real, bounded guarantee; claiming more would be the kind of overreach this course keeps refusing.

Who did it — the polymorphic actor. An actor is not always a user. It might be:

  • a user (the owner uploads, sends, deletes),
  • a signer (accountless — they view and sign via a token),
  • the system (the automatic sent → completed transition),
  • anonymous (a rejected or expired token — nobody we can name).

Forcing a user_id on every row would be a lie for three of those four. So the actor is modelled honestly:

actor_type      TEXT NOT NULL CHECK (actor_type IN ('user','signer','system','anonymous')),
actor_user_id   UUID,           -- nullable, NO foreign key
actor_signer_id UUID,           -- nullable, NO foreign key
actor_label     TEXT NOT NULL,  -- the human identity, FROZEN at event time

actor_type is the discriminator; the two id columns are nullable and carry whichever applies (or neither). And actor_label — the email, or "system" — is frozen into the row at write time, so the trail reads correctly with no joins even after the user or signer is deleted.

FK-free on purpose — the rows must outlive what they describe. This is the lesson's sharpest edge. Not only the actor ids but document_id too is a plain UUID with no foreign key. Think about deleting a draft: you want its "Deleted draft" event — and every earlier event — to survive. A foreign key gives you two bad choices:

  • ON DELETE CASCADE would erase the evidence the instant the document is deleted — the exact opposite of an audit trail.
  • ON DELETE RESTRICT would block the delete because audit rows reference it.

FK-free is the third way: the rows stay, document_id still points at a now-gone document, and the trail is intact. A naive foreign key here would quietly destroy the entire point.

A seq for stable order. Several events can share one transaction — a final signature writes signed and completed together, so their now() timestamps are identical (now() is fixed for a whole transaction). A timestamp alone can't order them. So the table carries seq BIGINT GENERATED ALWAYS AS IDENTITY, a monotonic counter, and the trail is ordered by seq.

Frozen messages. Each event stores a complete, plain-language sentence written at the moment it happened ("alice@example.com signed as ..."). The UI renders it verbatim — never re-derived from raw fields — so the historical record can't drift if display code changes later.

This lesson:

  1. The audit_events table with its append-only trigger, the two queries, and the actor model.
  2. Weaving one event into every state change — and proving the rows survive the document's deletion.