Policies
Normative policy semantics. The practical path — which policy to write, where to attach it — is in guides/adding-policies.md.
A policy is a deterministic function that evaluates an execution request and returns one of four decisions. Policies are the governance layer's programmable part — small, composable, auditable — and deliberately not a general plugin system.
import { definePolicy, allow, deny, hide, requireApproval } from "@orpc-agent/core";
export const refundLimit = definePolicy(
"refund-limit",
({ input }) => {
const { amount } = input as { amount: number };
if (amount >= 5000) return deny("REFUND_TOO_LARGE", "Refunds of $5000 or more cannot be issued by agents.");
if (amount > 500) return requireApproval({ reason: `Refund of $${amount} exceeds $500`, approvalType: "manager" });
return allow();
},
{ scope: { capabilities: { ids: ["orders.refund"] } } },
);The four decisions
| Decision | Effect | Typical use |
|---|---|---|
allow(metadata?) | Proceed; metadata lands in the audit record | Default stance; annotate why |
deny(code?, message?) | Stop with POLICY_DENIED; message is model-visible | Hard business limits |
hide() | At discovery: exclude from listings. Elsewhere: concealing deny (CAPABILITY_NOT_FOUND) | Existence itself is sensitive (SI-8) |
requireApproval({ reason, approvalType?, expiresInMs? }) | Gate at pipeline stage 8 | Thresholds, sensitive targets |
deny vs hide: deny admits the capability exists ("you can't"); hide does not ("there is no such thing"). Choose hide when knowing the capability exists leaks information.
Scope: inspectable applicability
scope is an authoritative upper bound on where a policy evaluates, not documentation. Outside it the runtime skips the policy. Missing scope means every capability and surface; a present empty array matches nothing.
definePolicy("model-writes", evaluate, {
scope: {
capabilities: { sideEffects: ["write", "destructive"] },
surfaces: ["aiSdk", "mcp"],
},
});Selector fields compose with AND; values within one field use ANY. The example means write-or-destructive capabilities on AI SDK or MCP. Available capability selectors are exact ids, tags, sideEffects, and risks. defineGovernance rejects an explicit id absent from its registry, and resolves every scope into current candidate capability ids for tooling.
Scope answers where the policy can evaluate, never what it decides. A matched policy may still return allow() for a particular actor, input, or context. orpc-agent inspect therefore labels matches as runtime candidates and keeps the runtime verdict caveat beside the table.
Phases
type PolicyPhase = "discovery" | "invocation" | "execution";
definePolicy(name, evaluate, { phases: ["invocation", "execution"] }); // default: ["invocation"]- discovery — during
describe;inputisundefined.hide/denyexclude from the listing;require-approvalannotates the descriptor (requiresApproval: true) so adapters can hint the model. - invocation — pipeline stage 7, with validated input. The main gate.
- execution — pipeline stage 9. Runs at approval resumption (and, for opted-in policies, right before the procedure call). Exists because hours may pass between "may ask" and "may run": re-check membership, limits, record state. Policies default to invocation-only, so nothing evaluates twice unless it opted in.
This phase model implements the discovery / invocation / execution separation of ADR-005.
Keep discovery-phase policies synchronous or memoized
A discovery policy runs once per candidate capability, not once per call. An invocation policy that does a permission lookup costs one round trip; the same policy at discovery phase costs one per capability — at 300 capabilities, on every describe, for every step of every turn.
The runtime bounds the damage rather than hiding it: discovery evaluates defaults.policyConcurrency capabilities at a time (16), and defaults.discoveryBudgetMs (30 s) fails the whole describe rather than returning a short catalog. Neither makes the lookups free — the measured numbers show what they cost.
- Read from already-resolved state —
actor.attributes, a field oncontext— and stay synchronous. - If a lookup is unavoidable, do it once in context construction and have the policy read the result. One batched query per request beats N per discovery.
- Narrow what is walked at all with
describe'sscope: capabilities outside the scope never reach a policy.
Evaluation and composition semantics (normative)
- Order. Runtime-level
policiesarray in declaration order, then the capability'smeta.policiesin order. - All policies evaluate. No short-circuiting on the first deny — the audit record captures every policy's stance (
policyDecisions), which is worth the marginal cost of evaluating deterministic functions. - Precedence.
deny>hide>require-approval>allow. Deny always wins; conservative conflict resolution. - Approval merging. Multiple
require-approvaldecisions merge into one approval request: all reasons, all types, the minimum expiry. One human decision satisfies the merged requirement; if your domain needs two distinct sign-offs, model it as a policy that inspectsapprovalmetadata and re-requires — the runtime itself keeps single-request semantics. - Fail closed. A policy that throws, rejects, or exceeds
defaults.policyTimeoutMsproducesPOLICY_FAILED, treated as deny (SI-7). Never fail open. - No input rewriting. Policies cannot modify the validated input (SI-6). A "safe rewrite" mechanism (e.g., clamping limits) is deliberately absent — silent mutation of what the model asked for is a debugging and audit nightmare; deny with a clear message instead (Q6).
- Determinism expectation. Same request + same context ⇒ same decision. Read from
actor/context/input; avoid clocks (inject via context if time matters), randomness, and network calls. Async is permitted (a membership lookup against a request-scoped loader is fine) but slow or flaky policies degrade every invocation — heavyweight authorization belongs in middleware or precomputed context.
What policies see
A PolicyRequest carries the phase, the capability's id and metadata, the surface, the actor, your application context, the validated input (undefined at discovery), and the approval record on resumed executions. Full type: reference/core.
The two fields worth designing around are capability.meta — target by sideEffect, risk, or tags rather than by id — and surface, which is how one rule can be strict for mcp and permissive for direct.
Patterns that compose well:
// Target by classification, not by id list
const destructiveNeedsApproval = definePolicy(
"destructive-approval",
() => requireApproval({ reason: "Destructive operation", approvalType: "manager" }),
{ scope: { capabilities: { sideEffects: ["destructive"] } } },
);
// Surface-aware tightening
const mcpReadOnly = definePolicy(
"mcp-read-only",
() => deny("MCP_READ_ONLY", "Write operations are not available over MCP."),
{
scope: {
capabilities: { sideEffects: ["write", "destructive", "external"] },
surfaces: ["mcp"],
},
},
);
// Tenancy backstop (middleware remains authoritative; this is defense in depth)
const orgIsolation = definePolicy("org-isolation", ({ actor, context }) => {
const ctx = context as AppContext;
return actor.attributes?.orgId === ctx.organizationId
? allow()
: deny("ORG_MISMATCH", "Operation not available for this organization.");
});Policies vs middleware (the boundary that matters)
| Policies | oRPC middleware | |
|---|---|---|
| Question | May the agent path proceed — visibility, gating, approval? | May this operation happen — authn, tenancy, permissions? |
| Runs | Pipeline stages 7/9 (and discovery) | Inside the procedure call, stage 11 |
| Applies to | Agent-runtime invocations only | Every invocation from every caller |
| Authoritative for security | No — additive (SI-2) | Yes (ADR-008) |
If a check must hold for all callers, it belongs in middleware. If it is agent-specific (approval thresholds, surface restrictions, model-facing visibility), it belongs in a policy. Duplicating a critical check in both is legitimate defense in depth.
Related
- Guide: adding-policies.md · Reference: core.md · Testing: testing-capabilities.md