A Step declares, a Strategy implements, a Runner executes - #4
Open
borisdev wants to merge 12 commits into
Open
Conversation
`Step.run()` asserted that a Step has ONE implementation and that it is a method
on the class. It does not. `ExtractClaims` is a stable operation with several ways
to perform it, and none of them is the real one the others override.
Removed, and RETIRED rather than deleted — a subclass declaring `run` again would
get the privileged implementation back with nothing to say so.
Measured before the change, not argued after:
- nothing called it: not in plan_types/, tests/, evals/ or examples/
- examples/evidence_case_graph/flow.py, its only implementation here, did not
import AT ALL — it still declared `consumes`, retired 2026-08-16 — and its
run() took a POSITIONAL arg against the keywords-only contract. No test
imported it, so no check could have caught either fault. Both examples now
run in tests/test_execution.py.
- plan_types/plan/plan.py has said "run() is not the centre of this" and
"an execution adapter wraps Steps from outside, never the reverse" throughout.
New: plan_types/execution/ — Strategy (a mapping), check_strategy (static, and it
says NOT CHECKED when it cannot read a signature), the StepRunner Protocol, and
LocalRunner. Sequential, in-process, no retries. It refuses an async
implementation rather than returning an un-awaited coroutine that flows onward as
though it were data.
⚠️ Named Strategy, not `bindings`, which the four handoffs all used: this repo
already spends that word on how VARIABLES connect STEPS
(plan_types/plan/bindings.py). One name, two concepts is the
naming.ambiguous_reference this package exists to report.
⚠️ BREAKING for nobsmed on a tag bump — measured against the built wheel, not
inferred: plans.py fails at IMPORT, because its 15 declaration-only run()
overrides now hit the retirement guard. Contained by the v0.6.0 pin. The fix is
one commit there: delete the 15 methods, _DECLARATION_ONLY, and
test_plans.py:56-65. A loud break is right — the quiet alternative leaves 15
methods that read as evidence that Steps execute.
Also: the README's mermaid block was hand-edited (node ids `s0`/`s1`, which this
renderer has never emitted) and the test guarding it rebuilt an equivalent Plan
from local fixtures and grepped for three edges. It now asserts the block is
byte-identical to render_mermaid(examples.hello.flow.plan).
Full rationale, the four handoffs' three contradictions, and the nobsmed
migration: docs/handoffs-consolidated.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`grep -c` exits 1 when it counts ZERO. `set -e` was restored one line above the
count, and the step runs under `bash -e {0}`, so the job died before its first
echo — no findings printed, no "findings: N baseline: M", just exit 1.
main has been red since 2026-08-17, three runs, none of them about the code. The
baseline reaching 0 is what triggered it: the gate was green only while something
was wrong.
Reproduced under `bash -e` before changing anything:
old (silence) exit=1
new findings: 0 baseline: 0 exit=0
Fixed by leaving `set -e` off across the count rather than adding `|| true` — the
comment directly above explains why `|| true` is wrong here, and it is: it is how
the same gate in nobsmed counted a usage message as 0 findings and went green.
The RC check that comment protects is unchanged.
`.claude/rules/checks.md`: a check that goes red when everything is fine is one
nobody reads when it is right.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes, both positioning. **"Workflow" is the category word**, and the README never said it. LangGraph, Temporal and Pydantic Graph all execute a workflow; this is the layer above them. A cold reader could not place the package, because the one word that would have placed it was missing — P-Plan is the grounding, not the pitch (docs/gtm.md already says do not lead with the ontology). Also states the reason for the split in the terms that motivate it: cognitive load. Working inside an execution framework means reasoning about domain concepts, schemas, step boundaries, dependencies, async, retries, timeouts, workers and serialization at once, and half of those have nothing to do with whether the process is right. **"Open question, deliberately" is now answered.** Semantic invariants as the general product needs a corpus of trial and error that does not exist, and is hard to grasp before it is demonstrated. PlanTypes is the product; conceptlint is a supporting linter. The code stays arranged so it could still split out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntimes
The claim this package makes is that the process specification and the execution
machinery are separable. Until now that was prose. `to_pydantic_graph(plan,
strategy)` makes it executable: the same Plan and the same Strategy run under
LocalRunner and under Pydantic Graph, and tests/test_pydantic_graph.py asserts
the two answers are identical.
That is also the pitch to anyone who already has a workflow engine: this does not
compete with one. Decide whether the process is RIGHT — before retries, workers,
state and serialization are in the room — then hand it over unchanged.
The compile is honest about its limits, in the docstring and in tests:
- acyclic only. execution_order raises on a cycle rather than inventing an
order, and a cyclic plan is exactly where their engine is the right answer.
Pydantic Graph's own three examples are all cyclic state machines.
- no Fork/Join yet. Pydantic Graph 2.x has both, and a Plan's bindings already
say which Steps are independent, so parallelising is DERIVABLE rather than
declarable — which is the version worth building, and it is not built.
- no state/deps. A Step implementation's dependencies live in its closure.
pydantic-graph is an OPTIONAL extra. plan_types/plan/ imports no execution
framework and a test now asserts that by reading the source, rather than trusting
that nobody added one.
The worked example is Pydantic Graph's own email-feedback workflow, in its acyclic
core, plus the one thing none of their three examples has: a fan-in. `Revise`
needs the draft AND the critique of it. In a node-returns-next-node model the
draft has to be carried in mutable state to still be there two hops later; as an
edge it is visible in the declaration and topology.bound_inputs can check it.
README's "Not built: ... Pydantic Graph" line was false the moment this landed, so
it moved to "Also works today". A status section that overstates is the failure
this package exists to report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Working against Pydantic Graph's own documented examples, copied verbatim, so the comparison is against what they actually ship rather than a strawman. STAGE 1 — examples/pydantic_graph_docs/stage1_counter.py Their `simple_counter.py` from the Quick Start, unchanged, is in the file next to ours. Three results asserted equal: their hand-wired GraphBuilder, our Plan on LocalRunner, and our Plan compiled onto their runtime. All 2. The one substantive difference is not cosmetic and is worth the whole stage: their `increment` declares its input as None and threads the counter through `ctx.state.value += 1`. Ours makes it an EDGE, so the dependency is in the diagram rather than in a function body. Same answer either way. Note the render shows `topology.bound_inputs: NOT CHECKED` on their counter, because that Plan genuinely has no inputs. It reports what it did not check rather than a green tick. STAGE 2 — examples/pydantic_graph_docs/stage2_strategies.py Their `parallel_processing.py` domain. One Plan, constructed once, three implementations of ONE Step, and an eval table: input expected exact repeated_addition cheap_approximation [12] 144 144 ok 144 ok 120 WRONG [3, 20] 409 409 ok 409 ok 209 WRONG The rows are comparable because the Plan is the same OBJECT across arms — a test asserts that by identity, not equality, since a Plan rebuilt per arm could drift a Step and still compare equal under a weaker check. render_mermaid(plan, strategy) — new, and the point of it is visual: the three arms render with identical topology and different implementation labels, so "the logical process was held constant, only the implementation changed" is something a reader can check by looking. A test strips the labels and asserts the three renders are byte-identical underneath. An unbound Step gets NO label rather than "unbound" or "?" — a diagram that invents a label is asserting something about a Strategy nobody wrote. Still to come: stage 3 (variants that add nodes while sharing most), then the control arm — the same three stages written directly against GraphBuilder with no Plan layer, which is the only way to answer whether any of this is easier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old message was "there is no execution order to return", which reads as a limitation of Kahn's algorithm. It is not, and believing it sends the next person to write a cyclic scheduler. Given a perfect cyclic scheduler this would still loop forever, because a Plan does not carry a TERMINATION PREDICATE. pydantic-graph puts that in the node body (`-> Next | End[T]`), which is the right place for it and is theirs. Adding Decision/branch/End here would be rebuilding what GraphBuilder already owns. Verified what a cyclic Plan can already do, since the refusal is narrower than it sounded: construction, edges, render_mermaid and topology.acyclic ALL work on one today. Only execution refuses. `Plan` was never a DAG and this does not change that. The error now names the route that would work instead of only saying no: collapse each strongly connected component into a MultiStep (p-plan:MultiStep, a Plan that appears as a Step). An SCC condensation is always a DAG, so the outer Plan compiles and the loop inside goes to their state machine. Not implemented — named, so the refusal points somewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`class MultiStep(Plan)` — one parent. `Plan.__post_init__` requires every entry in `steps` to be a Step, so nesting a Plan inside a Plan raised. The docstring cited p-plan:MultiStep as rdfs:subClassOf BOTH Plan and Step; the code implemented one of the two. Nothing caught it because nothing had ever nested a Plan — the exact shape of failure provenance.grounded_citation was written for, in the class that carries the IRI. Now `class MultiStep(Plan, Step)`. Its ports are DERIVED: Plan.inputs and Plan.outputs already mean free and terminal Variables, which is what this node consumes and produces. Declaring them again would be a second source of truth. Step.__init_subclass__ now skips a port that is a descriptor rather than a tuple, because "derived" is a legal answer there. Adds `until` — the termination predicate, plan-time only. A Plan that says WriteEmail -> Feedback -> WriteEmail never says when to stop, so no scheduler could run it however good; `until` names the Variable whose value decides. The TEST is a function and therefore an implementation, so it lives in the Strategy, same split as Step. That keeps Decision/branching/End out of the Plan layer where pydantic-graph and LangGraph already own them. `until` is ours, deliberately uncited: P-Plan's 18 terms are Plan/Step/Variable structure, PROV-O describes executions, and neither has a word for "this planned region repeats until". Deferred deliberately: SCC condensation as an analysis, the terminating_iteration invariant, and compiling an iterative MultiStep. None is needed for the MVP — both docs examples we run are acyclic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three variants that ADD nodes while sharing most, against the same three written
directly in GraphBuilder with no Plan layer. Both arms produce identical numbers,
asserted in a test — the comparison is worthless otherwise.
input baseline filtered weighted
[1, 2, 50] 2505 5 10
MEASURED, wiring and declarations only, shared implementations excluded from both:
with a Plan 12 lines 6 Step classes + 3 Plan constructions
control 43 lines 3 build_* functions
`async def sq` declared 3x in the control, once with a Plan
`async def total` declared 3x in the control, once with a Plan
At three variants the Plan layer is ~3.5x less wiring, and the duplication in the
control is the interesting part rather than the line count: three `sq` functions
that nothing says are the same operation, so a change to one is silent in the
other two.
What the control cannot do, and this is not a strawman — it is short, readable
and idiomatic:
- answer "do these three share a contract?" without reading three functions.
check_arms answers it by type.
- produce a structural diff. `filtered -> weighted` reports shared
{DropOutliers, Square}, added {Weight, TotalWeighted} off the declarations;
the control's only diff is a diff of wiring code, where a renamed local looks
like a changed process.
⚠️ AND WHERE THE PLAN LAYER LOSES, which belongs in the same commit:
`Total`, `TotalKept` and `TotalWeighted` are three Step classes doing one job —
sum a list — differing only in WHICH Variable they consume. That is three names
for one concept, the naming.naming_drift this package reports, caused by our own
design: a Step's port is bound to a Variable's identity, not its type.
The control has no such problem: its `total` takes whatever arrives. So explicit
typed dataflow bought the diff and the shared declaration, and charged a Step
class per wiring position. Whether a port should be declarable by TYPE is a real
design question and is not answered here.
Stage 1 remains the other honest data point: at one arm and two steps the Plan
layer was pure overhead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… actual docs
Boris was right and the first stage 3 was a strawman I built for myself. It
invented three topology variants (DropOutliers, Weight — neither in their docs)
to demonstrate structural diffing, and produced Total/TotalKept/TotalWeighted:
three Step classes doing one job. I read that as a design flaw and drafted a fix.
It was not a design flaw. All three were ONE Plan —
numbers -> [transform] -> transformed -> [sum] -> total
— with three implementations of the transform. Two Step classes, not six. The
duplication was the naming rule REPORTING that I had drawn the Step boundaries in
the wrong place. The proposed fix is cancelled: its motivating case evaporated.
FAN-OUT, which was the real gap:
class Square(Step):
inputs, outputs = (number,), (squared,) # int -> int, exactly their `square`
map_over = (numbers, squares) # list[int] in, list[int] out
Their vocabulary — map, join, reducer — because a Plan that fans out is
describing what pydantic-graph and LangGraph already have words for, and a third
word for it would be the drift this package reports.
It is declared on the Step because our edges are DERIVED: two Steps are connected
when they share a Variable, so there is no edge object to hang `.map()` on.
`wired_inputs`/`wired_outputs` in step.py is what keeps this from spreading: the
Plan sees `numbers -> Square -> squares` and only the runner sees the per-item
call, so no invariant had to learn about mapping and none of them could learn it
slightly differently. bindings.py, plan.py and the renderer all read it.
The compiler now emits their REAL primitives rather than a loop wearing the name:
emit --.map()--> per-item step --> join(reduce_list_append) --> store
The Plan's environment moved into their `state`, which is what state is for and
how their own examples carry values across edges. So one declaration is a
sequential loop under LocalRunner and an actual parallel map on their engine,
with no edit. Their documented output, [1, 4, 9, 16, 25], is asserted on both.
MEASURED on their parallel_processing.py, three implementations of one Step:
with a Plan 5 lines 2 Step classes; 3 arms are 3 dict entries
control 37 lines 3 build_* functions
square 3x, total 3x, g.join( 3x, .map() 4x
Stage 1 remains the honest other end: one arm, two steps, the Plan layer is pure
overhead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
⚠️ RETRACTED: "5 lines with a Plan, 37 for the control". The 37 was three copy-pasted builders. The obvious control parameterises, handles all three arms in twelve lines, and produces identical results — verified. Measuring against code nobody would write is not a measurement, and a reader who spotted it would have been right to discard everything near it. control_no_plan.py is now the fair version and there is no line-count claim anywhere. What survives, and is now what the README leads with: THE PLAN EXISTS BEFORE THE CODE DOES. validate() and render_mermaid() run with nothing implemented — no prompt, no model, no function body. Their build() cannot produce a graph, a diagram or a type check until an implementation exists, because their node IS the implementation. Adds a "How is this different from Pydantic Graph?" section that concedes first: they own steps, typed execution, edges, branching, map, join, reducers, state, DI, execution and rendering, and this compiles ONTO them. Then the two diagrams of the same workflow, side by side, showing the two differences that are modelling rather than rendering: - their edges carry no types, and cannot: an edge holds whatever the function returned and there is no name for it. Ours labels every edge with its Variable. - `map <<fork>>` and `reduce_list_append <<join>>` are NODES in their diagram. Theirs draws the machinery; ours draws the process. Status now states the four objections up front — cycles do not run and why, Fork/Join are not first-class, no async runner, and at one arm this is pure overhead — plus the scale caveat: everything demonstrable here is small, and the failure it is built for shows up at 18 pipeline variants, not at 2 steps. CONCEPTLINT CAUGHT SOMETHING OF MINE, which is the first time this session: naming.ambiguous_reference: Square is declared twice with different shapes Square (stage2_strategies.py:34), Square (stage3_map_join.py:43) Two Step classes with one name and different shapes, written an hour apart, in the examples FOR this package. Fixed the way the finding says: one declaration in their_example.py, imported by both stages — which is also the reuse the package claims you get. Repo is back to its 6 pre-existing findings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…other `uv run conceptlint plan_types/`. I put a duplicate `Square` in examples/pydantic_graph_docs/ and nothing said a word — not CI, not me — until Boris asked whether the semantic invariants were earning their place and I ran the tool to answer. Where it was reported: nowhere. It was never emitted. The narrowing was deliberate and the reason was good — evals/minimal/ holds before/after pairs that exist to BE duplicates, so linting them measures the fixtures. But excluding a path excludes everything in it, and this one quietly took examples/ and tests/ with it.⚠️ `.conceptlint-baseline` already carried this exact warning about a DIFFERENT directory: "a gate pointed at the wrong directory reads exactly like a clean codebase." It happened again, one directory over, in the file that warns about it. Now lints `.` with a baseline of 6, carrying the known-deliberate duplicates IN the number where they are visible rather than behind a path exclusion where they are not. Simulated before committing: findings 6, baseline 6, passes; it would have gone red at 8 when my duplicate landed. The step comment claimed "`conceptlint/` and not `.` on purpose" and is corrected rather than left — a note that outlives its fix is how the last three of these were read as current. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The P-Plan section explained the grounding as correctness discipline — we cite
IRIs and we check them. That is true and it was not the interesting half.
The interesting half is that a Plan is meant to be HANDED OVER, and a private
schema cannot be. `p-plan:Step` has a definition we did not write, at an address
anyone can resolve, so one artifact serves three audiences who never read each
other's code:
owns WHAT THE PROCESS IS writes and validates the Plan — no prompt, no
model, no function body, no runtime
owns HOW IT IS PERFORMED binds a Strategy
owns HOW IT RUNS picks the engine, concurrency, retries
The Plan a product owner argues about in a PRD is the same object an engineer
compiles onto Pydantic Graph. What makes it arguable — typed Steps, named edges,
no runtime detail — is what makes it compilable.
And standard-rather-than-ours is what leaves the door open past this package:
another tool that speaks P-Plan means the same thing by `Step`, and a model asked
for a `p-plan:Plan` is asked for a term with a published definition it can be held
to rather than a private shape inferred from examples.
⚠️ A DOOR, NOT A FEATURE, and the README says so in the same breath. There is no
RDF import or export — no Turtle, no JSON-LD, no rdflib anywhere in the package.
Checked before writing the paragraph, not assumed.
tests/test_plan_types.py now guards it in BOTH directions: the disclaimer must be
present, AND no RDF machinery may quietly appear that would make the disclaimer
stale by understating what ships. A claim about what is missing goes stale exactly
as silently as a claim about what exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A Step declares, a Strategy implements, a Runner executes
Four ChatGPT handoffs about this repo, consolidated. They converge on one decision, contradict each
other in three places, and one of them contradicts this repo's own vocabulary. This doc is the
decision, the disagreements resolved, and what was measured rather than argued.
The decision, in code
class Step(Generic[InputT, OutputT]): inputs: ClassVar[tuple[Variable[Any], ...]] = () outputs: ClassVar[tuple[Variable[Any], ...]] = () - def run(self, **values: Any) -> Any: - """Execute. Keyword arguments are the input Variables, by name.""" - raise NotImplementedErrorThe
planobject is not touched between those two lines.Why — the argument that actually carries it
The weak argument is YAGNI: nothing called
run(), so delete it. True, and it sounds like tidying.The real argument is that the signature was a false claim about the domain:
ExtractClaimsis one stable operation with several ways to perform it — a cheap model, anexpensive one, an ensemble — and none of them is the real one that the others override. A method
on the class privileges whichever implementation got there first.
The alternative people reach for when the method is in the way is worse:
So the implementation moves out of the declaration, and which implementation runs becomes a
property of an execution rather than of the process.
Every handoff calls the Step→implementation mapping "bindings". This repo already has that word:
Two concepts, one word, in one package. That is
naming.ambiguous_reference— one name, manyconcepts — and it would have shipped inside the module written to catch it.
Shipped as
Strategy, which is the handoffs' own gloss ("different strategies for performingthe same operation") and is what makes
plan_types/plan/bindings.pystill mean exactly what itmeant. One rename reverses it if you disagree; the thing that must not happen is the collision.
Grounding, stated plainly: P-Plan and PROV-O have no term for "the code that will perform this
Step if it runs."
p-plan:Stepis the intended operation,prov:Activityis one execution of it,and this is neither. So
Strategyis ours, deliberately uncited — the same call asStep.uses, andfor the same reason.
Where the four handoffs disagree
Step.run(), "portable domain vocabulary" · B: not on Step,bind()+ callable · C:step.impl, called by a Protocol · D: abindingsmap, resolved by a runnerasync def run(...)in the Protocolasync defon the Protocol is not neutral — it forces every implementation of every Step to be a coroutine, includinglambda x: x + 1. AnAsyncStepRunnerwhen a real async implementation exists.extract_claims_v1(paper: Paper)— positional · D, same page:impl(**inputs)— keywordVariable.nametherefore becomes part of the contract. A Step with three inputs called positionally is one reorder away from a mis-wire the types cannot catch when two inputs share a type.check_strategyreports a parameter-name mismatch at declaration time.plan/+execution/plan_types/plan/+plan_types/execution/, which is whereplan/already was.EvidenceBuildState,NoBSmedDepsinjected per executionplan_types/plan/service.pyis already the plan-time half — what a Step needs reachable — andtyping.plan_time_onlyrefuses the runtime half onto plan types by rule.Evidence — measured before the change, not argued after
Nothing called
run(). Not inplan_types/,tests/,evals/orexamples/.The only implementation of it in this repo did not import.
examples/evidence_case_graph/flow.pystill declaredconsumes/produces, retired 2026-08-16:It had been dead since the P-Plan DAG correction, and no test imported it, so no check could
have caught that or the second fault on the line below —
def run(self, value: Study), positional,against a base class whose docstring warns positional args silently mis-wire. An example nobody
imports is not an example, it is a claim.
tests/test_execution.py::test_examples_import_and_runnow runs both examples end to end.
The type contradicted its own module.
plan_types/plan/plan.pyhas said this the whole time:Downstream, 15 overrides that all raise. nobsmed's
plans.py, every oneraise NotImplementedError(_DECLARATION_ONLY).What shipped
142 tests pass, up from 122.
_RETIRED, not deletion. Deletingrun()would let a subclass declare it again and get theprivileged implementation back with nothing to say so. The error names its replacement:
__init_subclass__built its message with'Input' if old == 'consumes' else 'Output',so retiring a third name would have explained it as being about
hasOutputVar. Each retiredname now carries its own sentence.
What the runner refuses, loudly, rather than accepting quietly:
async defimplementation(name, type), so two can differ in type and collide in a name-keyed environmentI previously wrote that nobsmed's 15
run()overrides "still import and work as ordinary methods."That is wrong. It described deleting
run(); what shipped retires it, which is stricter. Runagainst the built wheel, using
plans.py's exact class shape:Contained by the pin. nobsmed has
plan-types = { git = ..., tag = "v0.6.0" }(
pyproject.toml:68), so nothing moves until that tag is bumped — deliberately, not by luck.On bump, one nobsmed commit fixes all of it:
plans.py— 15def run(...)overridesplans.py—_DECLARATION_ONLYtest_plans.py:56-65— assertsrun()raisesplan_diagram.py:190— "Everyrun()raises"Step.runA loud break is the right one here. The quiet alternative leaves 15 methods in the file that a
reader reasonably takes as evidence that Steps execute — which is the thing that was false. And the
fix is mechanical: delete, do not rewrite.
Advice — where I would push back on the handoffs
"A dictionary plus a LocalRunner is sufficient to prove the architecture." It proves the
architecture; it does not give it a consumer. nobsmed's builders are not decomposed into Steps
(
plans.pysays so and calls it separate work with its own risk), so nothing in production bindsa Strategy yet. The examples and tests are real; the production caller is not there. Worth
knowing before this reads as load-bearing.
Handoff D §1 asks to edit nobsmed's
plans.py. Different repo, different PR, and gated on thetag bump above.
Step(Generic[InputT, OutputT])is still declared and is now entirely vestigial.InputTandOutputTare unused — the README already says that one-in-one-out signature "died to a realbuilder." Left alone here to keep this diff about one thing; it should go.
Do not add the Temporal / LangGraph / Pydantic Graph adapters yet. Each is a real dependency
bought to serve zero callers. The Protocol is three lines; an adapter can be written the day someone
has a workflow that needs durability.
Not built, and what would justify each
AsyncStepRunner— one real async implementation to run. Today the honest state is that asyncis refused, not supported, and it says so at the call site.
different implementations. Keyed on the class today.
list[Finding]cannot be tested byisinstance. ReportedNOT CHECKED. A real mis-wire that slipped through would justify a deeper check.
EvidenceBuildState,NoBSmedDeps) — a Stepimplementation that genuinely needs per-execution context beyond its closure.
Deliberately out of scope
The A/B counterfactual experiment from the process-spec handoff: fork an official Pydantic Graph
example, evolve it with Claude Code alone vs Claude Code + PlanTypes, same requirement changes to
both arms, and measure. That is GTM evidence, not this decision, and it deserves its own issue.
docs/counterfactual.md's rule applies to it in advance:no_effectandrefutesare results.The claim to test is does a separate typed process specification help a coding agent preserve
coherence as requirements evolve — not PlanTypes makes Claude better.