The problem
A deployment pipeline isn't a single tool call. It's a multi-step workflow: build, test, stage, canary, promote. At each step, the agent is free to do other things — read logs, debug a failing test, search the web, fix code. But the deployment itself must follow a strict path, and only one deployment can be in progress at a time.
You can't express this with require A before B rules. A sequence rule resets after each cycle — it has no memory of "a deployment is in progress." You need something that tracks state across multiple steps, and resources that are consumed and restored.
This is what Petri nets are actually for.
The deploy slot
The key insight is a single token representing "a deployment can be started." When the agent starts a build, the token moves from idle to building. It's no longer in idle — no second deployment can start. The token moves through the pipeline: building → tested → staged → canary. It returns to idle only when the agent promotes or rolls back.
There's exactly one token. It can be in one of five places. That's the entire state space — five reachable states, each representing a stage of the deployment. The agent's creativity is unconstrained between transitions. The net doesn't say "now do step 3." It says "the deployment is currently in tested, and the only deployment actions available are stage or do something else entirely."
Build the net
This is a hand-crafted SkillNet, not a compiled .rules file. The rules DSL handles simple gates; for real workflows, you write the net directly:
import { defineSkillNet } from "@petriflow/gate";
type Place = "idle" | "building" | "tested" | "staged" | "canary";
export const deploymentPipelineNet = defineSkillNet<Place>({
name: "deployment-pipeline",
places: ["idle", "building", "tested", "staged", "canary"],
terminalPlaces: [],
freeTools: [],
initialMarking: { idle: 1, building: 0, tested: 0, staged: 0, canary: 0 },
transitions: [
{ name: "build", type: "auto", inputs: ["idle"], outputs: ["building"], tools: ["deploy-build"], deferred: true },
{ name: "test", type: "auto", inputs: ["building"], outputs: ["tested"], tools: ["deploy-test"], deferred: true },
{ name: "stage", type: "auto", inputs: ["tested"], outputs: ["staged"], tools: ["deploy-stage"], deferred: true },
{ name: "canary", type: "auto", inputs: ["staged"], outputs: ["canary"], tools: ["deploy-canary"], deferred: true },
{ name: "promote", type: "auto", inputs: ["canary"], outputs: ["idle"], tools: ["deploy-promote"], deferred: true },
// Rollback from canary or staging — both restore the deploy slot
{ name: "rollback-canary", type: "auto", inputs: ["canary"], outputs: ["idle"], tools: ["deploy-rollback"], deferred: true },
{ name: "rollback-staging", type: "auto", inputs: ["staged"], outputs: ["idle"], tools: ["deploy-rollback"], deferred: true },
],
toolMapper(event) {
if (event.toolName !== "exec" && event.toolName !== "process") return event.toolName;
const cmd = (event.input?.command as string) ?? "";
if (/\bdeploy\s+build\b/.test(cmd)) return "deploy-build";
if (/\bdeploy\s+test\b/.test(cmd)) return "deploy-test";
if (/\bdeploy\s+stage\b/.test(cmd)) return "deploy-stage";
if (/\bdeploy\s+canary\b/.test(cmd)) return "deploy-canary";
if (/\bdeploy\s+promote\b/.test(cmd)) return "deploy-promote";
if (/\bdeploy\s+rollback\b/.test(cmd)) return "deploy-rollback";
return event.toolName;
},
}); What makes this different from rules
- Resource token: The
idleplace starts with 1 token. Whenbuildfires, it moves tobuilding. No second deployment can start becauseidleis empty. - Branching: From
canary, the agent canpromote(happy path) orrollback(unhappy path). Both return the token toidle. A simple sequence rule can't express OR paths. - Deferred transitions: Every transition is deferred — the tool call is allowed immediately, but the net only advances on success. A failed build doesn't consume the deploy slot.
- Tool mapper: Maps
execcommands likedeploy buildto virtual tool names likedeploy-build. The net gates the virtual names.
Two-layer composition
The workflow net composes with simple rules via AND-logic. Both layers check every tool call; a tool is allowed only if all nets agree.
import { compile } from "@petriflow/rules";
import { createPetriflowGate } from "@petriflow/vercel-ai";
import { readFileSync } from "fs";
import { deploymentPipelineNet } from "./deployment-pipeline";
// Layer 1: Simple rules (blocks, sequences, rate limits)
const rulesText = readFileSync("./safety.rules", "utf-8");
const { nets: ruleNets } = compile(rulesText);
// Layer 2: Workflow nets (stateful, branching, resource tokens)
const allNets = [...ruleNets, deploymentPipelineNet];
// Both layers compose via AND-logic
const gate = createPetriflowGate(allNets, {
confirm: askApproval,
});
const tools = gate.wrapTools({ /* your tools */ }); The same exec tool call passes through both layers independently:
- Rules layer: maps
execto a virtual tool (e.g.git-push), checks blocks, sequences, and rate limits. - Workflow layer: maps the same
execto a deployment action (e.g.deploy-build), checks the pipeline state.
Each layer has its own toolMapper, its own state, its own verification. Neither interferes with the other. An agent can git push freely when no deployment is active — the workflow net abstains because git push doesn't map to any deploy-* tool.
Verify it
The formal verification proves three properties that if/then rules cannot express:
$ bun run verify.ts
Rules: 56 nets from safety.rules
Deployment pipeline: 5 reachable states
Deadlock-free: true
Token conservation (1 token always): true
Terminal states: 0
States: idle, building, tested, staged, canary
Total: 57 nets, 62 unique gated tools | Deadlock-free | From every reachable state, the agent has a valid next step. A deployment can always complete — the agent is never stuck. |
| Token conservation | Exactly one token exists across all five places at all times. This proves mutual exclusion: only one deployment can be in progress. Not a check — a mathematical invariant. |
| Zero terminal states | The net always cycles back to idle via promote or rollback. The deploy slot is always eventually restored. |
See it in action
An agent walks through the deployment pipeline, tries to skip ahead, and handles a rollback:
BLOCKED pipeline is in
idle — must deploy build firstOK token moves idle → building. Deploy slot consumed.
FREE non-deployment tools are unconstrained
OK token moves building → tested
OK token moves tested → staged
BLOCKED deploy slot is empty — mutual exclusion
OK token moves staged → canary, 5% traffic routed
ROLLED BACK token returns to idle. Deploy slot restored.
READY full pipeline cycle complete
Key observations:
- The agent is free between transitions. It can read files, search the web, debug failures, write code — the net only constrains deployment actions.
- Mutual exclusion is structural. A second deployment is blocked because the token is physically not in
idle. No prompt can change this. - Rollback restores the token. The agent isn't stuck after a bad canary — rollback returns to
idleand the cycle can restart. - This composes with the rules layer.
deploy buildalso needs to pass any applicable rules (rate limits, etc.).
Why not if/then rules?
The .rules DSL from previous tutorials compiles to tiny nets (2–3 reachable states). They're powerful but stateless — each rule resets after every cycle. They can't express:
| Mutual exclusion | A resource token that's consumed and restored. Simple sequence rules reset after each use — they have no concept of "in progress." |
| Branching | Two paths from one state (promote OR rollback). Sequence rules are linear: A before B, period. |
| Multi-step state | A workflow that persists across tool calls. Sequence rules track one prerequisite, not a five-step pipeline. |
| Verified invariants | "Exactly one token always" is a provable invariant. "The deploy slot is always eventually restored" is a liveness property. If/then rules don't have a state space to verify. |
This is why PetriFlow uses Petri nets as its core primitive, not a config file. Simple constraints get the simple DSL. Real workflows get the full power of the net. Both compose in the same agent.
Concepts
| Resource tokens | Places with tokens represent resources (deploy slots, locks, budgets). Transitions consume and produce tokens, modeling resource contention. |
| Mutual exclusion | A single token in a shared place ensures only one workflow instance at a time. Proved by a place invariant: sum of all place tokens = 1. |
| Workflow branching | Multiple transitions from one place model non-deterministic outcomes (promote vs rollback). Both restore the resource. |
| Two-layer composition | Simple rules (DSL) + workflow nets (hand-crafted), composed via AND-logic. Each layer has its own tool mapper and verification. |
| Formal verification | Deadlock freedom, token conservation, and zero terminal states are mathematical properties of the net, not runtime checks. |
Next steps
| Gate reference | SkillNet type, deferred transitions, toolMapper, and multi-net composition internals. |
| Rules Engine reference | Full DSL syntax for the simple rules layer. |
| Vercel AI SDK docs | Wiring PetriFlow into Vercel AI agents. |