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

# Write approvals

> Hold an agent's write until a human says yes — the third answer between allowing a write and refusing it.

Some writes are too routine to review and too consequential to wave through. An agent that can update `feature_flags` is doing its job; the same agent running a migration against `orders` at 2am is a conversation you want to have first. Until now the only answers were **allow** and **refuse**, so the choice was between granting a write you'd rather see and denying one the agent legitimately needs.

**Ask** is the third answer (`0.16.0+`). A write the policy already permits is held, a human decides, and the agent picks the result back up — no credential handed over, no policy loosened.

Reach for it when:

* **An agent needs writes you don't want unsupervised.** Grant `read_write` on `orders`, then hold row changes so a person sees each one before it lands.
* **Schema changes need a second pair of eyes, but row writes don't.** Hold schema changes only; ordinary `UPDATE`s keep running.
* **You're loosening a policy and want a safety period.** Turn a table writable and hold its writes for a week to see what the agent actually does.

## The three write classes

Every write falls into one of three classes, and each carries its own independent rule (`0.17.0+`):

| Class                  | Statements                                             | Refuse                             | Ask                            |
| ---------------------- | ------------------------------------------------------ | ---------------------------------- | ------------------------------ |
| **Row changes**        | `INSERT` / `MERGE`, `UPDATE` / `DELETE` with a `WHERE` | `guardrails.block_dml`             | `approvals.row_changes`        |
| **Whole-table writes** | `UPDATE` / `DELETE` with no `WHERE`                    | `guardrails.block_unqualified_dml` | `approvals.whole_table_writes` |
| **Schema changes**     | `DROP` / `TRUNCATE` / `ALTER`                          | `guardrails.block_ddl`             | `approvals.schema_changes`     |

Each class takes exactly one of **refuse**, **ask**, or **allow** — values of a single rule, not gates stacked on each other, so the two can't be configured to contradict one another.

Two consequences worth knowing before you configure this:

* **Approvals sit under the policy, never over it.** The hold runs only after the policy returns ALLOW, so a statement [`table_access`](/docs/concepts/table-access) or a guardrail already refused never reaches a reviewer — and no approval can buy a way past one.
* **A statement in more than one class is held if *any* of its classes is held.** `WITH d AS (DELETE FROM audit_log) UPDATE orders SET … WHERE id = 1` is both a whole-table write and a row change; running it because only one of the two was set to ask would be the worst available answer.

## What happens to a held write

<Steps>
  <Step title="The agent's query is held">
    The engine files an approval request and holds the connection briefly. If a human answers within that window, the write runs inside the agent's original tool call — the agent never sees a hold at all.
  </Step>

  <Step title="A slow decision becomes a ticket">
    Rather than block past an MCP client's timeout, the engine hands the agent an `approval_id`, a deadline, and a review URL. Nothing has run, and nothing has been denied.
  </Step>

  <Step title="The agent polls">
    [`check_approval`](/docs/reference/mcp-tools#check_approval) reports `pending` / `approved` / `executed` / `consumed` / `denied` / `expired`. Polling is cheap and never consumes the grant.
  </Step>

  <Step title="The agent re-runs to collect">
    Once approved, the agent calls `query` again with the **identical** `sql` and `intent`. That call is what executes the write.
  </Step>
</Steps>

The grant is bound to `(database, sql, intent, token)` and is **single-use**. An approval for `DELETE FROM orders WHERE id < 100` cannot authorize `… < 100000` — one character's difference is a different request, needing its own approval. Two retries racing one approval yield exactly one execution.

<Warning>
  A held write **has not run**. The tool result says so first and flatly (`executed: false`), because the worst thing an agent can do here is report a write that never happened. If you're writing an agent harness, treat `awaiting_approval` as an open task, not a result.
</Warning>

### An outage is never a refusal

If the approval service can't be reached, the agent gets `approval_unavailable` — explicitly retryable, and **not** a denial. Nothing is audited and no deny webhook fires, so an outage never leaves a refusal nobody made in your compliance record. Real decisions are audited like any other denial, as `approval_denied` or `approval_expired`. See [denial reasons](/docs/reference/denial-reasons#write-approvals).

## Reviewing a request

Held writes land in **Approvals** in the dashboard — a cross-project queue, alongside the [audit log](/docs/concepts/audit-trail) it mirrors. Anyone on the team can read the queue; approving and denying is [owner and admin only](/docs/cloud/projects#team-roles). A request nobody answers expires after **30 minutes**, and the agent is told to ask again.

* **A denial's note reaches the agent.** Say why you refused, and it can write a better statement instead of retrying the same one.
* **Notifications are link-only.** A held `DELETE`'s `WHERE` clause routinely carries live values, so the statement is customer data — it renders in the dashboard, never in an email.

## Turn it on

<Tabs>
  <Tab title="Cloud (dashboard)">
    In the project's **Database** pane, set any row under **What a write may do** to **Ask**, then **Save**. The change hot-reloads the running engine — no restart, and no agent session dropped.
  </Tab>

  <Tab title="Self-host (YAML)">
    Add an `approvals` block to the database, and point the engine at a gate:

    ```yaml theme={null}
    table_access:
      default: read
      tables:
        orders: read_write
    approvals:
      row_changes: true          # hold INSERT / UPDATE / DELETE with a WHERE
      schema_changes: true       # hold DROP / TRUNCATE / ALTER
    ```

    Or hold every class with the umbrella, `approvals: { writes: true }`.

    The engine reaches its reviewer over HTTP: set `MIDPLANE_APPROVAL_URL` and `MIDPLANE_APPROVAL_TOKEN` — both or neither, so a half-configured deployment fails at boot rather than 401-ing every held write. The bundled self-host control plane wires that pair for you once you set `MIDPLANE_APPROVAL_SECRET`; see [environment variables](/docs/reference/environment-variables#write-approvals).
  </Tab>
</Tabs>

<Warning>
  Approvals need a reviewer that can answer. With no gate configured they'd hold every permitted write forever with nothing to review, so the save is refused rather than accepted into a dead end.
</Warning>

## Related

<Columns cols={2}>
  <Card title="Table access & guardrails" icon="table" href="/docs/concepts/table-access">
    Which tables an agent may reach, and the writes refused outright.
  </Card>

  <Card title="`check_approval`" icon="clock" href="/reference/mcp-tools#check_approval">
    The tool an agent polls, and what each status tells it to do.
  </Card>

  <Card title="Policy schema" icon="file-code" href="/docs/reference/policy-schema#approvals">
    The `approvals` and `guardrails` keys, with every default.
  </Card>

  <Card title="Denial reasons" icon="ban" href="/docs/reference/denial-reasons#write-approvals">
    How a denied, expired, or held write reaches the agent and the audit log.
  </Card>
</Columns>
