Quick start

# safety.rules require backup before delete require human-approval before deploy block rm limit push to 3 per session
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

require backup before delete

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

require human-approval before deploy-prod

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

block rm

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

limit push to 3 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

limit send to 3 per read

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.

// limit send to 3 per read: refill is one-at-a-time
send, send, send
OK budget: 3 → 0, spent: 0 → 3
Agent calls send
BLOCKED budget exhausted
Agent calls read
OK refills one token. budget: 1, spent: 2
Agent calls send
OK budget: 0, spent: 3
Agent calls send
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:

require discord.readMessages before discord.sendMessage require human-approval before discord.sendMessage block discord.timeout

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:

map bash.command rm as delete map bash.command cp as backup map bash.command deploy as deploy-cmd require backup before delete require human-approval before deploy-cmd

Syntax: map <tool>.<field> <pattern> as <name>

  • bash.command matches against input.command of the bash tool
  • Bare words use word-boundary matching: rm matches rm -rf build/ but not format
  • 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.

require lint before test require test before deploy

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.

// agent tries to skip steps
Agent calls deploy
BLOCKED by net 2: test hasn't fired
Agent calls test
BLOCKED by net 1: lint hasn't fired
Agent calls lint
OK net 1 unlocks test
Agent calls test
OK net 1 allows, net 2 unlocks deploy
Agent calls 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.

# These rules are completely unrelated. # They enforce independently in the same agent. # File safety require backup before delete block rm # Deployment pipeline require lint before test require test before deploy require human-approval before deploy limit deploy to 2 per session # Messaging require discord.readMessages before discord.sendMessage limit discord.sendMessage to 5 per session

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

require A before B
places: idle(1) ready(0) gate(0) transitions: start(idleready) do-A(readygate) deferred do-B(gateready) cycle: ready → gate → ready (A then B, repeatable) states: 3

Approval

require human-approval before B
places: idle(1) ready(0) transitions: start(idleready) approve(readyready) type: manual mechanism: manual transition, requires ctx.confirm() states: 2

Block

block A
places: idle(1) ready(0) locked(0) transitions: start(idleready) do-A(lockedlocked) needs token in locked, never has one mechanism: A exists but is permanently unfireable states: 2

Session limit

limit A to N per session
places: idle(1) ready(0) budget(N) transitions: start(idleready) do-A(ready+budgetready) consumes one budget token mechanism: budget tokens consumed, never replenished states: N+2

Action limit

limit A to N per action
places: idle(1) ready(0) budget(N) spent(0) transitions: start(idleready) do-A(ready+budgetready+spent) uses budget refill(ready+spentready+budget) one token per action call mechanism: each action call recycles one spent → budget states: N+2

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

require A before B require B before A

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" });