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.

┌──────────────────────────────────────────────────┐ │ │ ▼ │ [idle:1] ──build──▶ [building] ──test──▶ [tested] │ │ stage │ │ │ ▼ │ [staged] ────┤ rollback │ │ canary │ │ │ ▼ │ [canary] ────┤ rollback │ │ promote │ │ │ └─────────┘

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 idle place starts with 1 token. When build fires, it moves to building. No second deployment can start because idle is empty.
  • Branching: From canary, the agent can promote (happy path) or rollback (unhappy path). Both return the token to idle. 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 exec commands like deploy build to virtual tool names like deploy-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 exec to a virtual tool (e.g. git-push), checks blocks, sequences, and rate limits.
  • Workflow layer: maps the same exec to 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-freeFrom every reachable state, the agent has a valid next step. A deployment can always complete — the agent is never stuck.
Token conservationExactly 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 statesThe 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:

// deployment pipeline with mutual exclusion
Agent calls deploy stage
BLOCKED pipeline is in idle — must deploy build first
Agent calls deploy build
OK token moves idle → building. Deploy slot consumed.
Agent reads logs, searches docs, fixes a test — no restrictions
FREE non-deployment tools are unconstrained
Agent calls deploy test
OK token moves building → tested
Agent calls deploy stage
OK token moves tested → staged
Another session tries deploy build
BLOCKED deploy slot is empty — mutual exclusion
Agent calls deploy canary
OK token moves staged → canary, 5% traffic routed
Metrics look bad — agent calls deploy rollback
ROLLED BACK token returns to idle. Deploy slot restored.
Agent can now start a new deployment — deploy build is available again
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 idle and the cycle can restart.
  • This composes with the rules layer. deploy build also 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 exclusionA resource token that's consumed and restored. Simple sequence rules reset after each use — they have no concept of "in progress."
BranchingTwo paths from one state (promote OR rollback). Sequence rules are linear: A before B, period.
Multi-step stateA 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 tokensPlaces with tokens represent resources (deploy slots, locks, budgets). Transitions consume and produce tokens, modeling resource contention.
Mutual exclusionA 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 branchingMultiple transitions from one place model non-deterministic outcomes (promote vs rollback). Both restore the resource.
Two-layer compositionSimple rules (DSL) + workflow nets (hand-crafted), composed via AND-logic. Each layer has its own tool mapper and verification.
Formal verificationDeadlock freedom, token conservation, and zero terminal states are mathematical properties of the net, not runtime checks.

Next steps

Gate referenceSkillNet type, deferred transitions, toolMapper, and multi-net composition internals.
Rules Engine referenceFull DSL syntax for the simple rules layer.
Vercel AI SDK docsWiring PetriFlow into Vercel AI agents.