Building an Append-Only Audit Trail for Financial AI
When we started building Arvexi, a lease accounting platform where AI classifies leases, generates journal entries, and computes present-value amortization schedules, we quickly realized that the biggest engineering challenge was not the AI itself. It was trust.
When an AI agent generates 200 journal entries at 2 AM because a user asked it to "process the Q1 roll-forward," auditors need to see exactly what the AI did, when it did it, why it was triggered, and who authorized the action.
Click any element to explore the security architecture
AI changes the accountability model
AI breaks the traditional audit model in three ways:
-
Agency: The AI agent decides which tools to call. A user says "classify all my unclassified leases" and the agent runs
classify_leasetwelve times. Who made those twelve decisions? -
Judgment: When the AI estimates an incremental borrowing rate or assesses a lease modification, it is making an accounting judgment. Auditors under ASC 842 need to trace every judgment back to supporting evidence.
-
Batch operations: A single conversational prompt can trigger dozens of mutations. Each mutation needs its own audit entry, not a single "AI did some stuff" record.
The append-only audit trail
The most important property of our audit log is that it cannot be edited or deleted. Not at the application layer. Not by an admin. Not by a database migration. At the PostgreSQL level.
CREATE OR REPLACE FUNCTION prevent_audit_log_mutation()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION
'Audit log records are immutable: % operations are not allowed on %',
TG_OP, TG_TABLE_NAME;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Applied to both audit_logs and admin_audit_logs
CREATE TRIGGER audit_logs_prevent_update
BEFORE UPDATE ON "audit_logs"
FOR EACH ROW
EXECUTE FUNCTION prevent_audit_log_mutation();
CREATE TRIGGER audit_logs_prevent_delete
BEFORE DELETE ON "audit_logs"
FOR EACH ROW
EXECUTE FUNCTION prevent_audit_log_mutation();This is a BEFORE trigger, not an AFTER trigger. The row never gets modified. Any UPDATE or DELETE statement (from Prisma, raw SQL, psql shell, or a migration script) raises an exception. The trigger is unconditional. There is no test-mode escape hatch.
Watch INSERT, UPDATE, and DELETE operations hit the enforcement layers
The dual-tier audit model
We maintain two separate audit tables. They serve different audiences and different compliance requirements.
AuditLog is org-scoped. Every tenant has their own audit trail, isolated by organizationId. It captures 15 fields per entry: user identity, entity type/ID, action, before/after JSON state, IP address, API route, reason, source document, context (web_ui, api, csv_import, automation, impersonation), and accounting period (YYYY-MM).
The context field matters for SOX: a change made during admin impersonation has different control implications than one made through the normal UI. The accountingPeriod field enables queries like "show me everything that changed in the March 2026 close" without date arithmetic.
AdminAuditLog is global. No organizationId. It tracks super-admin actions across the platform: tenant suspension, user disablement, feature flag changes, impersonation events, deal approvals. A tenant admin exporting their audit log for SOX compliance never sees cross-tenant operations.
Both tables are protected by the same prevent_audit_log_mutation() trigger. Both are append-only.
Compliance tagging
We have 87 distinct action types. An auditor looking at raw action strings like CLASSIFY, GENERATE_SCHEDULES, or IMPERSONATION_STARTED needs to filter by regulatory category, not by action name.
87 action types · 13 compliance categories · single-query SOX filtering
Click any category to see its audit actions
The Judgement category is particularly important for lease accounting. ASC 842 requires entities to make significant estimates: the incremental borrowing rate, whether a purchase option is reasonably certain, lease-term assessments. When our AI agent calls ESTIMATE_IBR or CLASSIFY, those entries carry the Judgement compliance tag. An auditor can pull every AI-driven judgment in a single query.
Judgement Override is separate from Judgement. If a user overrides an AI classification, that gets tagged differently because it represents a human decision to contradict an automated assessment. This distinction matters for controls testing.
Role-based access control
Five roles, nine permissions, enforced consistently from the UI through the API to the AI agent's tool permissions.
| Web & API | AI Agent | ||||||||
|---|---|---|---|---|---|---|---|---|---|
| Role | Manage Users | Edit Leases | Approve/Post | View Audit Trail | Manage Settings | classify_lease | generate_journal_entries | run_match/analysis | Read-only tools |
| ADMINFull platform access | |||||||||
| CONTROLLERFinancial operations lead | |||||||||
| ACCOUNTANTDay-to-day operations | |||||||||
| AUDITORRead-only access | |||||||||
Click any role to see its full permission profile
The same RBAC model extends into the AI agent. When an AUDITOR uses the AI workspace, the agent can search leases, retrieve details, and generate analysis reports. But any attempt to call a mutation tool is blocked before execution with a structured explanation: "The classify_lease tool requires: Admin or Controller. Your current role (Auditor) has read-only access to all data."
Admin portal security
The super admin portal runs on admin.arvexi.com with four independent security layers. Compromising one is not enough.
Click any layer for security details
Why 404 instead of 403 for the first three layers? A 403 confirms the route exists and tells an attacker they need higher privileges. A 404 reveals nothing. From the perspective of any non-super-admin, the admin API does not exist.
The fourth layer produces an HMAC-SHA256 signed cookie (httpOnly, secure, sameSite: strict, 30-minute TTL). Verification uses timingSafeEqual to prevent timing attacks. If someone steals the cookie and tries to use it with a different Supabase session, the UID mismatch check rejects it.
AI action auditing
Every mutation the AI agent performs is individually audit-logged. After the agent completes, we iterate over all tool calls and create an audit entry for each mutation:
Each audit entry captures the tool name, full input parameters, success/failure status, user role, and a triggeredVia: "ai_assistant" marker. This distinguishes between a user clicking "Classify" in the UI and the AI classifying a lease from a chat command. Same outcome, different provenance. Both fully traced.
The numbers
| Component | Implementation | Key metric |
|---|---|---|
| Append-only trail | PostgreSQL BEFORE triggers | 0 UPDATE/DELETE possible |
| Dual-tier model | AuditLog (15 fields) + AdminAuditLog (7 fields) | 2 tables, 5 indexes |
| Compliance tagging | 87 actions → 13 categories | Single-query SOX filtering |
| RBAC | 5 roles, 9 permissions, requireRole() decorator | Same matrix in UI, API, AI |
| Admin security | 4 layers: hostname, IP, isSuperAdmin, HMAC | 30-min TTL, timingSafeEqual |
| AI auditing | Per-tool-call entries with full I/O | Every AI mutation individually traced |
The bar
When an auditor opens the audit trail and filters by AI Processing actions in Q1 2026, they see every lease the AI classified, every journal entry it generated, every judgment it made. They see who authorized the conversation, what role they had, whether they were being impersonated, and what the before/after state was for every change.
If your AI makes accounting decisions, your audit trail needs to prove it.
See Intelligence to learn more about these capabilities.