The table, the trigger, and the actor model
Three files as tabs: the migration, the queries, and the actor model in Go. 00006_audit_events.sql → audit.sql → audit.go.
1. db/migrations/00006_audit_events.sql (new) — the audit_events table, its index, and the append-only enforcement: a plpgsql function that RAISEs, and triggers wiring it to BEFORE UPDATE OR DELETE (per row) and BEFORE TRUNCATE (per statement). Note seq BIGINT GENERATED ALWAYS AS IDENTITY and the FK-free document_id / actor_* columns.
2. internal/db/queries/audit.sql (new) — two queries: CreateAuditEvent (insert one row) and ListAuditEventsByDocument (ORDER BY seq). Run sqlc generate.
3. internal/handlers/audit.go (new) — the Go side of the actor model:
- the
evt*event-type constants (one per state change), auditActorand its constructorsactorUser/actorSigner/actorSystem/actorAnonymous— each setsactor_typeand the frozenLabel,audit(ctx, q, docID, type, message, actor)— writes one row. It takes a*db.Queries, so you pass a transaction-boundq(viaWithTx) to record the event atomically with the state change, orh.Queriesfor best-effort read-path events,auditBestEffort— for page-view events that must never fail the user's request (it logs on error instead of returning it).
func (h *Handlers) audit(ctx context.Context, q *db.Queries, docID pgtype.UUID, eventType, message string, a auditActor) error {
return q.CreateAuditEvent(ctx, db.CreateAuditEventParams{
DocumentID: docID, EventType: eventType, Message: message,
ActorType: a.Type, ActorUserID: a.UserID, ActorSignerID: a.SignerID, ActorLabel: a.Label,
})
}
Verify — reseed to run the migration, then try to break the append-only rule directly in psql:
$ psql "$DATABASE_URL" -c "insert into audit_events (document_id, event_type, message, actor_type, actor_label)
values (gen_random_uuid(), 'test', 'hi', 'system', 'system');"
INSERT 0 1
$ psql "$DATABASE_URL" -c "update audit_events set message='changed';"
ERROR: audit_events is append-only: UPDATE is not permitted
$ psql "$DATABASE_URL" -c "delete from audit_events;"
ERROR: audit_events is append-only: DELETE is not permitted
Inserting works; updating and deleting are refused by the database, loudly — not by application code you have to remember to write. That is the guarantee. And notice you now cannot delete your own test row — which is exactly the point. To clear it, run the migration's down (which drops the whole table) and migrate up again.