Skip to content

Execution policy

ExecutionPolicy is the trusted local side-effect gate.

Planning may identify a relevant mutation. That does not mean the mutation is authorized.

Default behavior

The default policy is intentionally conservative for remote capabilities:

  • normal local/manual contracts can execute;
  • known OpenAPI mutations are blocked unless enabled;
  • destructive operations are blocked unless enabled;
  • unclassified remote MCP operations are blocked unless enabled.

Configure local authority

from schemarouter import ExecutionPolicy, SchemaRouter

router = SchemaRouter(
    policy=ExecutionPolicy(
        allow_mutations=True,
        allow_destructive=False,
        allow_unclassified_remote=False,
    )
)

Only trusted application code should construct this policy.

Operation-scoped rules

Use PolicyRule when a category-wide switch would grant more authority than the application needs. Rules are evaluated in declaration order and the first match wins.

from schemarouter import ExecutionPolicy, PolicyRule, SchemaRouter

router = SchemaRouter(
    policy=ExecutionPolicy(
        rules=(
            PolicyRule(
                name="allow-job-create",
                operation="jobs.create",
                effect="allow",
            ),
            PolicyRule(
                name="protect-delete",
                operation="jobs.delete*",
                effect="deny",
            ),
            PolicyRule(
                name="review-refunds",
                operation="payments.refund",
                effect="require_approval",
            ),
        ),
    ),
    approval_callback=approve,
)

The operation string is matched against tool.endpoint with shell-style wildcards. Optional remote, read_only, destructive, and unclassified predicates can narrow a rule further. unclassified=True explicitly matches endpoints whose side-effect classification is unknown; read_only=None remains the default wildcard rather than overloading that meaning.

A scoped allow rule is trusted local authority for that operation only. A scoped deny rule can narrow a globally enabled category. require_approval grants no model authority: the call still passes schema, binding, and policy validation and then requires the trusted approval callback.

If no rule matches, the existing allow_mutations, allow_destructive, and allow_unclassified_remote behavior is unchanged.

Per-call approval

Policy permission and human/application approval are separate gates.

from schemarouter import ExecutionPolicy, SchemaRouter

async def approve(tool, endpoint, call) -> bool:
    return await my_approval_service.check(
        tool=tool.key,
        endpoint=endpoint.name,
    )

router = SchemaRouter(
    policy=ExecutionPolicy(
        allow_mutations=True,
        approval_mode="non_read_only",
    ),
    approval_callback=approve,
)

approval_mode accepts:

  • never — no per-call callback, the default;
  • non_read_only — approval for mutating and unclassified operations;
  • all — approval before every call.

If approval is required and no callback exists, the call fails closed. Callback exceptions also fail closed. Only the literal boolean True approves a call. Async approval is bounded by the run's remaining elapsed-time budget.

The callback exists only in trusted local code. It is not serializable planner input and cannot be created by remote metadata or a model.

Per-run execution budgets

from schemarouter import ExecutionBudget, RunConfig

config = RunConfig(
    budget=ExecutionBudget(
        max_tool_calls=4,
        max_attempts=6,
        max_remote_attempts=4,
        max_elapsed_seconds=15,
        max_cost_units=3.0,
        per_tool_calls={"materials": 2},
        cost_units={
            "materials.search": 0.5,
            "papers.search": 1.0,
            "*": 0.25,
        },
    )
)

results = await router.ainvoke(request, config=config)

Semantics are deterministic:

  • logical tool calls are counted once per planned call;
  • every real invoker attempt counts, including retries;
  • remote attempts count separately;
  • cost units are charged per attempt;
  • operation-specific cost overrides tool-specific cost, which overrides "*";
  • the same budget state is shared by all calls in one plan;
  • async approval callbacks, execution hooks, invocations, and retry backoff are interrupted or bounded when the wall-clock budget expires;
  • synchronous approval/hooks cannot be preempted, but elapsed time is checked immediately after they return.

Batch APIs treat each input invocation as its own run and therefore its own budget.

Budgets are local enforcement, not billing. Cost units are application-defined weights.

Retry interaction

Read-only retries remain the default. If a call is retried, each retry consumes attempt, remote, and cost budgets before the network/tool invocation occurs.

A budget refusal is not retried.

Why remote metadata cannot grant permission

A remote server controls its own descriptions and annotations. Allowing those fields to set local execution authority would let the capability provider authorize itself.

SchemaRouter therefore keeps ordinary remote metadata descriptive and local policy authoritative. The local/remote classification used by policy is the fingerprinted ToolSpec.remote contract, not a model-visible metadata flag. Built-in remote adapters set that field locally during import.

Approval versus documentation proposal approval

Documentation-derived tools have distinct gates:

grounded proposal
 -> explicit approve_proposal()
 -> registered/bound tool
 -> ExecutionPolicy
 -> optional per-call approval
 -> execution budget
 -> execute

Proposal approval decides whether an inferred contract may enter the registry. Runtime approval decides whether this particular call may execute now.

Destructive operations

DELETE-like operations are marked destructive when the adapter can determine that classification. Set allow_destructive=True only when the surrounding application has appropriate authorization, audit, confirmation, and rollback semantics.