> ## Documentation Index
> Fetch the complete documentation index at: https://midplane.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Audit events

> The audit event types, the common columns, and the per-event payload shapes Midplane writes for every query.

Every query the agent sends produces a sequence of audit events, written **before** the query runs. The audit log is append-only: local SQLite (`/data/audit.db`) for self-host, indexed Postgres for Cloud. The row shape is identical on both. See the [audit trail](/docs/concepts/audit-trail) concept for the model and how to read it.

## Event types

A single query emits two or three events, in a fixed order. A standalone fifth type records a policy hot-swap.

| Event type        | When                                                         | Always written?                         |
| ----------------- | ------------------------------------------------------------ | --------------------------------------- |
| `ATTEMPTED`       | The agent's intent and SQL are recorded, before policy runs. | Yes — written first.                    |
| `DECIDED`         | The `ALLOW` / `DENY` decision, before execution.             | Yes.                                    |
| `EXECUTED`        | The query ran successfully.                                  | Only on `ALLOW` + successful execution. |
| `FAILED`          | The query was allowed but the database rejected it.          | Only on `ALLOW` + execution error.      |
| `POLICY_RELOADED` | The in-memory policy was hot-swapped via the admin endpoint. | Standalone; not tied to a query.        |

The pipeline order is non-negotiable: parse → policy → audit (`ATTEMPTED` + `DECIDED`) → execute → audit (`EXECUTED` | `FAILED`). If the `ATTEMPTED` + `DECIDED` write fails, the query does **not** execute — no query reaches the database unaudited.

## Common columns

Every audit row carries the same columns. `payload` is JSON whose shape is keyed by `event_type`.

<ResponseField name="id" type="string (ULID)">
  Primary key. Sortable by creation time.
</ResponseField>

<ResponseField name="query_id" type="string (ULID)">
  Groups all events of one query. For `POLICY_RELOADED`, a synthetic ULID.
</ResponseField>

<ResponseField name="tenant_id" type="string">
  `__self_host__` for OSS; the customer ULID for Cloud.
</ResponseField>

<ResponseField name="database" type="string">
  The database the event applied to — a `databases[]` name, or `__default__` for the legacy single-database path.
</ResponseField>

<ResponseField name="agent_name" type="string | null">
  The MCP `clientInfo.name` (e.g. `claude-code`). `null` for non-MCP callers and on `POLICY_RELOADED`.
</ResponseField>

<ResponseField name="agent_version" type="string | null">
  The MCP `clientInfo.version` (e.g. `0.42.1`). `null` for non-MCP callers and on `POLICY_RELOADED`.
</ResponseField>

<ResponseField name="agent_intent" type="string | null">
  The per-call task description from the `query` tool's required `intent` argument (≤ 500 chars). `null` on the schema-browsing tools (`list_tables`, `describe_table`) and on `POLICY_RELOADED`.
</ResponseField>

<ResponseField name="mcp_token_id" type="string | null">
  Cloud-injected ULID identifying the MCP token that opened the session (from the `X-Midplane-Token-Id` header at MCP `initialize`). `null` on self-host, on a missing or malformed header, on non-MCP callers, and on `POLICY_RELOADED`. Added in `0.6.0` (additive-nullable).
</ResponseField>

<ResponseField name="ts" type="integer">
  Milliseconds since the Unix epoch.
</ResponseField>

<ResponseField name="event_type" type="string">
  One of `ATTEMPTED`, `DECIDED`, `EXECUTED`, `FAILED`, `POLICY_RELOADED`.
</ResponseField>

<ResponseField name="payload" type="JSON">
  Event-specific payload — see below.
</ResponseField>

<ResponseField name="schema_version" type="integer">
  Row-shape version. Currently `3`.
</ResponseField>

## Payload shapes

### `ATTEMPTED`

The intent record — written before policy, never redactable afterward.

<ResponseField name="sql_raw" type="string">
  The raw SQL, capped at 1 MiB (longer is denied as `parse_error` at the decision).
</ResponseField>

<ResponseField name="sql_fingerprint" type="string (16 hex)">
  A stable fingerprint of the normalized AST — the same for "the same query intent" with different literals.
</ResponseField>

```json theme={null}
{
  "sql_raw": "SELECT * FROM users WHERE id = 42",
  "sql_fingerprint": "a1b2c3d4e5f60718"
}
```

### `DECIDED`

The policy decision. Always written; captures **why** on a denial. The optional `dialect` field (`0.7.0+`) names the dialect the query was parsed under — currently always `postgres`.

**Allowed:**

<ResponseField name="decision" type="&#x22;ALLOW&#x22;">
  The decision.
</ResponseField>

<ResponseField name="statement_type" type="string">
  `SELECT`, `INSERT`, `UPDATE`, etc.
</ResponseField>

<ResponseField name="tables_touched" type="string[]">
  Schema-qualified, deduped table names (≤ 64).
</ResponseField>

<ResponseField name="dialect" type="string (optional)">
  The dialect the query was parsed under. Currently always `postgres`.
</ResponseField>

```json theme={null}
{
  "decision": "ALLOW",
  "statement_type": "SELECT",
  "tables_touched": ["public.users"],
  "dialect": "postgres"
}
```

**Denied:**

<ResponseField name="decision" type="&#x22;DENY&#x22;">
  The decision.
</ResponseField>

<ResponseField name="policy_rule" type="string">
  The rule that denied — `table_access`, `multi_statement`, `dangerous_statement`, or `parse_error`.
</ResponseField>

<ResponseField name="reason" type="string">
  Human-readable, surfaced to the agent.
</ResponseField>

<ResponseField name="statement_type" type="string (optional)">
  Present when parsing succeeded.
</ResponseField>

<ResponseField name="tables_touched" type="string[] (optional)">
  Present when parsing succeeded.
</ResponseField>

<ResponseField name="dialect" type="string (optional)">
  The parse dialect.
</ResponseField>

```json theme={null}
{
  "decision": "DENY",
  "policy_rule": "table_access",
  "reason": "Midplane denied this query because writes to table `users` are not allowed by the table-access policy (`users` resolves to `read`; mark it `read_write` in your MIDPLANE_POLICY_FILE to grant writes).",
  "statement_type": "DELETE",
  "tables_touched": ["public.users"],
  "dialect": "postgres"
}
```

### `EXECUTED`

Written on a successful run. Captures timing and the side-effect summary.

<ResponseField name="exec_ms" type="integer">
  Database execution time, in milliseconds.
</ResponseField>

<ResponseField name="overhead_ms" type="integer">
  Midplane-added latency (parse + policy + audit + net), in milliseconds.
</ResponseField>

<ResponseField name="rows_affected" type="integer (optional)">
  For `INSERT` / `UPDATE` / `DELETE`.
</ResponseField>

<ResponseField name="rows_returned" type="integer (optional)">
  For `SELECT`.
</ResponseField>

<ResponseField name="columns_masked" type="string[] (optional)">
  The `schema.table.column` names that [column masking](/docs/concepts/masking) transformed — column names only, never masked values. Present when a mask applied; added in `0.12.0` (additive-nullable).
</ResponseField>

<ResponseField name="masking_path" type="string (optional)">
  How masking was applied: `source_rewrite` (the query was rewritten to redact the column at the source) or `post_exec` (the earlier post-execution masker). Present when a mask applied; `0.14.0+`.
</ResponseField>

<ResponseField name="masking_rejected" type="boolean (optional)">
  `true` when the post-execution masker ran the query but withheld every row because it couldn't safely mask the result set (the fail-closed [`column_masking`](/docs/reference/denial-reasons) reject). Only the `post_exec` path produces this; under `source_rewrite` a masking reject happens **before** execution and is written as a [`FAILED`](#failed) event instead. `0.12.0+`.
</ResponseField>

<ResponseField name="masking_reason" type="string (optional)">
  Human-readable explanation of why the result couldn't be masked, surfaced to the agent. Present alongside `masking_rejected`; `0.12.0+`.
</ResponseField>

```json theme={null}
{ "exec_ms": 4, "overhead_ms": 2, "rows_returned": 1, "columns_masked": ["public.users.email", "public.users.ssn"] }
```

### `FAILED`

Written when an allowed query was rejected by the database.

<ResponseField name="exec_ms" type="integer">
  Time spent before the error, in milliseconds.
</ResponseField>

<ResponseField name="overhead_ms" type="integer">
  Midplane-added latency, in milliseconds.
</ResponseField>

<ResponseField name="error_class" type="string">
  The database SQLSTATE class (e.g. `42P01`), or `column_masking` when a [source-rewrite](/docs/concepts/masking) mask couldn't be applied and the query was denied before it ran (`0.14.0+`).
</ResponseField>

<ResponseField name="error_message" type="string">
  The error message, truncated at 4096 characters.
</ResponseField>

<ResponseField name="masking_stage" type="string (optional)">
  On a `column_masking` failure, which stage of the rewrite failed closed: `gate`, `salt`, `shadow`, or `rewrite`. `0.14.0+`.
</ResponseField>

```json theme={null}
{ "exec_ms": 1, "overhead_ms": 2, "error_class": "42P01", "error_message": "relation \"userz\" does not exist" }
```

### `POLICY_RELOADED`

Written when the in-memory policy is hot-swapped via [`POST /admin/policy`](/docs/reference/mcp-tools#http-endpoints). Not tied to a query; `agent_name`, `agent_version`, `agent_intent`, and `mcp_token_id` are always `null`.

<ResponseField name="source" type="string">
  Where the swap came from — `admin_endpoint` today.
</ResponseField>

<ResponseField name="table_access" type="object | null">
  The new `table_access` (`{ default, tables }`), or `null` when the swap didn't carry one.
</ResponseField>

```json theme={null}
{
  "source": "admin_endpoint",
  "table_access": { "default": "read", "tables": { "feature_flags": "read_write" } }
}
```

## Indexes

The local SQLite table is indexed for the common read patterns:

| Index                 | Columns                 |
| --------------------- | ----------------------- |
| `idx_audit_query_id`  | `query_id`              |
| `idx_audit_tenant_ts` | `tenant_id`, `ts DESC`  |
| `idx_audit_type_ts`   | `event_type`, `ts DESC` |
| `idx_audit_db_ts`     | `database`, `ts DESC`   |

The table runs in WAL mode (`journal_mode = WAL`, `synchronous = NORMAL`) so reads don't block the single audit writer.

## `schema_version`

The current `schema_version` is `3`. New fields are added additively — nullable, and ignored by older readers — without bumping `schema_version`, so an indexer pinned to `3` keeps working as fields are added.

## Related

<Columns cols={2}>
  <Card title="Audit CLI" icon="terminal-square" href="/docs/reference/cli">
    `midplane audit tail` / `since` / `stats`.
  </Card>

  <Card title="HTTP endpoints" icon="network" href="/docs/reference/mcp-tools#http-endpoints">
    The pull endpoints an external indexer reads these from.
  </Card>
</Columns>
