ArvexiBuilders Blog

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.

REQUEST SOURCESSECURITY PIPELINEAUDIT SINKSWeb UICookie sessionAI AgentTool executionAdmin PortalStep-up authMiddlewareHostname gate + IP allowlistAuthwithAuth / withSuperAdminRBACrequireRole decoratorRate LimitOrg-scoped throttleError HandlerwithErrorHandler wrapperAuditLogOrg-scoped · 15 fieldsAdminAuditLogGlobal · 7 fieldsPostgreSQLappend-only triggers

Click any element to explore the security architecture


AI changes the accountability model

AI breaks the traditional audit model in three ways:

  1. Agency: The AI agent decides which tools to call. A user says "classify all my unclassified leases" and the agent runs classify_lease twelve times. Who made those twelve decisions?

  2. 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.

  3. 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.

Application LayerauditLogService.logChange() — INSERT only API
ORM LayerPrisma schema — no update/delete methods exposed
Database LayerPostgreSQL BEFORE triggers — RAISE EXCEPTION on UPDATE or DELETE

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 & APIAI Agent
RoleManage UsersEdit LeasesApprove/PostView Audit TrailManage Settingsclassify_leasegenerate_journal_entriesrun_match/analysisRead-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.

Layer 1: Hostname Gate
Request to /admin on app.arvexi.com → 404
404
Layer 2: IP Allowlist
Source IP not in CIDR allowlist → 404
404
Layer 3: Super Admin Check
user.isSuperAdmin ≠ true → 404 (not 403)
404
Layer 4: HMAC Step-Up Auth
No valid admin session cookie → 401
30-min TTL · timingSafeEqual · httpOnly
401
Admin Handler
Rate limited to 30 req/min

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

ComponentImplementationKey metric
Append-only trailPostgreSQL BEFORE triggers0 UPDATE/DELETE possible
Dual-tier modelAuditLog (15 fields) + AdminAuditLog (7 fields)2 tables, 5 indexes
Compliance tagging87 actions → 13 categoriesSingle-query SOX filtering
RBAC5 roles, 9 permissions, requireRole() decoratorSame matrix in UI, API, AI
Admin security4 layers: hostname, IP, isSuperAdmin, HMAC30-min TTL, timingSafeEqual
AI auditingPer-tool-call entries with full I/OEvery 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.