Skip to content

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.

ts
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

DecisionEffectTypical use
allow(metadata?)Proceed; metadata lands in the audit recordDefault stance; annotate why
deny(code?, message?)Stop with POLICY_DENIED; message is model-visibleHard 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 8Thresholds, 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.

ts
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

ts
type PolicyPhase = "discovery" | "invocation" | "execution";
definePolicy(name, evaluate, { phases: ["invocation", "execution"] });  // default: ["invocation"]
  • discovery — during describe; input is undefined. hide/deny exclude from the listing; require-approval annotates 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 on context — 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's scope: capabilities outside the scope never reach a policy.

Evaluation and composition semantics (normative)

  1. Order. Runtime-level policies array in declaration order, then the capability's meta.policies in order.
  2. 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.
  3. Precedence. deny > hide > require-approval > allow. Deny always wins; conservative conflict resolution.
  4. Approval merging. Multiple require-approval decisions 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 inspects approval metadata and re-requires — the runtime itself keeps single-request semantics.
  5. Fail closed. A policy that throws, rejects, or exceeds defaults.policyTimeoutMs produces POLICY_FAILED, treated as deny (SI-7). Never fail open.
  6. 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).
  7. 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:

ts
// 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)

PoliciesoRPC middleware
QuestionMay the agent path proceed — visibility, gating, approval?May this operation happen — authn, tenancy, permissions?
RunsPipeline stages 7/9 (and discovery)Inside the procedure call, stage 11
Applies toAgent-runtime invocations onlyEvery invocation from every caller
Authoritative for securityNo — 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.

Independent community project — not affiliated with or endorsed by the oRPC maintainers.