Sequence governance (recipe)
Single-call permission answers “is this tool allowed?” — necessary but not sufficient. An agent can be fully compliant on every individual call and still produce harm by chaining individually-allowed steps. v2.12 enriches
PermissionChecker.check()with sequence + history + iteration + identity + signal, and adds'halt'as a Decision result. Sequence-aware governance is now ~80 LOC of user-land code on top — no new library factory required.
What v2.12 added (small primitive change)
Section titled “What v2.12 added (small primitive change)”// PermissionRequest — enrichedinterface PermissionRequest { // existing capability: 'tool_call' | ...; actor: string; target?: string; context?: Record<string, unknown>;
// NEW in v2.12 sequence?: readonly ToolCallEntry[]; // dispatched calls so far history?: readonly LLMMessage[]; // full conversation iteration?: number; // current ReAct iteration identity?: { tenant?, principal?, conversationId }; signal?: AbortSignal; // for async checkers}
// PermissionDecision — extendedinterface PermissionDecision { result: 'allow' | 'deny' | 'halt' | 'gate_open'; // 'halt' is NEW reason?: string; // NEW — telemetry tag tellLLM?: ToolResultContent; // NEW — LLM-facing message rationale?: string; policyRuleId?: string; gateId?: string;}'halt' terminates the run via PolicyHaltError — the framework writes a synthetic tool_result (using tellLLM) to scope.history BEFORE throwing, so:
- Anthropic / OpenAI tool_use ↔ tool_result pairing protocol stays satisfied
- Conversation history is consistent for
agent.resumeOnError(checkpoint) - Lens /
getNarrative()shows what the LLM was told before the halt
The recipe — sequencePolicy({ forbidden, limits }) in user-land
Section titled “The recipe — sequencePolicy({ forbidden, limits }) in user-land”Two helpers + a factory. The full runnable file is examples/features/11-sequence-policy.ts — paste it into your project and adapt.
Suffix matcher
Section titled “Suffix matcher”/** Suffix-match a sequence against a pattern. Pattern items can be: * - a string for exact name match * - { namePrefix } for prefix match (e.g. 'slack.*' for any slack tool) */function suffixMatches( seq: readonly ToolCallEntry[], pattern: ReadonlyArray<string | { namePrefix: string }>,): boolean { if (seq.length < pattern.length) return false; const tail = seq.slice(-pattern.length); return pattern.every((p, i) => { const call = tail[i]!; if (typeof p === 'string') return call.name === p; return call.name.startsWith(p.namePrefix); });}The policy factory
Section titled “The policy factory”interface ForbiddenRule { readonly sequence: ReadonlyArray<string | { namePrefix: string }>; readonly action: 'deny' | 'halt'; readonly reason: string; readonly tellLLM?: string;}
interface LimitRule { readonly maxPerSession: number; readonly action: 'deny' | 'halt'; readonly reason: string; readonly tellLLM?: string;}
interface SequencePolicyOptions { readonly forbidden?: readonly ForbiddenRule[]; readonly limits?: Record<string, LimitRule>; readonly id?: string;}
/** Build a `PermissionChecker` over the v2.12 enriched ctx. */function sequencePolicy(opts: SequencePolicyOptions): PermissionChecker { return { name: opts.id ?? 'sequence-policy', check({ capability, target, context, sequence }) { if (capability !== 'tool_call' || !target) return { result: 'allow' }; const wouldBe: ToolCallEntry[] = [ ...(sequence ?? []), { name: target, args: context, iteration: 1 }, ];
// Forbidden suffixes for (const rule of opts.forbidden ?? []) { if (suffixMatches(wouldBe, rule.sequence)) { return { result: rule.action, reason: rule.reason, ...(rule.tellLLM !== undefined && { tellLLM: rule.tellLLM }), }; } }
// Frequency limits const limit = opts.limits?.[target]; if (limit) { const count = (sequence ?? []).filter((c) => c.name === target).length; if (count >= limit.maxPerSession) { return { result: limit.action, reason: limit.reason, ...(limit.tellLLM !== undefined && { tellLLM: limit.tellLLM }), }; } }
return { result: 'allow' }; }, };}That’s the whole recipe. Three concepts (forbidden rules, limits, tellLLM) over the v2.12 enriched ctx.
Three rule categories — same mechanism, three product pitches
Section titled “Three rule categories — same mechanism, three product pitches”| Category | Pattern | What it blocks | Action |
|---|---|---|---|
| 🛡 Security | forbidden with cross-tool / cross-hub matchers | Exfil chains, privilege escalation | 'halt' — terminate run |
| 💸 Cost | forbidden (wasteful patterns) + limits (caps) | Context bloat, runaway loops, expensive repeats | 'deny' — LLM recovers |
| ✅ Correctness | limits + ordering checks | Double-charges, idempotency violations | 'halt' — hard cap |
Same code path. The reason field’s namespace prefix carries the framing for telemetry routing.
Wiring it up
Section titled “Wiring it up”import { Agent, PolicyHaltError } from 'agentfootprint';
const policy = sequencePolicy({ forbidden: [ { sequence: ['runPython', { namePrefix: 'slack.' }], action: 'halt', reason: 'security:exfiltration', tellLLM: 'This tool combination is restricted. Operation has been logged for security review.', }, { sequence: ['fetchAllUsers', 'summarize'], action: 'deny', reason: 'cost:context-bloat', tellLLM: 'fetchAllUsers returns too much data. Try searchUsers with a filter, then summarize.', }, ], limits: { processRefund: { maxPerSession: 3, action: 'halt', reason: 'correctness:idempotency', tellLLM: 'Refund limit reached (3/3). Escalate to a human agent.', }, },});
const agent = Agent.create({ provider, model, permissionChecker: policy }) .system("You're a customer support agent.") .tools([lookupOrder, runPython, slackDM, fetchAllUsers, processRefund, summarize]) .build();
try { const result = await agent.run({ message: 'help me with order #42' }); console.log(result.content);} catch (e) { if (e instanceof PolicyHaltError) { console.log(`HALT: rule='${e.reason}' iteration=${e.iteration}`); console.log(`Sequence: ${e.sequence.map(c => c.name).join(' → ')}`); if (e.reason.startsWith('security:')) await pagerDuty.notify(e); if (e.reason.startsWith('correctness:')) await opsDashboard.flag(e); } else { throw e; }}
agent.on('agentfootprint.permission.halt', (e) => { metrics.increment('policy.halt', { reason: e.payload.reason, checker: e.payload.checkerId });});How sequence is derived (no parallel state)
Section titled “How sequence is derived (no parallel state)”The framework derives sequence on demand from scope.history via the extractSequence helper. No parallel state in scope — the conversation history IS the source of truth. This means:
- ✅ Survives
agent.resumeOnError(checkpoint)— resumed runs see the same sequence the failed run did - ✅ Survives parallel tool calls — siblings already dispatched in the same turn appear in the sequence for later siblings
- ✅ Skips synthetic-deny tool_results — denied calls don’t count as “dispatched”
- ✅ Skips in-flight calls — proposed calls without a tool_result yet don’t pollute the sequence
You can use the helper directly if you need it outside a checker:
import { extractSequence } from 'agentfootprint/security';
const sequence = extractSequence(history, currentIteration);// → readonly ToolCallEntry[] of {name, args, iteration, providerId?}Strict halt ordering — what the framework guarantees
Section titled “Strict halt ordering — what the framework guarantees”When your checker returns { result: 'halt', ... }:
- Synthetic
tool_resultappended toscope.historywithtellLLMcontent agentfootprint.permission.haltevent emitted with{checkerId, reason, tellLLM, iteration, target, sequenceLength}- Stage commits —
scope.historywritten,runtimeStageIdcomplete,commitLoghas the entry scope.$break(...)propagates the haltAgent.run()catches at the API boundary, constructsPolicyHaltErrorwith full forensic contextPolicyHaltErrorthrown to the caller
Steps 1–3 happen inside a try/finally-equivalent ordering — if anything in the halt path throws, the audit trail is still committed before the run terminates. Same correctness pattern as the v2.11.5 reliability gate.
Default tellLLM — deliberately generic, never leaks reason
Section titled “Default tellLLM — deliberately generic, never leaks reason”When you omit tellLLM on a halt decision, the framework defaults to:
Tool '${name}' is not available in this context.This is deliberately generic and NEVER falls back to reason. Why? reason is telemetry — your tag is something like 'security:exfiltration' or 'cost:context-bloat'. Leaking that to the LLM teaches it the rule space; over enough turns a determined model can probe edges. Consumers who want a richer LLM-facing message provide tellLLM explicitly.
For deny decisions (which let the LLM recover), the default DOES include rationale text (kept for v2.4 back-compat). If you want a generic deny message too, set tellLLM explicitly.
When to use deny vs halt
Section titled “When to use deny vs halt”| Goal | Use |
|---|---|
| LLM should pick a different path | 'deny' — synthetic tool_result lands, run continues |
| Run must terminate (security / hard idempotency cap) | 'halt' — PolicyHaltError thrown |
| Want a clean error type for the caller to branch on | 'halt' always |
| Want zero impact on cost / latency unless attack succeeds | Either — both are O(1) per check |
Anti-patterns
Section titled “Anti-patterns”- ❌ Never put protected data in
tellLLM.tellLLM: "Cannot exfiltrate: file has 1543 SSNs"literally leaks the count. The LLM sees this content; the developer is responsible for keeping sensitive details out. - ❌ Don’t default
tellLLMto yourreasonvalue.reasonis telemetry, not user-facing. The framework default deliberately uses a generic message — match that pattern for your custom defaults. - ❌ Don’t throw inside
check()unless you mean it. Throwing is treated as deny-by-default with the error message echoed in the synthetic tool_result. Use{ result: 'halt' }for clean termination. - ❌ Don’t maintain parallel state in scope to track sequence. Use
request.sequence(derived from history) — it survives resume correctly. - ❌ Don’t overload one rule for multiple categories. Split into separate rules with distinct
reasontags so telemetry routing works (security:*→ PagerDuty,cost:*→ Slack channel).
Composition with staticTools + gatedTools
Section titled “Composition with staticTools + gatedTools”PermissionChecker runs at dispatch time. gatedTools hides tools at visibility time (the LLM doesn’t see them). Use both for defense-in-depth:
const provider = gatedTools( staticTools(allTools), (toolName) => roleCanCall(toolName), // visibility — fewer calls to deny);
const agent = Agent.create({ provider: llm, model, permissionChecker: sequencePolicy({...}) }) .toolProvider(provider) .build();gatedTools reduces wasted LLM cycles (“don’t show what we’ll deny”); permissionChecker enforces what gatedTools couldn’t catch (sequence patterns the LLM couldn’t have seen at visibility time).
Why no library factory ships in v2.12
Section titled “Why no library factory ships in v2.12”Same answer as the v2.11.6 discoveryProvider recipe — the library extends the primitive to enable consumer policies; consumers ship the convenience layer. Reasons:
- Lock-in risk — committing to ONE matcher API (forbidden / patterns / wildcards / providerId) before real consumer patterns emerge would lock us into the wrong shape
- Cost-benefit — extending
PermissionCheckeris ~250 LOC of library work; shipping a fulltoolSequencePolicy({...})factory is ~700 LOC plus a new subpath plus more tests, for the same consumer outcome - Future option — if 5+ consumers build the same factory shape, we promote it to
agentfootprint/governancein a future minor with a known-good API
Next steps
Section titled “Next steps”examples/features/11-sequence-policy.ts— the runnable file behind this recipe- Permission policy — single-call governance + role allowlists (
PermissionPolicy.fromRoles(...)) - Tool providers —
gatedToolsfor visibility-time filtering - Reliability gate —
ReliabilityFailFastErrorfor LLM-call-level termination (sibling pattern toPolicyHaltError) - Observability — the
agentfootprint.permission.haltevent in the full taxonomy