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

# Writing policies

> Grant the exact reads and writes an agent needs — one YAML file (self-host) or the dashboard grid (Cloud), same model.

A policy is the set of access rules Midplane enforces on every query: which tables an agent can read, which it can write, and everything it can't touch. The model is identical whether you self-host or use Cloud — only where you author it differs.

<Tabs>
  <Tab title="Cloud (dashboard)">
    Your policy is the per-database grid on a [project](/docs/cloud/projects). Each database has a **Table permissions** matrix and a **Guardrails** card; saving hot-reloads the running engine. A new database starts read-only.
  </Tab>

  <Tab title="Self-host (YAML)">
    Your policy is a single YAML file referenced by `MIDPLANE_POLICY_FILE`. Mount it into the container and point the env var at it. With no file at all, Midplane allows every read and denies every write.
  </Tab>
</Tabs>

## Read-only by default

The starting point is always the same: **all reads allow, all writes deny.** A fresh Midplane is immediately safe — an agent can explore and query, but can't change anything until you grant a write, one table at a time. See [the policy engine](/docs/how-it-works) for how each decision is made.

A policy has two top-level keys, both optional:

| Key            | What it controls                                                                                      |
| -------------- | ----------------------------------------------------------------------------------------------------- |
| `table_access` | Per-table read / write / deny.                                                                        |
| `databases`    | Serve more than one database from one endpoint (`0.2.0+`); each entry carries its own `table_access`. |

Use `databases` *or* the top-level `table_access`, not both — when a `databases` block is present, the top-level keys and `DATABASE_URL` are ignored. Every key, type, and default is in the [policy schema reference](/docs/reference/policy-schema).

## The three access levels

Every table resolves to one of three levels. A query is denied if **any** table it touches fails its required permission.

| Level        | The agent can                                              |
| ------------ | ---------------------------------------------------------- |
| `deny`       | Nothing — the table is invisible and untouchable.          |
| `read`       | `SELECT` only.                                             |
| `read_write` | `SELECT` plus row writes (`INSERT` / `UPDATE` / `DELETE`). |

<Note>
  `read_write` grants *row* writes, not whole-table destruction. A no-`WHERE` `DELETE`/`UPDATE` and any `DROP`/`TRUNCATE`/`ALTER` are denied by the dangerous-statement guardrails even on a `read_write` table — so a write grant can't be escalated into a wipe. For exactly what each level permits, how names resolve, and the full guardrail set, see [table access and guardrails](/docs/concepts/table-access).
</Note>

## Set a default, then override per table

Pick the level for any table you don't list, then add the exceptions:

* `default: read` — the recommended start. Reads everywhere, writes nowhere until granted.
* `default: deny` — locked down. Only the tables you list are visible. Use it when the agent should see a named allowlist and nothing else.

<Warning>
  Avoid `default: read_write` — it makes every unlisted table writable, including tables created later. `midplane policy lint` flags it as an error.
</Warning>

<Tabs>
  <Tab title="Cloud (dashboard)">
    In the database's **Table permissions** matrix, set **Default access** to `read`. Each row is a table; each column is a level. Add a row per exception and pick its level — overrides win over the default. Set `feature_flags` to `read_write` and `audit_log` to `deny`.
  </Tab>

  <Tab title="Self-host (YAML)">
    ```yaml theme={null}
    table_access:
      default: read
      tables:
        feature_flags: read_write
        audit_log:     deny
    ```

    Save it as `policy.yaml`, mount it, and set `MIDPLANE_POLICY_FILE=/policy.yaml`.
  </Tab>
</Tabs>

With this policy:

* `SELECT * FROM users` — **allowed** (`default: read`).
* `DELETE FROM users WHERE id = 1` — **denied** (`users` is read-only).
* `INSERT INTO feature_flags (name) VALUES ('beta')` — **allowed** (`feature_flags` is `read_write`).
* `SELECT * FROM audit_log` — **denied** (`audit_log` is `deny`, no access at all).

### Schema-qualified names

Table keys may be bare (`feature_flags`) or schema-qualified (`public.users`, `stripe.charges`). Schema-qualified keys match first, so a canonical policy still matches agent SQL that uses bare names in `public`.

```yaml theme={null}
table_access:
  default: read
  tables:
    "stripe.charges": read       # matched for FROM stripe.charges
    public.feature_flags: read_write
    audit_log: deny
```

Bare references always resolve to `public` — Midplane pins each connection's search path to `public, pg_catalog`. Tables outside `public` must be schema-qualified in **both** the policy and the agent's SQL (for example `app_data.orders: read` plus `FROM app_data.orders`).

## Serve multiple databases

One Midplane endpoint can front several databases (`0.2.0+`). Replace the top-level `table_access` with a `databases` array; each entry has its own connection URL and policy.

<ParamField path="name" type="string" required>
  Short identifier the agent uses to pick the database — lowercase, starts with a letter, up to 32 characters.
</ParamField>

<ParamField path="url" type="string" required>
  The connection DSN. Supports `${ENV_VAR}` interpolation, so DSNs and passwords stay in environment variables, not the file. An unset referenced variable fails loudly at boot.
</ParamField>

<ParamField path="dialect" type="string" default="postgres">
  Only `postgres` — omit it.
</ParamField>

<ParamField path="table_access" type="object">
  The same per-table policy as above, scoped to this database.
</ParamField>

With two or more databases configured, the [MCP tool surface](/docs/reference/mcp-tools) adapts — `query` takes a `database` argument and a `list_databases` tool appears. A single database changes none of this.

<Tabs>
  <Tab title="Cloud (dashboard)">
    Add each database's URL under the project; each gets its own policy grid. The project exposes one endpoint, and the agent picks a database by name.
  </Tab>

  <Tab title="Self-host (YAML)">
    ```yaml theme={null}
    databases:
      - name: prod
        url: ${PROD_DATABASE_URL}
        table_access:
          default: read

      - name: warehouse
        url: ${WAREHOUSE_DATABASE_URL}
        table_access:
          default: read                  # everything read-only, no writes
    ```

    Supply the DSNs via `--env-file` or compose, never inline on the command line. The agent then calls `query` with `database: prod` or `database: warehouse`.
  </Tab>
</Tabs>

## Guardrails

Guardrails are categorical blocks that fire **regardless of table access** — a table you granted `read_write` still can't be wiped or altered. Both ship **on**:

* **Unqualified writes** — a `DELETE` / `UPDATE` with no `WHERE` clause (the whole-table write).
* **Schema changes (DDL)** — `DROP`, `TRUNCATE`, `ALTER`.

In the dashboard these are the **Guardrails** card (**Block** / **Allow** per rule); in YAML they're the [`guardrails`](/docs/reference/policy-schema) block. Switching one to **Allow** is opting into a risk, not a neutral default. Two deeper invariants — multi-statement queries and unparseable SQL — are **always denied** and have no toggle anywhere. See [what Midplane blocks](/docs/concepts/table-access#beyond-table-access) for the full rule set.

## Test before you ship

<Tabs>
  <Tab title="Cloud (dashboard)">
    The editor's **test panel** runs your policy through the engine's real decision path and shows a pass/fail result without touching your database. Saving then hot-reloads the running engine — **no restart, no redeploy** — and the edit is [audited](/docs/concepts/audit-trail) with the teammate who made it.
  </Tab>

  <Tab title="Self-host (YAML)">
    Dry-run a statement against the policy without touching a database:

    ```bash theme={null}
    midplane policy test policy.yaml --sql "DELETE FROM users WHERE id = 1"
    # DENY — rule: table_access
    ```

    `midplane policy init` scaffolds a commented file, and `validate` / `lint` / `test` check it before it reaches production. See [author policies with the CLI](/docs/reference/cli#midplane-policy).
  </Tab>
</Tabs>

Column masking is authored separately — in Cloud, the project's **Exposure** scan. See [column masking](/docs/concepts/masking).

## Examples

Complete, copy-pasteable policies for the shapes that come up most. None set a `guardrails` block, so the dangerous-statement guardrails stay **on** — add one only to relax them.

### SaaS product database

Reads open by default, a couple of operational tables writable, the audit log locked out entirely.

```yaml theme={null}
table_access:
  default: read
  tables:
    feature_flags: read_write   # let the agent toggle flags
    webhooks:      read_write    # let the agent enqueue webhooks
    audit_log:     deny          # never readable or writable
```

### Feature flags only

Read everything, write exactly one table.

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

### Multiple databases

A production database plus a read-only analytics warehouse through one endpoint (`0.2.0+`). DSNs come from environment variables via `${ENV_VAR}`, so no secrets live in the file.

```yaml theme={null}
databases:
  - name: prod
    url: ${PROD_DATABASE_URL}
    table_access:
      default: read
      tables:
        feature_flags: read_write
        audit_log:     deny

  - name: warehouse
    url: ${WAREHOUSE_DATABASE_URL}
    table_access:
      default: read                  # read-only warehouse
```

## Reference

<Columns cols={2}>
  <Card title="Policy schema" icon="file-code" href="/docs/reference/policy-schema">
    Every key, type, and default.
  </Card>

  <Card title="Table access & guardrails" icon="table" href="/docs/concepts/table-access">
    How each level behaves, plus the guardrails and rules that deny dangerous shapes.
  </Card>

  <Card title="Author with the CLI" icon="terminal" href="/docs/reference/cli#midplane-policy">
    Scaffold, validate, lint, and test policies.
  </Card>
</Columns>
