Describes the shape of a workflow, deliberately leaving out execution — retries, durability, fan-in/out, concurrency. The intent is to let you experiment with the algorithm separately from debugging the execution.
The Python package is workflow_plan.
This is Pydantic Graph's simple_counter.py,
verbatim:
g = GraphBuilder(state_type=CounterState, output_type=int)
@g.step
async def increment(ctx: StepContext[CounterState, None, None]) -> int:
ctx.state.value += 1
return ctx.state.value
@g.step
async def double_it(ctx: StepContext[CounterState, None, int]) -> int:
return ctx.inputs * 2
g.add(
g.edge_from(g.start_node).to(increment),
g.edge_from(increment).to(double_it),
g.edge_from(double_it).to(g.end_node),
)Here is the same workflow as a Plan. Nothing is implemented:
count = Variable("count", int)
doubled = Variable("doubled", int)
class Increment(PlanStep):
inputs, outputs = (), (count,)
class DoubleIt(PlanStep):
inputs, outputs = (count,), (doubled,)
plan = Plan(name="counter", steps=(Increment(), DoubleIt()))Two differences, and only the second is the point.
Their increment writes ctx.state.value and declares its input type as None. The dependency
between the two steps is real and does not appear in the graph. Here it is an edge, because
Increment produces count and DoubleIt consumes it — that is what makes them adjacent, and no
one wrote the edge down twice:
flowchart TD
counter_0["Increment"]
counter_1["Double It"]
OUT_doubled(["doubled: int"])
counter_0 -- count --> counter_1
counter_1 --> OUT_doubled
classDef port fill:#fff,stroke:#333,stroke-width:1px,color:#333;
class OUT_doubled port;
And the Plan has no bodies. It type-checks, renders and validates before any function exists. That is the trade: you give up being able to run it, and you get to argue about the shape without a runtime in the room.
Source: examples/pydantic_graph_docs/stage1_counter.py.
You write the graph. Keep their GraphBuilder, keep their edges, keep every runtime feature —
and let each node body call the implementation the Plan has bound:
@g.step
async def increment(ctx: StepContext[None, None, None]) -> int:
return strategy[Increment]() # the Plan's binding, not a body
@g.step
async def double_it(ctx: StepContext[None, None, int]) -> int:
return strategy[DoubleIt](count=ctx.inputs) # the keyword IS the Variable nameSix lines of glue and no compiler. What the Plan is still doing while you hold the pen:
| the declaration | Increment produces count, DoubleIt consumes it — checkable with no bodies |
| the binding | strategy[DoubleIt] — one Step, several peer implementations, chosen per run |
| the diagram | derived from the same declaration your nodes call |
The test that matters is not that both return 2. It is that rebinding one entry in the Strategy
moves both sides, because there is one function and two call sites — see
test_the_hand_written_wrapper_and_the_plan_run_the_same_function.
Source: examples/pydantic_graph_docs/hand_written_wrapper.py.
to_pydantic_graph(plan, strategy) generates the whole graph, including their .map() and
join(reduce_list_append) for a fan-out:
graph = to_pydantic_graph(plan, strategy)
result = await graph.run(state={}, inputs={"document": doc})It exists because it proves the declaration is complete enough to compile, and
tests/test_pydantic_graph.py asserts both runtimes return the same result. But generating
somebody's graph invites "why would I let you generate my graph?", which has no good answer. The
hand-written wrapper above is the integration to reach for.
Not on PyPI. From source:
git clone https://github.com/borisdev/workflow-plan && cd workflow-plan
uv sync # add --extra pydantic-graph for the Pydantic Graph adapter
uv run pytest -q| Type | What it is |
|---|---|
Variable[T] |
a named, typed value that flows between steps |
PlanStep |
one operation: which Variables it consumes, which it produces |
Plan |
a set of Steps; the edges are worked out from the Variables they share |
PlanDependency |
something a step needs to be reachable — an API, a database, a file on disk |
MultiStep |
a Plan that is also a step, so plans can nest |
Strategy |
a plain dict saying which function implements which step |
StepRunner |
the protocol for executing one step |
SequentialRunner |
runs the steps one at a time, in this process |
run(plan, inputs, runner) |
works out the order from the edges and runs it |
Plan, PlanStep and Variable are taken from P-Plan; Activity,
Entity and Agent from PROV-O.
from workflow_plan import Plan, PlanStep, Variable, render_mermaid, check
from workflow_plan.invariants import topology, typing
document = Variable("document", Document)
outline = Variable("outline", Outline)
summary = Variable("summary", Summary)
class MakeOutline(PlanStep):
inputs, outputs = (document,), (outline,)
class Summarize(PlanStep):
inputs, outputs = (document, outline), (summary,) # two inputs
plan = Plan(
name="summarize_document",
steps=(MakeOutline(), Summarize()),
declared_inputs=(document,),
)
check(plan, [*topology.ALL, *typing.ALL]) # -> []
print(plan.shape()) # -> ((Document,), (Summary,))
print(render_mermaid(plan))flowchart TD
IN_document(["document: Document"])
summarize_document_0["Make Outline"]
summarize_document_1["Summarize"]
OUT_summary(["summary: Summary"])
IN_document -- document --> summarize_document_0
IN_document -- document --> summarize_document_1
summarize_document_0 -- outline --> summarize_document_1
summarize_document_1 --> OUT_summary
classDef port fill:#fff,stroke:#333,stroke-width:1px,color:#333;
class IN_document,OUT_summary port;
No part of this workflow is implemented. Edges are computed from the Variables the Steps share, so the diagram cannot disagree with the declaration.
Source: examples/hello/flow.py.
Implementations are ordinary functions, associated with Steps by a Strategy — a plain dict.
Parameter names must match the input Variable names, because the runner calls by keyword.
from workflow_plan.execution import SequentialRunner, check_strategy, run
def summarize_fast(document: Document, outline: Outline) -> Summary: ...
def summarize_precise(document: Document, outline: Outline) -> Summary: ...
fast = {MakeOutline: outline_by_sentence, Summarize: summarize_fast}
precise = {MakeOutline: outline_by_sentence, Summarize: summarize_precise}
check_strategy(plan, fast) # -> () ; reports unbound Steps and signature mismatches
run(plan, {"document": doc}, SequentialRunner(fast))
run(plan, {"document": doc}, SequentialRunner(precise))The same plan object serves both. One step may have several implementations without becoming
several steps.
render_mermaid(plan, strategy) labels each node with its bound implementation.
SequentialRunner is sequential and in-process: no concurrency, no retries, no durability. An
exception propagates.
A step that runs per item declares the list it maps over. map_over names the source and collected
Variables; inputs/outputs describe one item.
class Square(PlanStep):
inputs, outputs = (number,), (squared,) # int -> int
map_over = (numbers, squares) # list[int] -> list[int]Under SequentialRunner this is a sequential loop. Compiled, it emits Pydantic Graph's .map() and
join(reduce_list_append). The declaration is the same in both cases.
Pydantic Graph provides steps, typed execution, edges, branching, map, join, reducers, state, dependency injection, execution and diagram rendering. This library does not reimplement any of them.
The difference is what a step is. In Pydantic Graph the decorated function is both the graph node
and the implementation. Here a PlanStep is a declaration and implementations are bound separately,
so several are peers rather than one being primary.
examples/pydantic_graph_docs/ contains their documented examples
alongside equivalents, plus a control arm written with no plan layer. All of it runs in the test
suite.
uv sync --extra pydantic-graph
uv run python3 -m examples.pydantic_graph_docs.stage1_counter
uv run python3 -m examples.pydantic_graph_docs.hand_written_wrapper
uv run python3 -m examples.pydantic_graph_docs.stage3_map_join
uv run python3 -m examples.pydantic_graph_docs.control_no_planThe examples mix their code and ours, and it should not be necessary to guess:
| theirs | simple_counter.py and parallel_processing.py, copied from their docs; GraphBuilder, StepContext, .map(), join(reduce_list_append); the square operation and the numbers -> squares shape |
| ours | Total, added because their example stops at the joined list and summing gives an eval something to score; the three implementations square_exact, square_by_addition, square_cheap; every Plan, PlanStep and Variable declaration |
square_cheap is wrong above 10 on purpose — it is the arm an eval has to catch.
One Plan object, three dict entries:
input expected exact by_addition cheap
[1, 2, 3, 4, 5] 55 55 ok 55 ok 55 ok
[12] 144 144 ok 144 ok 120 WRONG
[3, 20] 409 409 ok 409 ok 209 WRONG
Rendering the three arms produces identical topology with different implementation labels. A test strips the labels and asserts the renders are byte-identical.
Pydantic Graph, via graph.render():
stateDiagram-v2
direction TB
state map <<fork>>
square
state reduce_list_append <<join>>
total
[*] --> map
map --> square
square --> reduce_list_append
reduce_list_append --> total
total --> [*]
This library, via render_mermaid(plan):
flowchart TD
IN_numbers(["numbers: list"])
parallel_processing_0["Square"]
parallel_processing_1["Total"]
OUT_total(["total: int"])
IN_numbers -- numbers --> parallel_processing_0
parallel_processing_0 -- squares --> parallel_processing_1
parallel_processing_1 --> OUT_total
classDef port fill:#fff,stroke:#333,stroke-width:1px,color:#333;
class IN_numbers,OUT_total port;
Two differences:
- Their edges carry no type labels. An edge holds whatever the function returned and has no declared name. Here each edge is labelled with the Variable that flows along it.
- Their diagram includes execution constructs —
mapas a fork node,reduce_list_appendas a join node. This diagram shows only the logical steps; the fan-out is a property ofSquare.
Both are correct for their purpose: one describes how the workflow executes, the other what it computes.
control_no_plan.py implements the same three
arms with GraphBuilder alone, in twelve lines, by parameterising the builder. It produces
identical results.
There is no line-count claim here. The measurable difference is that build(square_impl) requires
an implementation before it can produce a graph, a diagram or a type check, whereas a Plan
validates and renders with nothing implemented.
check(plan, invariants) runs a list of Invariants and returns a list of Violations rather than
raising on the first.
| category | checks |
|---|---|
naming |
one name used for two concepts; two names used for one concept |
typing |
bindings whose types do not match; plan-time types carrying runtime state |
topology |
cycles, unbound inputs, orphaned Variables, two producers for one Variable |
provenance |
an ONTOLOGY_IRI naming a term that does not exist in the vendored ontology |
A check that cannot run reports NOT CHECKED rather than passing. topology.bound_inputs does this
when a Plan omits declared_inputs, because derived inputs make an accidental gap and a deliberate
signature the same set.
Acyclicity is an invariant, not a property of the type. A Plan may be cyclic; topology.acyclic
reports it if you run that check.
p-plan:Step has a published definition, so a plan can be described to another tool or reader in
terms neither party owns. The ontologies are vendored with a hash and
provenance.grounded_citation verifies that every cited IRI names a real term.
There is no RDF import or export — no Turtle, no JSON-LD, no rdflib. The grounding is a checked
vocabulary, not a serialization format.
p-plan:Step is an intended operation; prov:Activity is one execution of it. The library keeps
them distinct even where a runtime maps them one to one. Temporal's Activity is prov:Activity,
not a PlanStep.
workflow_plan.ontology holds the PROV-O side as Pydantic models — Entity, Activity, Agent,
and Run (prov:Bundle, one execution of a Plan, with validated referential integrity between its
edges). None of it is populated by run(), which returns a plain dict. Producing a provenance
document from an execution is a feature these types were written for and it is not built.
Works today: typed Plans, the four invariant categories, render_mermaid, Strategy,
check_strategy, SequentialRunner, map_over fan-out, and to_pydantic_graph. 174 tests.
Not built: Temporal and LangGraph adapters, RDF import/export, persistence, retries, scheduling,
concurrency, an async StepRunner, embedding-based similarity, and agent-hook integration.
Specific constraints:
- Cyclic plans do not execute. A Plan may be cyclic and will render and validate, but a Plan
carries no termination predicate, so no scheduler could run one. Pydantic Graph places that
predicate in the node body (
-> Next | End[T]), which is where it belongs.MultiStep.untildeclares an iterative region; collapsing a strongly connected component into one is not implemented. ForkandJoinare not first-class. Onlymap_over.SequentialRunneris synchronous and rejects anasync defimplementation rather than returning an un-awaited coroutine. A compiled graph awaits it normally.- At one implementation the plan layer is overhead. With a single arm and two steps it costs a declaration and provides nothing. It becomes useful at the second arm or the second reader.
- Everything demonstrable here is small. The failure mode this addresses appears at scale: one
downstream codebase has 18 variants of one extraction pipeline, 23 distinct step names, 47% of
them used once, and
enrich,batch_enrichandenrich_one_findingin a single file.
conceptlint is a second package in this repository — a supporting linter, not a separate product.
It reports one name used for two meanings, and two names used for one, across ordinary Pydantic
models and the library's own declared vocabulary.
uv run workflow-plan lint . # self-lint; CI fails if the count rises above the baselineconceptlint/integrations/pre_write.py is a Claude Code PreToolUse hook that blocks a write which
introduces a Pydantic model duplicating an existing one:
⛔ `EvidenceLookup` looks like `EvidenceSearch`, which already exists at
libs/.../find_evidence.py:86
{"hooks": {"PreToolUse": [{"matcher": "Write|Edit",
"hooks": [{"type": "command",
"command": "<venv>/bin/python3",
"args": ["-m", "conceptlint.integrations.pre_write"]}]}]}}Use the args exec form. With a shell string, 2>/dev/null discards the message and || true
masks the exit code, either of which leaves a hook that runs and reports nothing.
Limits: it fires only on new Pydantic models, not on PlanStep subclasses; it exits 2 on a violation
and 0 on any error, so it never blocks work for an unrelated reason.
/lint-plan (.claude/commands/lint-plan.md) runs it over a whole
repository and reports by rule id.
uv run pytest -q
uv run workflow-plan lint .