Skip to content

Policy

Gantry treats agent-generated work as a proposal. Policy decides whether it may happen at all, before anything reaches an engine.

Policy controls authority. Verification controls acceptance. Evidence explains why.

Policy is a guardrail around execution, not a replacement for database authorization. Always use least-privilege roles, scoped IAM, network controls, and provider resource limits underneath it.

Two layers

Small deployments need one operation with bounds on it. Larger ones need the same rules across many operations and engines. Both are trusted configuration, and they compose:

simple use  ->  operation configuration    db.query(schemas=[...], max_rows=100)
larger use  ->  reusable policy            gantry.sql.connect(..., policy=policy)

Attaching a policy never relaxes what a call site configured. The effective authority is the narrower of the two, always.

A reusable policy

policy = gantry.Policy(
    name="data-agents",
    rules=[
        gantry.allow.query(sources=["analytics.*"]),
        gantry.allow.materialize(sources=["raw.*"], destinations=["agent_scratch.*"]),
        gantry.deny.materialize(destinations=["prod.*"]),
    ],
)

db = gantry.sql.connect("postgres", url=DATABASE_URL, policy=policy)
mongo = gantry.nosql.connect("mongodb", uri=URI, database="analytics", policy=policy)
stream = gantry.stream.connect("flink", endpoint=GATEWAY, policy=policy)

Execution is unchanged. Evaluation is automatic:

query = db.query()
run = await query("SELECT * FROM analytics.customers", verify=[gantry.verify.row_count(min=1)])

Semantics

a matching DENY      ->  DENY
otherwise an ALLOW   ->  ALLOW
otherwise            ->  DENY

An explicit policy is default-deny, and every resource the proposal touches must be authorized on its own:

SELECT ... FROM analytics.orders o JOIN finance.customers c ON ...

Allowing analytics.* does not authorize finance.customers. The whole proposal is denied if any resource it touches is outside authority.

Reads compose across rules — two rules each naming one schema together authorize a join over both. Writes do not: a rule grants write authority only when its own sources cover everything the proposal reads, so rules allowing raw.* -> scratch_a.* and other.* -> scratch_b.* never combine into permission to move raw data into scratch_b.

Rules narrow on each dimension they name — actors, operations, engines, sources, destinations, environments, constraints — and grant nothing on a dimension they do not. One asymmetry is deliberate: an allow rule that names no destinations authorizes no writes. Reading the wrong table leaks; writing the wrong table destroys something, so write authority is never granted by omission.

Resource patterns

Three forms, no regex:

Pattern Matches
events exactly that resource
analytics.* anything inside the analytics namespace, at any depth
derived_* resources in this namespace whose name starts that way

Providers normalize native resources to stable names, and patterns are written against those:

PostgreSQL   analytics.customers
BigQuery     project.dataset.table
MongoDB      database.collection
Flink        catalog.database.table

Matching is case-insensitive. An unqualified name is matched as written — Gantry does not resolve a search path or a default schema to decide whether a rule applies, so customers does not match analytics.*. Flink is the one place Gantry fills anything in: a bare table name is qualified with the connection's default_catalog and default_database, so the spelling the SQL happened to use cannot decide the outcome. A Flink identifier that already contains a dot is used exactly as written, because Flink allows one identifier to contain dots and counting them cannot tell a qualified name from a name with a dot in it.

Constraints

A rule's constraints are ceilings the operation must already be under:

gantry.allow.query(sources=["analytics.*"], constraints={"max_rows": 1_000})
policy max_rows      1000
operation max_rows    100
effective             100

A rule that bounds something the operation did not bound cannot apply: nothing would hold the ceiling, so the request is denied with CONSTRAINT_EXCEEDED. Trusted constraints compose toward less authority, never more.

Asking before it happens

Some operations are allowed and still deserve a question first. A rule can say so without turning the answer into a refusal:

gantry.allow.materialize(
    sources=["raw.*"],
    destinations=["prod.*"],
    require_confirmation=True,
    confirmation_message="This will write to production data.",
)

The decision stays allowed=True, the run parks at AWAITING_CONFIRMATION, and the host confirms or declines on a separate path. See Confirmation — including what v0 does and does not claim, which is not authenticated approval.

Actor and environment

Both come from the application, through trusted context:

with gantry.actor.context(
    actor=gantry.actor.actor("agent", "research-agent"),
    environment="prod",
):
    run = await query(sql)

Neither is a tool argument. An agent that could name its own actor could name someone else's, and one that could name its own environment could call production staging. No environment is not dev: a rule scoped to an environment does not apply when the application set none.

Resource extraction

The agent does not authoritatively declare what its proposal touches. Gantry derives it:

INSERT INTO prod.orders SELECT * FROM raw.orders
input   raw.orders
output  prod.orders

The same holds for MongoDB $lookup, $out and $merge, and for Flink SQL sources and sinks. Where the effect cannot be determined safely — an unclassifiable statement, a multi-statement submission whose reads and writes are no longer separable — admission fails closed with RESOURCE_UNRESOLVED.

Policy governs the resources inspection names. A statement that touches no table — SELECT 1 — has nothing for a rule to match, and is admitted when a rule otherwise applies to the actor, operation and environment. Engine-native side effects that are not table references, such as a function that reads a file, are the classifier's business rather than policy's: read_only refuses the ones it recognizes. Least-privilege database roles remain the layer underneath, not an optional extra.

Reason codes

Every decision is structured. A denial names the resource and the rule:

run.admission.allowed  # False
run.admission.codes  # ("DESTINATION_DENIED",)
run.admission.matched_rules  # ("deny-materialize-2",)
run.admission.reasons  # ("writing prod.orders is denied by rule deny-materialize-2",)
Code Means
NO_MATCHING_ALLOW nothing in the policy authorizes this
EXPLICIT_DENY a deny rule matched
ACTOR_DENIED no rule allows this actor, or a deny named it
OPERATION_DENIED no rule allows this operation kind
SOURCE_DENIED a deny rule named a source that is read
DESTINATION_DENIED a deny rule named a destination that is written
ENVIRONMENT_DENIED the environment is outside every rule, or a deny named it
CONSTRAINT_EXCEEDED the operation's limits exceed every rule's ceiling
RESOURCE_UNRESOLVED a policy-relevant effect could not be determined
POLICY_INVALID the policy could not be evaluated

What lands on the run

run.admission carries the decision, whether it allowed or refused:

Run run_01M2…

Actor
  agent:etl-agent

Operation
  materialize

Admission
  ✗ refused
  policy: data-agents
    ✓ read raw.orders
    ✗ write prod.orders
    DESTINATION_DENIED
      writing prod.orders is denied by rule deny-materialize-2

Decision
  POLICY_REJECTED

The policy name and the version that decided are both recorded. v0 does not re-evaluate work that was already admitted, so a run stays explainable after the rules change: editing them produces a different sha256:…, and the run keeps pointing at the one that decided it.

Inputs are recorded on an admitted run; outputs are recorded when the run produces them. A destination that was authorized is not a destination that exists, so the one a refused proposal wanted appears in run.admission.request["outputs"] rather than in run.outputs.

Invalid policies

A policy that cannot mean anything raises PolicyConfigurationError where it is written, not at admission:

gantry.allow.query(sources=["ana*lytics"])  # a star inside a name
gantry.allow.materialize(destinations=[])  # could never authorize a write
gantry.allow.query(constraints={"max_joins": 3})  # not a bounded constraint

Per-operation configuration

A policy object is not required. Bounds configured on the operation govern admission on their own, and keep applying when a policy is attached:

query = db.query(
    read_only=True,
    schemas=("analytics",),
    tables=("analytics.customers", "analytics.orders"),
    denied_tables=("analytics.payroll",),
    max_rows=100,
    timeout=15,
    max_bytes_scanned=10_000_000_000,
    max_cost_usd=1.00,
)

Call it directly or expose its narrow tool form:

result = await query("SELECT customer_id, total FROM analytics.orders")
tool = query.tool()

The agent sees only the tool's sql argument. It does not receive the connection, provider client, credentials, policy, or any policy fields — a tool call carrying policy is refused rather than ignored.

SQLPolicy remains the internal authority model used by adapters and lower-level execution APIs:

policy = gantry.sql.SQLPolicy(
    read_only=True,
    allowed_schemas=("analytics",),
    max_rows=100,
    timeout_seconds=15,
)

Operation fields

Field Default Guarantee
read_only True Reject write statements and require a native read-only execution boundary.
schemas empty When set, every referenced table must be qualified with an allowed schema.
tables empty When set, every referenced table must match an allowed base or qualified name.
denied_tables empty Reject matching base or qualified table names.
max_rows 1000 Materialize at most this many rows in the agent result.
timeout 30 Require a native timeout or a reconnectable job Gantry can monitor and cancel.
max_bytes_scanned None Reject work whose native estimate exceeds the byte limit.
max_cost_usd None Reject work whose native estimate exceeds the cost limit.
allow_multiple_statements False Reject more than one SQL statement by default.

These are the arguments to db.query(...); adapters receive their normalized SQLPolicy equivalents. Schema and table names are compared case-insensitively. An allowlist that cannot be evaluated safely rejects the statement. For example, when schemas is set, use analytics.orders rather than an unqualified orders reference.

How admission works

Every operation follows the same path:

classify the proposal
    ↓
resolve what it touches: inputs, outputs, effects
    ↓
evaluate the reusable policy, if one is attached
    ↓
check the operation's own scope and bounds
    ↓
ask the adapter for native validation or estimates
    ↓
compare requested guarantees with adapter capabilities
    ↓
execute or reject

The local classifier is conservative. Unknown operations, disallowed multiple statements, and unsafe writes fail before submission. It does not try to translate SQL or replace the target's parser and planner.

Adapters declare the guarantees they can actually enforce. If a policy requires a row limit, timeout, byte estimate, cost estimate, or read-only session that the adapter cannot provide, admission fails closed instead of weakening the policy.

Read-only execution

read_only=True has two layers:

  1. Gantry rejects SQL classified as a write.
  2. The adapter must provide a target-native read-only boundary.

For PostgreSQL, Neon, and Supabase, queries run in a read-only transaction. BigQuery validates the native dry-run statement type. Snowflake requires a genuinely read-only role and an explicit read_only=True connection assertion. Static classification alone is never treated as sufficient isolation.

For governed writes that create derived data, configure a separate materialization operation:

materialize = db.materialize(
    sources=("analytics.*",),
    destinations=("agent_scratch.*",),
    timeout=15,
)

This succeeds only if the adapter declares the required materialization support. Gantry does not grant database permissions; the configured identity must already have them.

Result and resource bounds

For inline results, adapters fetch at most max_rows + 1 rows. The extra row is used only to set result.inline.truncated; no more than max_rows rows are returned to the agent.

Warehouse and asynchronous adapters can return an OutputRef instead of materializing a large result. The data remains in the target system.

Byte and cost limits depend on native estimates. For example, BigQuery can enforce max_bytes_scanned; cost enforcement additionally requires a configured price_per_tb_usd. If the target cannot produce the required estimate, the query is rejected.

Handling rejection

Policy rejection is structured and does not raise from the callable tool:

result = await query("DELETE FROM analytics.orders")

if result.status is gantry.ResultStatus.REJECTED:
    print(result.failure.kind)
    print(result.failure.message)

Operational failures use normalized kinds such as AUTH_ERROR, OBJECT_NOT_FOUND, SYNTAX_ERROR, TIMEOUT, and ENGINE_ERROR, while native details remain available for diagnosis.

Agent exposure

Calling .tool() removes configuration from the model-visible interface:

tool = query.tool()

tool.name  # "query_sql"
tool.description
tool.input_schema  # SQL plus allowlisted agent verification

Policy and trusted checks are still absent from that schema. query.tool() requires read_only=True. Expose agent writes through a separately scoped db.materialize(...) operation.

Keep schema discovery and explanation in trusted application code through db.describe() and db.explain(sql) unless a separate integration deliberately exposes them.

Production checklist

  • Use a dedicated agent role or service identity.
  • Grant access only to required catalogs, schemas, tables, and views.
  • Keep read_only=True unless writes are an explicit product requirement.
  • Set row and timeout limits for every exposed query tool.
  • Use byte and cost limits where the provider can estimate them.
  • Keep credentials in server-side configuration, never prompts or tool arguments.
  • Log rejection and normalized failure metadata without logging secrets.
  • Test representative allowed, denied, expensive, and malformed statements before deployment.