ADR-0002: PostgreSQL AFTER triggers for audit trails

Status

Accepted

Tags

database, postgresql, audit, compliance, triggers, leave

Decision

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

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

Single shared trigger function 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 TRIGGER pair in createAuditTriggers() in migrator.go
  • Use CREATE OR REPLACE FUNCTION for the trigger function (idempotent)
  • Use DROP TRIGGER IF EXISTS + CREATE TRIGGER for per-table triggers (idempotent; CREATE TRIGGER IF NOT EXISTS requires PG 17+)
  • Do not add old_data/new_data filtering 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'

Rollback

No application code changes required — zero-downtime rollback.