Appearance
Guide: adding policies
Status: Implemented in v0.1. 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
ts
// src/policies/refund-limit.ts
import { definePolicy, allow, requireApproval, deny } from "@orpc-agent/core";
export const refundLimit = definePolicy("refund-limit", ({ capability, input }) => {
if (capability.id !== "orders.refund") return allow();
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();
});Habits that pay off:
- Name policies like log lines — the
namelands in every audit record'spolicyDecisions. - Target classifications, not id lists, when the rule is general:
capability.meta.sideEffect === "destructive"survives new capabilities;["orders.refund", "orders.cancel", ...]rots. - 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"] |
ts
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
ts
// Runtime-level: cross-cutting rules, evaluated first, in array order
const runtime = createAgentRuntime({
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.
Compose reusable sets
ts
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:
ts
// 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. 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.