ADR-0002: PostgreSQL AFTER triggers for audit trails
Status
AcceptedTags
database, postgresql, audit, compliance, triggers, leaveDecision
Use PostgreSQL AFTER triggers to capture row-level change history for compliance-sensitive tables. Phase 1 scope:leave_requests, leave_balances, leave_types.
Why
After migrating from MongoDB to PostgreSQL, the leave module had business-level audit logging (“who did what?”) but no database-level change tracking (“what changed in this row?”). Compliance teams need queryable before/after snapshots without parsing logs or rebuilding history from application events. AFTER triggers fire in the same transaction — if the write rolls back, the audit entry rolls back too. No application code changes required; the trigger fires transparently on every INSERT/UPDATE/DELETE.Enabled approach
| Approach | Atomic? | Queryable? | App changes? | Choice |
|---|---|---|---|---|
| AFTER triggers | Yes | Yes (table) | None | Chosen |
| pgaudit | No (async logs) | Files only | Requires log shipper | Rejected |
| Application code | Yes | Yes (table) | Every use case | Rejected |
pgaudit rejected because it requires shared_preload_libraries restart and ships logs to files.
Application code rejected because it requires instrumentation at every call site — error-prone.
Schema
fn_audit_trail() using TG_TABLE_NAME and to_jsonb(OLD/NEW) — one function covers all tables, no per-table duplication.
Rules for agents
- Never instrument application code to write audit trail entries — the trigger handles it automatically
- New tables requiring audit coverage: add a
DROP TRIGGER IF EXISTS+CREATE TRIGGERpair increateAuditTriggers()inmigrator.go - Use
CREATE OR REPLACE FUNCTIONfor the trigger function (idempotent) - Use
DROP TRIGGER IF EXISTS+CREATE TRIGGERfor per-table triggers (idempotent;CREATE TRIGGER IF NOT EXISTSrequires PG 17+) - Do not add
old_data/new_datafiltering in Phase 1 — full JSONB snapshots are correct - Audit trails are append-only — never UPDATE or DELETE rows from
audit_trails
Bad pattern (do not generate)
Good pattern
Phase scope
Phase 1 (done):leave_requests, leave_balances, leave_types — sync AFTER triggers.
Phase 2 (future): High-volume tables (orders, customers, products) — async via outbox pattern to avoid write amplification at scale.
Phase 3 (future): REST query endpoint GET /api/audit-trails?table=...&row_id=...&from=...&to=...
Phase 4 (future): Retention policy — DELETE FROM audit_trails WHERE changed_at < NOW() - INTERVAL '5 years'