Every AI control plane claims an audit log. Most of them are wrong.
The standard pitch is "every prompt, every response, every tool call is logged for the auditor." It is technically true. It is also strategically thin. The application code that writes the log is the same application code that — if it wanted to, if it had a bug, if it had an attacker — could rewrite the log. That's not a hypothetical: the OWASP Top 10 has had log injection in the top 25 for a decade, and the SOC 2 Type II auditor cares specifically about whether the integrity of audit records is enforced outside the application that wrote them.
Visionality enforces audit-row integrity at the database role layer. The application role our gateway runs as — acc_app — has REVOKE UPDATE, DELETE on five audit tables. Verified at deploy time. Migration 0015. The application code cannot rewrite the row even if it wanted to. The auditor reads the role grants, sees the REVOKE, and the conversation is over.
This article walks through what that looks like, why it's not standard practice, and how to verify it in five minutes.
The five tables and why they're enforced at the role
The tables under append-only enforcement are the ones a procurement-grade auditor cares about:
request_logs— every proxied AI call: org, project, model, cost, agent identity, binding statusmcp_authorize_audit— every PKCE authorize event: client, subject, MCP server, outcomemcp_invocations— every MCP tool call: server, tool, parameters hash, outcome, latencyspend_token_envelopes_v2— every binding-key envelope issuance + every rejectionhris_revocation_log— every HRIS-driven token revocation: leaver event, signed sub, timestamp
Each of these is the source of truth for an SOC 2 control (CC6.1 / CC6.6 / CC7.2 / CC7.3 / CC8.1) and for the ISO 27001 + ISO 42001 + NIST AI RMF collectors that derive from them. If the application could rewrite the row, the entire compliance posture rests on the application's good behavior — which is exactly the assumption SOC 2 Type II is auditing.
What the REVOKE looks like in production
Run migration 0015 against your Postgres instance and the role grants come out like this (simplified):
-- The app's database role for runtime traffic
CREATE ROLE acc_app NOINHERIT NOCREATEROLE NOCREATEDB NOLOGIN;
-- The app role can INSERT (write new audit rows) and SELECT (read for
-- the dashboard + the evidence pack collectors)
GRANT INSERT, SELECT ON request_logs TO acc_app;
GRANT INSERT, SELECT ON mcp_authorize_audit TO acc_app;
GRANT INSERT, SELECT ON mcp_invocations TO acc_app;
GRANT INSERT, SELECT ON spend_token_envelopes_v2 TO acc_app;
GRANT INSERT, SELECT ON hris_revocation_log TO acc_app;
-- And critically — UPDATE and DELETE are REVOKED
REVOKE UPDATE, DELETE ON request_logs FROM acc_app;
REVOKE UPDATE, DELETE ON mcp_authorize_audit FROM acc_app;
REVOKE UPDATE, DELETE ON mcp_invocations FROM acc_app;
REVOKE UPDATE, DELETE ON spend_token_envelopes_v2 FROM acc_app;
REVOKE UPDATE, DELETE ON hris_revocation_log FROM acc_app;
Now suppose an attacker compromises the application layer — they exploit a deserialization bug, they steal a session cookie that gets them admin in the dashboard, they find an SSRF that lets them issue arbitrary queries through the app's connection. They still cannot rewrite an audit row because the connection authenticated as acc_app does not have the privilege. The Postgres response is permission denied for relation request_logs.
A separate role exists for the operational migration path — acc_admin — which holds the full DDL grants and is used only at deploy time to apply migrations. That role's credentials are not in the application service's environment; they live in Render's secrets-only-on-deploy slot. An application-layer compromise does not reach them.
Why isn't this standard practice?
Because most AI control plane products treat the audit log as an application concern. The product ships with one database connection that has full grants — and the architectural decision was made at a point in the project's life where "the application won't write a buggy UPDATE statement" felt like enough. It often is enough for early product-market-fit. It is not enough for SOC 2 Type II, ISO 27001, ISO 42001, or any regulated-vertical procurement.
Splitting the role grants is not technically hard. It is a one-migration discipline. The reason it doesn't ship by default is that most products have not yet had the procurement-grade conversation that requires it.
How to verify it in five minutes
If you're evaluating Visionality, here's the verification procedure your security review can run:
- Pull the
packages/db/migrations/0015_revoke_app_role.sqlfile from the public repository (apache 2.0 LICENSE-ed; you can read every line). - After deployment, connect as the application role and run:
Expected output: rows forSELECT grantee, privilege_type FROM information_schema.role_table_grants WHERE grantee = 'acc_app' AND table_name = 'request_logs';INSERTandSELECTonly. NoUPDATE. NoDELETE. - Try to update a row:
UPDATE request_logs SET cost_usd = 0 WHERE id = '<any-id>'; -- ERROR: permission denied for relation request_logs
The deploy-smoke check that runs on every Render deployment runs exactly this verification automatically. If the REVOKE state drifts — if an operator runs an apt-get upgrade style command that resets grants, if a migration is mis-applied — the smoke check fails and the deploy is rolled back.
What this does for your continuous-evidence pack
Visionality's continuous-evidence pack collects 12 control evidences across SOC 2 + ISO 27001 + ISO 42001 + NIST AI RMF. The CC6.1 collector specifically reads the role grants and emits them as the evidence row:
{
"framework": "SOC2",
"control": "CC6.1",
"title": "Role grants on audit tables",
"status": "pass",
"detail": {
"role": "acc_app",
"granted_privileges": ["INSERT", "SELECT"],
"tables_under_enforcement": 5
}
}
The auditor reads the evidence row. They read the role grants directly. They see the SHA-256 fingerprint of the entire evidence pack. They run vis-verify pack.json offline to confirm the bundle wasn't tampered with in transit. The auditor does not have to trust our dashboard. They trust the spec.
The structural argument
Application-layer audit logs are technically true: rows are written, the application reads them back, the auditor sees them. They are also structurally thin: the assumption that the application won't rewrite the rows is the assumption being audited. Structural enforcement at the database role layer ends that argument. It is the same posture as enforcing append-only semantics with WORM storage in legacy finance — except built into the database the application already uses, with zero additional infrastructure cost.
Visionality is the only AI Control Plane in the competitive matrix today with this enforcement live. Every other player — Speakeasy, Truefoundry, Portkey/PANW, Helicone, Langfuse/ClickHouse, Aris, Runlayer, MintMCP, Lunar.dev, Fiddler AI, the FinOps adjacencies — runs the application as a single fully-privileged role.
If your procurement team is asking the structural-integrity question and your vendor can't answer it, you should be looking at us.
Book a 30-minute walkthrough and we'll show you the role grants live in your own deployment.