Audit Log
Every gateway call_tool request made with a valid agent-scoped token — regardless of decision — produces an immutable audit record in PostgreSQL, including malformed requests and calls to a server that is not registered or not active. The record is queued to a local write-ahead log before the response is returned to the agent and shipped to PostgreSQL within a fraction of a second (a review decision is written to PostgreSQL synchronously). There are no gaps in the trail.
Audit event schema
The audit_events table. Fields a compliance engineer will care about:
| Column | Type | Description |
|---|---|---|
id | UUID | Immutable event identifier |
session_id | UUID | Groups all tool calls within one agent run |
sequence_number | integer | Call order within the session, as supplied by the caller (0 on a malformed request that carried none) |
agent_id | UUID | FK to the agents table |
agent_name | string | Denormalized agent name — queryable without a join |
tool_name | string | The tool that was called |
tool_parameters | JSONB | Full parameters at time of call. For http_get, http_post, http_put, http_delete and http_patch, a domain key extracted from url is added |
decision | string | allow, deny, review, or error (the downstream server returned a JSON-RPC error) |
decision_reason | text | Why this decision was made |
policy_id | UUID | FK to the policy that fired; null when no policy matched |
policy_name | string | Denormalized policy name — queryable without a join |
workflow | string | The business-process workflow the call belongs to, as reported by the SDK adapter. Context for grouping the trail, not a binding axis |
duration_ms | integer | AIControl gateway latency in milliseconds |
input_tokens / output_tokens / cost_usd | integer / integer / numeric | Usage the agent reported on the call, if any |
risk_delta | integer | Amount this event added to the session's risk score |
enforced | boolean | false when the agent is in observe mode and the decision was recorded but not applied |
bypass | boolean | Reserved; always false |
created_at | timestamp | Server-set, immutable |
tool_response | JSONB | Reserved for the raw tool response (the API returns it truncated to 200 characters). Always null via the MCP gateway path — the gateway does not currently record it. |
tool_parameters is stored as JSONB — it supports indexed queries on specific parameter values.
policy_id and policy_name are null when no policy fired. When a policy fires, both are populated.
The dashboard and GET /audit-events also return a nested policy object (name, scope, effect) alongside each event when one fired, and can group the log view by workflow or by session.
Decision reasons
decision_reason | Meaning |
|---|---|
response_scan_clean | The call was allowed, forwarded, and the downstream response passed the scan. This is the reason on a normal allowed gateway call, including one that matched no policy |
no_matching_policy | No policy matched — the engine's default allow applies (the gateway records the scan outcome instead once the call has been forwarded) |
server_approved_tools_gate | The registered server has an approved_tools list and the tool isn't on it. Checked first |
tool_not_approved_for_agent | The agent's approved-tools allowlist didn't include this tool. Checked before any policy is evaluated |
policy_matched:<policy_name> | A policy matched. decision (deny or review) and policy_name tell you the rest — there's no separate reason code per condition type |
review_requires_business_license: <reason> | A policy routed the call to review on a Community install, where it is enforced as deny |
hitl_approved_retry | An identical call was approved by a reviewer within the retry window, so the retry is allowed |
response_scan_flagged:<categories> | The downstream response was flagged. decision is deny with MCP_RESPONSE_SCAN_POLICY=block, allow with log |
upstream_unreachable, upstream_http_error:<status>, upstream_invalid_response | The call was allowed but the downstream server failed; decision is allow |
upstream_jsonrpc_error:<code> | The downstream server returned a JSON-RPC error; decision is error |
malformed_request:<detail> | Missing or invalid name, arguments, session_id or sequence_number, or an unavailable server. decision is deny |
evaluation_error:* | The policy engine failed to evaluate the call. Always resolves to deny |
evaluation_pipeline_error:<exception> | Loading policies or usage counts failed. Always resolves to deny |
Viewing the audit log
Open the Audit log page in the React dashboard at http://localhost:3000. You can filter by agent, decision, and tool name, and group the log by workflow or by session. Each row shows the session, tool, decision, reason, policy, and latency.
For programmatic access, query the audit_events table directly:
-- All deny events in the last 7 days
SELECT
created_at,
agent_name,
tool_name,
tool_parameters,
decision_reason,
policy_name
FROM audit_events
WHERE decision = 'deny'
AND created_at > now() - interval '7 days'
ORDER BY created_at DESC;-- All events for a specific session
SELECT
sequence_number,
tool_name,
tool_parameters,
decision,
decision_reason,
duration_ms
FROM audit_events
WHERE session_id = 'b3e1f9a2-...'
ORDER BY sequence_number;-- Deny events by policy, for SOC2 compliance export
SELECT
ae.created_at,
ae.agent_name,
ae.tool_name,
ae.tool_parameters,
ae.decision_reason,
p.name AS policy_name,
p.compliance_frameworks
FROM audit_events ae
JOIN policies p ON ae.policy_id = p.id
WHERE ae.decision = 'deny'
AND 'SOC2' = ANY(p.compliance_frameworks)
ORDER BY ae.created_at DESC;What is and isn't stored
Stored in every audit event:
- Full tool parameters as submitted by the agent (JSONB)
- The policy that fired (
policy_idandpolicy_name); its compliance framework tags are on the policy row - Decision and reason
- Intercept latency
Not stored:
- Tool execution results — AIControl governs the call, not the response
- LLM prompt or completion content — AIControl operates at the tool layer only
- Agent-to-agent communication — only tool calls that pass through the gateway are recorded
Immutability
Audit records are append-only. The API and dashboard have no delete or update endpoint for audit_events. The created_at timestamp is server-set and not modifiable by clients.
If you need to enforce immutability at the database level, configure your PostgreSQL user to have INSERT-only access to audit_events and remove UPDATE/DELETE grants.
Retention
AIControl runs a scheduled background purge that deletes audit_events (and any hitl_reviews referencing them) once they're older than your plan's retention window:
| Plan | Retention window |
|---|---|
| Community | 7 days |
| Business | 30 days |
| Enterprise | 90 days |
| Trial | 90 days |
The purge is skipped, and the install reports itself degraded in the logs, when a stored activation code is expired or fails verification, or when a legacy paid install has no signed code yet: deleting is irreversible, so an install is not purged at the Community window just because its code lapsed. Enter a renewed code and the next run purges at your plan's window. Until then, the dashboard and GET /audit-events only show the last 7 days.
The purge deletes in batches of 5,000 rows, so a first run on a large install completes without holding a long lock. It runs on a fixed interval (retention_purge_interval_hours, default 24 hours) — it is not manual and is not something you need to schedule yourself. This is the one sanctioned exception to audit_events being otherwise append-only: no other code path deletes or updates a row in it.
For regulated industries, most customers retain audit events for the duration required by their applicable framework (SOC2: 1 year, GDPR: varies by purpose, HIPAA: 6 years) — all of these exceed AIControl's built-in retention windows, so customers subject to those frameworks must archive audit events elsewhere before they age out.
Backup
The audit log lives in the aicontrol PostgreSQL database. Back it up using standard PostgreSQL tooling:
# Dump the audit_events table
docker compose exec postgres pg_dump \
-U aicontrol \
-t audit_events \
aicontrol > audit_events_$(date +%Y%m%d).sqlFor production deployments, configure continuous WAL archiving or use a managed PostgreSQL service with automated backups.
CSV Export (Enterprise)
Enterprise installations can export filtered audit events as a CSV file. The export honours the same filters as the list endpoint.
Via the dashboard: Open the Audit log page, apply any filters, and click Export. The file downloads immediately.
Via the API:
GET /audit-events/export
Authorization: Bearer <human-token>Requires a human token (a dashboard login). Query parameters (all optional):
| Parameter | Description |
|---|---|
decision | Filter by allow, deny, review, or error |
agent_id | Filter by agent UUID |
tool_name | Case-insensitive substring match on the tool name |
date_from | ISO 8601 start date |
date_to | ISO 8601 end date |
Response: text/csv with Content-Disposition: attachment; filename=audit_events.csv
Columns: id, created_at, agent_name, tool_name, tool_parameters, decision, decision_reason, policy_name, session_id, duration_ms
Text fields that start with =, +, -, @, a tab, or a carriage return are prefixed with ' so a spreadsheet does not run agent-supplied content as a formula. agent_name, tool_name, tool_parameters, decision_reason and policy_name come from the calling agent or its tool arguments.
Maximum 10,000 rows per export. Returns 402 for community installations.
SIEM Export (Business)
Business and Enterprise installations can stream every audit event to an outbound SIEM target as it's written, instead of pulling a CSV on demand. Two target types are supported:
- OTLP — ships each event as an OTel GenAI semantic-convention log record to a collector at
target_url. - Webhook — POSTs each event as flat JSON to
target_url.
Delivery is checkpoint-on-success-only: a target that's unreachable or returns a non-2xx response doesn't advance its checkpoint, so the event is retried on the next dispatch rather than dropped. Configure targets via POST /audit-export-configs (admin only); see API Reference for the full schema. This code ships in the licensed API image only (see Getting Started); the endpoints return 402 there on a Community plan and do not exist in the community image.
This is separate from CSV Export above — SIEM export is a continuous push of every event to your own collector, CSV export is an on-demand pull of a filtered set.
Human review records
When a tool call returns decision: review on a Business or Enterprise plan, a corresponding record is created in the hitl_reviews table. Review records link to their audit event via audit_event_id.
Review records are accessible via GET /reviews and GET /reviews/{id}. See API Reference for the full schema.