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

# Denial reasons

> A catalog of Midplane's denial rules, the messages they produce, and what to change to resolve each one.

When Midplane denies a query, the [MCP tool result](/docs/reference/mcp-tools) and the [`DECIDED` audit row](/docs/reference/audit-events) both carry a `policy_rule` naming the rule that denied, plus a human-readable `reason`. This page maps each rule to the cause and the fix.

The engine evaluates four rules in order, and the **first** rule that denies wins. The order is `parse_error` → `multi_statement` → `table_access` → `dangerous_statement`. Two are always-on guards (`parse_error`, `multi_statement`); the rest are driven by your [policy](/docs/policies/overview) — `table_access` and the `dangerous_statement` guardrails default on. See [the policy engine](/docs/how-it-works).

| `policy_rule`         | Default       | Cause                                                                              |
| --------------------- | ------------- | ---------------------------------------------------------------------------------- |
| `parse_error`         | Always on     | The SQL didn't parse.                                                              |
| `multi_statement`     | Always on     | More than one statement in one query.                                              |
| `table_access`        | On (`read`)   | A read or write the table's access level doesn't grant.                            |
| `dangerous_statement` | On (`0.9.0+`) | A whole-table write (no `WHERE`) or a DDL statement, regardless of `table_access`. |

## `table_access`

A query referenced a table at a permission level its policy doesn't grant. A query is denied if **any** referenced table fails — including writes hidden in CTEs, subqueries, UNION arms, or JOINs, caught by the recursive AST walk.

There are three sub-cases:

| Cause                                                                                                                                         | Fix                                                                               |
| --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| **Write blocked** — a write (`INSERT` / `UPDATE` / `DELETE`) to a table that's `read`.                                                        | Mark the table `read_write` in [`table_access.tables`](/docs/reference/policy-schema). |
| **Read blocked** — a `SELECT` from a table that's `deny`.                                                                                     | Mark the table `read` (or `read_write`).                                          |
| **No target / unlisted** — the table falls through to a `default` that denies the access (`default: deny`, or a write under `default: read`). | Add a per-table override, or raise the `default` (carefully).                     |

```sql theme={null}
-- with table_access.tables.users = read:
DELETE FROM users WHERE id = 1     -- DENY (users is read; writes need read_write)
SELECT * FROM audit_log            -- DENY (audit_log is deny; no access at all)
```

To grant the write, mark the table `read_write`:

```yaml theme={null}
table_access:
  default: read
  tables:
    users: read_write
```

Run [`midplane policy test`](/docs/reference/cli) to confirm the change before you ship it.

## `multi_statement`

The query contained more than one top-level statement. Midplane allows exactly one statement per query — stacked statements (the classic SQL-injection vector) never reach the database.

```sql theme={null}
SELECT 1; DROP TABLE users;   -- DENY (2 statements)
SELECT 1;                     -- ALLOW (trailing semicolon, still 1 statement)
```

**Fix:** split the query into one statement per call. A trailing semicolon is fine; a second statement is not.

## `dangerous_statement`

A [guardrail](/docs/reference/policy-schema) denied a categorically-destructive operation — **regardless of `table_access`**. On by default (`0.9.0+`), so this fires even on a table you've marked `read_write`. Two independent guards produce it:

| Cause                                                                 | `policy_rule`         | Fix                                                                                                        |
| --------------------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------- |
| **Whole-table write** — a `DELETE` / `UPDATE` with no `WHERE` clause. | `dangerous_statement` | Add a `WHERE` that scopes the rows you intend to change, or set `guardrails.block_unqualified_dml: false`. |
| **Schema change (DDL)** — a `DROP`, `TRUNCATE`, or `ALTER`.           | `dangerous_statement` | Run the DDL out-of-band, or set `guardrails.block_ddl: false`.                                             |

```sql theme={null}
DELETE FROM orders            -- DENY (no WHERE — whole-table write)
DELETE FROM orders WHERE id=1 -- the WHERE makes it qualified (dangerous_statement passes)
DROP TABLE orders             -- DENY (DDL)
```

The agent-facing message names the operation and the flag to relax it, for example:

```
Midplane denied this query because this DELETE on `orders` has no WHERE clause,
which would affect every row in the table. Add a WHERE clause that scopes the rows
you intend to change. This guardrail blocks whole-table writes regardless of
table-access policy; set `guardrails.block_unqualified_dml: false` in your policy
YAML to disable it.
```

Because the guardrail runs **last**, a statement another rule would also deny (for example a `DROP` under the no-YAML deny-all-writes default) surfaces that other rule's reason instead — the statement is blocked either way. See the [`guardrails`](/docs/reference/policy-schema) schema.

## `parse_error`

The input failed to parse. `libpg_query`, the real Postgres parser, decides this — anything that isn't valid Postgres SQL is denied. Midplane never enforces policy on text it can't read.

**Fix:** rewrite the query as valid **Postgres** SQL. Midplane parses with the Postgres parser, so dialect-specific or exotic syntax it doesn't recognize is denied — rewrite it into a supported form.

## Result masking

`column_masking` is **not** one of the ordered policy rules above — those decide whether a query *runs*. It fires when the engine can't safely apply [column masking](/docs/concepts/masking) to a query that touches a masked table, failing closed: the query is denied **before it runs** and **all** rows withheld rather than risk returning an unmasked value.

| `policy_rule`    | When                                                                                                                                                                                                                                                                                                                | Behavior                                                                               |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `column_masking` | A query references a masked table in a shape the engine can't safely rewrite — a **view** over a masked table, a **whole-row or composite serialization** (`to_jsonb(users)`, `row_to_json`), an **opaque or dynamic-SQL function** (`query_to_xml`, `dblink`, a UDF), a **system column**, or a **stale catalog**. | Fail-closed — the query is denied before execution, every row withheld, nothing leaks. |

Plain aggregates and joins over a masked table are **not** denied — they rewrite and run. The agent-facing message hints at the fix: query the masked column **directly** rather than through a view, a whole-row serializer, or an opaque function, so the engine can mask it at the source. See [column masking](/docs/concepts/masking) for the mechanic and transforms.

## Still stuck?

If a query is denied and you can't tell why, dry-run it with [`midplane policy test`](/docs/reference/cli) — it prints the deciding rule and the exact agent-facing message against your policy file, with no database connection. For more, see [troubleshooting](/docs/resources/troubleshooting).

<Card title="Troubleshooting" icon="life-buoy" href="/docs/resources/troubleshooting" horizontal>
  Common denials, connection problems, and agent-wiring issues.
</Card>
