# Policy

A connector's policy is its complete, stored contract of what it will
ever show to anyone: which tables and procedures are exposed, which
columns are masked, what writes are allowed. It lives in the admin
store (see [auth-and-permissions.md](auth-and-permissions.md) for how
that's structured), managed via `runku policy`, never parsed from
`config.yaml`. This document is about *what a connector can show at
all* — restricting that further per identity (who gets to see or touch
which of the exposed tables) is a separate layer, covered in
[auth-and-permissions.md](auth-and-permissions.md).

Nothing is exposed by default. A table absent from policy, or with
`expose: false`, produces no tools at all — not hidden, not filtered:
absent from `tools/list` and from `schema_overview`, exactly as if it
didn't exist.

## Viewing and applying policy

```sh
runku policy show postgres-main
```

Prints the connector's current stored policy as YAML.

```sh
runku policy apply --file policy.yaml postgres-main
```

Reads a policy file (same shape as below), applies defaults, validates
it internally (row limits, duplicate mask columns, mutation column
references) **and** against the connector's actual, live-introspected
schema — a table or column that doesn't exist is a hard error, not a
silent no-op. Only then is it written to the store. If `runku serve` is
already running against the same store, send it `SIGHUP` to pick the
change up without a restart.

## Shape of a policy file

```yaml
# Connector-wide read limits applied unless a table overrides them.
default_max_rows: 100
query_timeout: 5s
# Per-table visibility, masking and limits.
tables:
  customers:
    # Publish typed tools for this table but remove sensitive columns entirely.
    expose: true
    mask_columns: [email, phone]
    # Tighter row cap for this table.
    max_rows: 50
  orders:
    # Publish read tools using connector-wide limits.
    expose: true
  salaries:
    # Keep the decision explicit and do not generate tools for this table.
    expose: false   # explicit, to document the decision
# Stored procedures require an explicit allow-list and parameter contract.
procedures:
  recalc_totals:
    # Publish only this procedure with only the declared parameter.
    expose: true
    params: [order_id]
```

### `default_max_rows` / `query_timeout`

Policy-wide defaults. `default_max_rows` (default `100` if unset) caps
every `search_`/`aggregate_` call unless a table overrides it.
`query_timeout` (default `5s`) wraps every query/mutation/procedure call
in a context deadline.

### Per table (`tables.<name>`)

| Field | Effect |
|---|---|
| `expose: true` | Generates `get_<table>_by_pk`, `search_<table>`, `aggregate_<table>` for this table. Required before mutations can be enabled on it. |
| `mask_columns: [...]` | These columns are excluded from SELECT, results, filters, `order_by`, and `schema_overview` — structurally absent, not merely omitted from the response. Filtering by a masked column is rejected before the query ever reaches the database. |
| `max_rows: N` | Overrides `default_max_rows` for this table only. `0` (or omitted) means "use the policy default." |
| `mutations` | Opt-in write support — see below. Absent entirely = the table stays read-only. |

### Masking is absolute, including for writes

A masked column can **never** appear in a mutation's values, even if
`mutations.create.allowed_columns` lists it — masking is checked before
the write-side allow-list, not instead of it. This has one real
consequence worth knowing before you write your own policy: **a masked,
`NOT NULL` column makes `create_<table>` permanently fail** at the
database level, since the agent can never supply a value for a column it
can't see. If a column needs to stay populated on every insert, either
don't mask it or make it nullable with a sensible default.

## Mutations — opt-in writes

Every table starts read-only. Enabling writes is per-table, per-
operation, per-column — nothing is exposed by adding a `mutations:`
section unless each operation is separately turned on.

```yaml
# Write permissions are configured per table and operation.
tables:
  customers:
    # The table must be exposed before any read or write tool can exist.
    expose: true
    # Masked columns are absent from reads, filters and mutation inputs.
    mask_columns: [email, phone]
    mutations:
      create:
        # Generate a create tool with an explicit input/output contract.
        enabled: true
        allowed_columns: [name, country]
        required_columns: [name]
        returning: [id, name, country]
      update:
        # Permit only these fields and protect against broad writes.
        enabled: true
        allowed_columns: [name, country]
        max_affected_rows: 1
        returning: [id, name, country]
      delete:
        # Soft-delete one row by setting the configured marker column.
        enabled: true
        mode: soft
        soft_delete_column: deleted_at
        max_affected_rows: 1
        returning: [id, name]
```

Generates exactly the tools each enabled operation implies —
`create_customers`, `update_customers_by_pk`,
`soft_delete_customers_by_pk` — and no others. A table with only
`create.enabled: true` gets exactly one new tool.

**`create`**
- `allowed_columns` — the only columns an agent may ever set. A field
  outside this list is rejected before touching the database, with the
  attempted (not real) value logged.
- `required_columns` — must be present in the call. Note this is a floor
  Runku enforces on top of your table, not a guarantee it matches every
  real `NOT NULL` constraint the database has — if your schema requires
  more than `required_columns` lists, the database will still reject an
  otherwise-policy-valid call.
- `returning` — columns included in the response row.

**`update`** (always by primary key — never a filter-based bulk update)
- `allowed_columns`, `returning` — same meaning as `create`.
- `max_affected_rows` — default `1`. Enforced *before* commit: the
  driver runs inside a transaction, checks the actual affected-row
  count, and rolls back rather than commits if it exceeds this.
- `require_pk` — defaults `true`; every primary-key column must be
  supplied as an equality condition. There is no arbitrary agent-chosen
  `WHERE` clause on a write, ever.

**`delete`** — soft-delete only; there is no hard delete and no
filter-based bulk delete anywhere in the product.
- `mode: soft` — currently the only supported value.
- `soft_delete_column` — the timestamp column to set. Runku sets it to
  the current time server-side; the agent never supplies that value.
- `require_pk`, `max_affected_rows`, `returning` — same meaning as
  `update`.

A soft delete does not automatically hide the row from reads afterward —
`deleted_at` (or whatever you named it) is a normal, unmasked column
like any other; excluding deleted rows from a `search_` call is the
caller's job (`filters: [{"column":"deleted_at","op":"is_null"}]`), the
same way any other filter works. No implicit magic.

## Procedures

```yaml
# Procedure tools are denied unless explicitly listed here.
procedures:
  recalc_totals:
    # Expose the procedure and accept only this named parameter.
    expose: true
    params: [order_id]
```

A stored procedure is a second, first-class schema object alongside
tables. `expose: true` generates one MCP tool per procedure, with a
JSON Schema built from `params`. Calls are still fully declarative — a
`ProcedureCallPlan` with named, typed parameters, never a raw `CALL`/
`EXEC` string assembled from agent input.

## A complete worked example

```yaml
# Default safeguards for every exposed table in this connector policy.
default_max_rows: 100
query_timeout: 5s
tables:
  customers:
    # Exposed with sensitive fields removed and a tighter row limit.
    expose: true
    mask_columns: [email, phone]
    max_rows: 50
  orders:
    # Exposed using the connector defaults above.
    expose: true
  order_items:
    expose: true
  products:
    expose: true
  salaries:
    # Explicitly excluded; Runku will not generate tools for it.
    expose: false   # explicit, to document the decision — not just omitted
```

This example policy exposes four
tables exposed read-only, `customers.email`/`phone` masked, and
`salaries` explicitly marked not-exposed rather than left out of the
file entirely — the point is to document the decision, not just
implement it. See `config.mutations-example.yaml` /
`seed.mutations-example.yaml` for a full worked example with every
mutation operation enabled.
