Guide: adding policies
Semantics: concepts/policies.md. This guide is the practical path: which policy to write, where to attach it, how to keep it testable.
Start from the sentence
Every policy worth writing is a sentence with an actor, a condition, and a consequence:
"Refunds above $500 require manager approval." "Automations may never run destructive capabilities." "Over MCP, only reads are available." "Actors outside the order's organization must not know
orders.*exists."
If the sentence holds for every caller (not just agents), stop — that's middleware, not a policy (the boundary).
Write it as a pure function
// src/policies/refund-limit.ts
import { definePolicy, allow, requireApproval, deny } 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"] } } },
);Habits that pay off:
- Name policies like log lines — the
namelands in every audit record'spolicyDecisions. - Declare scope, so applicability is enforced and inspectable rather than buried in the function body.
- Target classifications, not id lists, when the rule is general:
scope: { capabilities: { sideEffects: ["destructive"] } }automatically covers new destructive capabilities; exact ids fit operation-specific rules like the refund threshold. - Default to
allow()explicitly at the end — a policy that falls through toundefinedis a bug the runtime treats asPOLICY_FAILED(deny, SI-7), which is safe but noisy. - Type your context once: write a tiny
appPolicyhelper that castscontexttoAppContextso every policy body stays clean.
Choose the phase deliberately
| You want | Phase |
|---|---|
| The main gate (almost everything) | ["invocation"] — the default |
| Hide from listings too | ["discovery", "invocation"] |
| Re-check after approval latency | ["invocation", "execution"] |
export const membershipFresh = definePolicy(
"membership-fresh",
async ({ actor, context }) => (await (context as AppContext).members.isActive(actor.id))
? allow()
: deny("MEMBERSHIP_INACTIVE", "Your access has changed. Contact an administrator."),
{ phases: ["invocation", "execution"] }, // runs again at resume time
);Remember discovery calls carry input: undefined — guard input access by phase.
Attach at the right level
// Runtime-level: cross-cutting rules, evaluated first, in array order
const runtime = createAgentRuntime({
governance: defineGovernance({ registry: capabilities, policies: [orgIsolation, surfaceRules, destructiveNeedsApproval] }),
});
// Capability-level: rules that belong to one operation, next to its definition
.meta({ agent: { policies: [refundLimit], /* ... */ } })Ordering within a level is yours; across levels runtime-first. Since all policies evaluate and deny wins regardless of position (no short-circuit), order affects audit readability more than outcomes — group related rules and keep the list short enough to read aloud.
Declare runtime-level policies with defineGovernance, and put them under the drift gate. A conditional approval gate registered here is invisible to a snapshot taken over a bare registry: delete it and every capability field stays byte-identical while the gate is gone.
export const governance = defineGovernance({
registry: capabilities,
policies: [orgIsolation, surfaceRules, destructiveNeedsApproval],
});
const runtime = createAgentRuntime({ governance, approvals: { coordinator } });Point --entry at that export and orpc-agent records the list, classifying a removal as widening. It works whether or not the serving runtime is reachable, which matters because runtimes are usually built inside a factory and the CLI reads values rather than calling functions (ADR-016).
The tool records the policy, its authoritative scope, and the capabilities currently matching it. It never claims what the policy decides: approval, denial, hiding, or allowing still depends on the real actor, surface, input and context.
Compose reusable sets
import { composePolicies } from "@orpc-agent/core";
export const baselineGovernance = composePolicies(
orgIsolation,
mcpReadOnly,
destructiveNeedsApproval,
);
// createAgentRuntime({ policies: [baselineGovernance, ...appSpecific] })Composition preserves per-policy identity in audit records — a composed set is packaging, not a black box.
Test before wiring
Policies are pure — test the function, then the wiring:
// Unit: the function
expect(refundLimit.evaluate(reqWith({ amount: 400 }))).toEqual(allow());
expect(refundLimit.evaluate(reqWith({ amount: 700 })).type).toBe("require-approval");
// Wired: precedence, fail-closed, audit
const t = createAgentTestRuntime({ registry, policies: [refundLimit] });
const r = await t.invoke("orders.refund", { orderId: "o1", amount: 700, reason: "x" });
expect(r.status).toBe("approval-required");
expect(t.audit.ofType("capability.approval_requested")).toHaveLength(1);Full patterns: testing-capabilities.md.
Pitfalls
- Slow policies tax every call. A policy awaiting an uncached HTTP call adds that latency to all invocations — and at discovery phase, once per capability (what that costs). Precompute into context, or move the check into middleware where it already lives.
- Nondeterminism (clock/randomness inside the function) makes audit records unexplainable — inject time via context if a rule is time-based.
- Rewriting input is not available (SI-6): clamp-style rules become
denywith a message telling the model the legal range — the model adjusts and retries; nothing silently executed something the model didn't ask for. - Policy sprawl: if every capability grows bespoke policies, push the recurring ones into classifications (
risk,tags) + one classification-driven policy.