Phase 6: tamper-evident audit log with DB-trigger immutability
Archibald312 wrapped every LLM call and tool invocation in an append-only audit trail: UPDATE and DELETE on the `audit_log` table raise an exception at the database trigger level, not in application code. Before third-party data connectors arrive, the stated goal is a provable record of who saw what and which model handled it.
The migration in backend/migrations/audit_log.sql creates the table and installs two triggers (audit_log_no_update, audit_log_no_delete) that raise audit_log entries are immutable on any modification. REVOKE ALL ON public.audit_log FROM anon, authenticated means only the service role can write rows - a compromised app credential can append but not overwrite.
user_email is stored as a denormalized column alongside the user_id FK. If the user account is deleted and the FK nulls out, the email persists for forensic identification. Check this against your data retention policy before importing.
backend/src/lib/audit.ts defines the AuditEntry shape, a fire-and-forget recordAudit() insert with error swallowing, a hashContent() SHA-256 helper, and an AUDIT_LOG_ENABLED feature flag. The fire-and-forget design means audit failures are transparent to users. Whether that's acceptable depends on your compliance posture.
The tool dispatcher records one row per invocation with duration, hashed input/output payloads, resolved document IDs, and status. The LLM adapter records one row per outer streaming call - per outer call, not per provider iteration inside tool-use loops. Three entry points are instrumented: the main chat, project chat, and tabular review. A GET /audit-log route returns the caller's own rows with filters for project, event type, time range, and pagination. It is self-service, not admin-scoped.
The routing_policy_applied jsonb column is present but unpopulated until the model routing seam lands in the follow-on PR. No retention or rollup story is included.
Unit tests (95 lines) cover hashContent determinism, the insert shape, the feature-flag no-op path, and error swallowing.
Spotted something wrong? Or know the PR text has fresher detail than the writeup above?