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
RULEcould turnUPDATE/DELETEinto 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 triggerRAISEs — it fails loudly, with an error. REVOKE UPDATE, DELETEgrants 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 → completedtransition), - 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 CASCADEwould erase the evidence the instant the document is deleted — the exact opposite of an audit trail.ON DELETE RESTRICTwould 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:
- The
audit_eventstable with its append-only trigger, the two queries, and the actor model. - Weaving one event into every state change — and proving the rows survive the document's deletion.