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 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.
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
runku policy show postgres-mainPrints the connector's current stored policy as YAML.
runku policy apply --file policy.yaml postgres-mainReads 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
# 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.
# 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 realNOT NULLconstraint the database has — if your schema requires more thanrequired_columnslists, 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 ascreate.max_affected_rows— default1. 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— defaultstrue; every primary-key column must be supplied as an equality condition. There is no arbitrary agent-chosenWHEREclause 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 asupdate.
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
# 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
# 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 omittedThis 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.