ADR-0064: DB-enforced audit_events immutability (retention-first)
Status: Accepted Date: 2026-06-16 Supersedes: none Superseded by: none Deciders: Adrian (Soft4U), Claude Opus 4.8
Decision context:
- Latency: a per-row BEFORE UPDATE/DELETE trigger check (negligible) + one FK swap; an audit-count pre-check on the rarely-used hard-delete endpoint.
- Dependency surface: no new packages. Migration 066 (trigger + function + REVOKE + FK alter); a 409
guard in
delete_case. - Debuggability: tampering attempts raise a clear, regulation-citing exception; case-delete blocks with a clear 409.
- Reversibility:
alembic downgrade -1drops the trigger/function and restores the CASCADE FK. - Blast radius: the hard-delete-case endpoint now 409s for cases with audit history (deliberate); normal ops + INSERT unaffected.
- Alternative considered: REVOKE-only / trigger-only / GUC-gated purge (see Alternatives).
Context
audit_events was append-only by convention only — no REVOKE, no trigger; trustrelay_app held
UPDATE/DELETE grants. AMLR Art. 77 (5-year record-keeping) + EU AI Act Art. 12 require a tamper-evident
audit log. A complication: migration 042 made the cases → audit_events FK ON DELETE CASCADE, and a
live DELETE /cases/{workflow_id} endpoint relies on it — so a naive DELETE-blocking trigger breaks
case deletion mid-cascade.
Decision (retention-first)
The audit trail is immutable and retained:
- Trigger
audit_events_immutable()—BEFORE UPDATE OR DELETEonaudit_events, unconditionalRAISE EXCEPTION(fires for ALL roles incl. superuser). - REVOKE UPDATE, DELETE on
audit_eventsfromtrustrelay_app(least-privilege; guarded for a missing role in CI/local). - FK CASCADE → RESTRICT (
cases → audit_events): a case with audit history cannot be hard-deleted; the audit trail is retained. This makes the schema itself express the retention intent and yields a clean FK error rather than a confusing mid-cascade trigger error. - Graceful endpoint:
delete_casepre-checks for audit rows and returns 409 with a clear retention message (before terminating the workflow / purging MinIO — so a blocked delete is atomic), instead of a raw 500. Cases with history are closed/soft-deleted, not erased. - Migration DDL runs under the migration/superuser URL (ADR-0050). A deliberate correction would require a superuser to drop the trigger — itself a traceable action.
Consequences
Positive
- The audit log is tamper-evident and retained at the DB layer — defence-in-depth beyond app discipline; satisfies AMLR Art. 77 / EU AI Act Art. 12. Aligns with AML records being exempt from GDPR erasure.
Negative
- Hard-deleting a case with audit history is no longer possible (409) — intended; consumers must soft-delete/close. A legitimate audit correction requires a deliberate superuser action.
Neutral
- INSERT (append) is unaffected. The stretch before/after-diff schema on
detailsis deferred.
Alternatives Considered
Alternative 1: REVOKE-only (no trigger)
Rejected — a superuser/DBA or a mis-granted role could still UPDATE/DELETE; the trigger catches everyone.
Alternative 2: Trigger-only (no REVOKE)
Rejected — least-privilege matters; the app role should not even hold the grant.
Alternative 3: GUC-gated purge (allow DELETE when an app.allow_audit_purge GUC is set)
Rejected — preserves the hard-delete endpoint but weakens the immutability guarantee and contradicts AMLR retention (the audit trail should survive case deletion). Retention-first (FK RESTRICT + 409) is the compliant posture; cases are soft-deleted, audit history is kept.