Quick start
import { loadRules, createGateManager } from "@petriflow/rules";
const { nets, verification } = await loadRules("./safety.rules");
console.log(verification);
// [
// { name: "require-backup-before-delete", reachableStates: 3 },
// { name: "approve-before-deploy", reachableStates: 2 },
// { name: "block-rm", reachableStates: 2 },
// { name: "limit-push-3", reachableStates: 5 },
// ]
const manager = createGateManager(nets, { mode: "enforce" }); loadRules() reads a .rules file, parses each rule, builds a Petri net for each one, and verifies every net by exhaustive state enumeration. If your rules have structural problems, you find out here, not when your agent is live. compile() does the same with inline strings.
Wiring it up
The gate manager sits between your agent and its tools. On every tool call, you pass the event to handleToolCall. It returns undefined if the call is allowed, or { block: true, reason: "..." } if it's blocked. After a tool executes, feed the result back with handleToolResult so deferred transitions can fire.
import { compile, createGateManager } from "@petriflow/rules";
const { nets } = compile("require backup before delete");
const manager = createGateManager(nets, { mode: "enforce" });
const ctx = { hasUI: false, confirm: async () => false };
// 1. Agent tries to delete without backing up
const decision = await manager.handleToolCall(
{ toolCallId: "call_1", toolName: "delete", input: { path: "/data" } },
ctx,
);
// decision → { block: true, reason: "..." }
// 2. Agent backs up
await manager.handleToolCall(
{ toolCallId: "call_2", toolName: "backup", input: { path: "/data" } },
ctx,
);
// → undefined (allowed)
// 3. Feed the result back so deferred transition fires on success
manager.handleToolResult({
toolCallId: "call_2",
toolName: "backup",
input: { path: "/data" },
isError: false,
});
// 4. Now delete is allowed
await manager.handleToolCall(
{ toolCallId: "call_3", toolName: "delete", input: { path: "/data" } },
ctx,
);
// → undefined (allowed) Step 3 matters. Sequence rules use deferred transitions: the net records that backup was attempted, but only advances the marking once the tool result confirms it succeeded. If you skip handleToolResult, the gate stays closed. See deferred transitions below for why this exists.
DSL reference
One rule per line. # starts a comment. Blank lines are ignored.
require A before B
A must fire before B is allowed. After B fires, the gate resets. A must fire again before the next B. This creates a 1:1 ratio: every delete is backed by a preceding backup.
The prerequisite (A) uses a deferred transition. When the agent calls A, the net doesn't advance immediately. Instead, it waits for the tool result. If A succeeds (isError: false), the token moves and B becomes available. If A fails, the marking stays unchanged and B remains blocked.
This matters for safety. Without deferral, the token would move the moment A is called, before knowing whether A actually succeeded. A failed backup would still unlock delete. Deferral closes that gap: the net only advances on confirmed success.
require human-approval before B
B requires manual confirmation every time. The runtime calls ctx.confirm() and waits for a human response. This is a manual transition in the Petri net. The transition type itself requires external input, so no amount of prompt engineering can bypass it.
block A
A is permanently blocked. The compiled net places A's transition behind a locked place that never receives a token. The tool name exists in the net, but no reachable state can ever fire it.
limit A to N per session
A can fire at most N times total. Each firing consumes one budget token. When the budget hits zero, A is blocked for the rest of the session. No refill mechanism exists.
limit A to N per action
A can fire N times, then it's blocked until action fires and refills the budget. The refill is one token per action call, not a full reset. Each read refills one send. To restore the full budget of 3, the agent must read 3 times.
OK budget: 3 → 0, spent: 0 → 3
BLOCKED budget exhausted
OK refills one token. budget: 1, spent: 2
OK budget: 0, spent: 3
BLOCKED must read again
This enforces a ratio, not a cycle. With limit send to 1 per read, the agent gets exactly one send per read. With limit send to 3 per read, the agent can burst 3 sends, then must read 3 times to fully recharge (or read once for one more send).
Tool mapping
Dot notation
Many MCP tools use a single tool name with an action field: discord with action: "sendMessage", whatsapp with action: "lookup". Dot notation gates specific actions:
discord.sendMessage means: tool name is discord, input has action: "sendMessage". The compiler generates a toolMapper automatically. Actions not mentioned in any rule pass through freely. discord.react, discord.getChannels, etc. are ungated.
map statements
For tools where you need pattern matching (like gating specific bash commands), map defines virtual tool names:
Syntax: map <tool>.<field> <pattern> as <name>
bash.commandmatches againstinput.commandof thebashtool- Bare words use word-boundary matching:
rmmatchesrm -rf build/but notformat - For regex, use
/delimiters:map bash.command /cp\s+-r/ as backup - Works with any tool and field, not just bash
- Unmatched calls pass through freely (nets abstain)
How rules compose
Each rule compiles to its own independent Petri net. At runtime, every net is checked on every tool call. A tool fires only if all nets allow it.
Rules compose by intersection. Each net enforces its own constraint. The runtime takes the conjunction. The nets don't coordinate and don't know about each other.
Two rules produce two nets. The effect is transitive: deploy needs test (net 2), and test needs lint (net 1). The result is a forced ordering of lint → test → deploy, without either net knowing the other exists.
BLOCKED by net 2: test hasn't fired
BLOCKED by net 1: lint hasn't fired
OK net 1 unlocks test
OK net 1 allows, net 2 unlocks deploy
ALLOWED both nets satisfied
Each net is small enough to verify exhaustively. A sequence rule has 3 reachable states. But their combined enforcement covers arbitrarily complex policies. Compositional guarantees without combinatorial explosion.
You can add rules incrementally. Adding require human-approval before deploy doesn't change any existing net. It adds a third independent check. Now deploy requires lint, test, and human approval.
Rules don't need to be related. Each rule compiles to its own Petri net. Nets that don't mention a tool simply abstain. A file safety net has no opinion on discord.sendMessage, and a messaging net has no opinion on deploy. Put all your rules in one file, give the agent all its tools, and every constraint is enforced simultaneously without interference.
Under the hood
Each rule compiles to a Petri net. Places hold tokens, transitions move them between places. Here's what each rule type produces.
Sequence
Approval
Block
Session limit
Action limit
Verification
compile() runs petri-ts's reachable state analysis on every net. This is exhaustive: it enumerates every state reachable from the initial marking. Unbounded state growth or structural errors are caught here, at compile time.
The nets produced by the DSL are small by construction. A sequence rule has 3 reachable states. A block rule has 2. Even limit A to 10 per session only has 12. Verification is instantaneous.
The output tells you two things per net:
- name: generated from the rule syntax (e.g.
require-backup-before-delete) - reachableStates: total states the net can reach. If this is unexpectedly high, the rule is wrong.
const { verification } = compile(`
require backup before delete
limit send to 3 per session
`);
console.log(verification);
// [
// { name: "require-backup-before-delete", reachableStates: 3 },
// { name: "limit-send-3", reachableStates: 5 },
// ] Gotchas
Misspelled tool names
Rules with typos compile without errors. A net referencing deply instead of deploy simply never matches any tool call. It abstains from every decision. Check the verification output: if a net name doesn't match what you expect, you have a typo.
require A before A
This compiles, but the behavior is subtle. A is both the prerequisite and the gated action. The first call to A is deferred (the net waits for the tool result), then A can fire again immediately. Every pair of A calls forms a cycle: one observed, one gated. Verification shows 3 reachable states. Probably not what you intended.
Circular dependencies
Two independent nets. Net 1 blocks B until A fires. Net 2 blocks A until B fires. Neither can ever fire. Both tools are permanently dead. The compiler doesn't detect this because each net in isolation is well-formed. If you suspect a deadlock, check whether any of your tools are permanently unreachable by tracing through the nets manually.
Redundant rules
block deploy alongside require test before deploy is not an error. Both nets compile independently. The block net is sufficient on its own, and the sequence net's deploy transition simply never fires. No conflict, just redundancy.
API reference
loadRules(path)
Reads a .rules file and compiles it. Returns the same CompiledRules as compile().
function loadRules(path: string): Promise<CompiledRules> compile(rules)
Parses inline rule strings, compiles each to a Petri net, verifies all nets by exhaustive state enumeration. Accepts a multiline string or an array of strings.
function compile(rules: string | string[]): CompiledRules CompiledRules
type CompiledRules = {
nets: SkillNet<string>[];
verification: NetVerification[];
} NetVerification
type NetVerification = {
name: string;
reachableStates: number;
} createGateManager(nets, options)
Creates a runtime gate manager from compiled nets. Re-exported from @petriflow/gate. Modes: "enforce" blocks disallowed calls, "shadow" logs but never blocks (useful for dry runs).
import { createGateManager } from "@petriflow/rules";
const manager = createGateManager(nets, { mode: "enforce" });
// Returns { block: true, reason } or undefined (allowed)
await manager.handleToolCall(event, ctx);
// Feed results back for deferred transitions
manager.handleToolResult(result); defineSkillNet(config)
Build custom Petri nets for things the DSL can't express: input validation, multi-phase approval flows, domain-specific guards. DSL nets and custom nets compose in the same createGateManager call.
The example below restricts file writes to /workspace/ using validateToolCall, which runs after the net confirms the transition is enabled but before it fires. The DSL has no way to inspect tool inputs, so this requires a custom net.
import { defineSkillNet, compile, createGateManager } from "@petriflow/rules";
// Restrict file writes to /workspace/ (the DSL can't express input validation)
const pathGuard = defineSkillNet({
name: "write-path-guard",
places: ["idle", "ready"],
initialMarking: { idle: 1, ready: 0 },
transitions: [
{ name: "start", type: "auto", inputs: ["idle"], outputs: ["ready"] },
{
name: "write",
type: "auto",
inputs: ["ready"],
outputs: ["ready"],
tools: ["write-file"],
},
],
freeTools: [],
terminalPlaces: [],
validateToolCall: (event) => {
const path = event.input.path as string;
if (!path.startsWith("/workspace/")) {
return { block: true, reason: `writes restricted to /workspace/, got ${path}` };
}
},
});
// Compose DSL rules and custom nets. They enforce independently.
const { nets: dslNets } = compile("require lint before write-file");
const manager = createGateManager([...dslNets, pathGuard], { mode: "enforce" });