Repository navigation
A GraphSpec's declared input_type/output_type is never checked — and check_subgraphs trusts it #21
Description
A fifth place, found while probing #12, and it is worse than the four in #1 because a check that
does run reads it as its oracle.
The failure, named first
A
GraphSpecdeclaresinput_type/output_type. Nothing compares them to the variables the
edges at START and END actually carry. So the design's public boundary — the one field a parent
trusts — can say anything.
class Lying(Greeting): # every edge in Greeting carries str
input_type, output_type = int, int
Lying().coherence_check() # []
Lying().coherence_check(trim_only) # []
Lying().render(trim_only).run_sync(inputs=" a b ") # 'Hello, a b!' <- a strClean, renders, runs, returns the wrong declared type. On its own that is a dead field, which
would be a small thing.
It is not a dead field — check_subgraphs believes it
_port_type compares a parent node's contract against child.input_type / child.output_type.
Those are exactly the values nothing verifies, so the check that exists to prove "the child fits
the node" proves "the child's claim about itself matches the node":
child alone: [] # Child declares output_type=int; its edges carry str end to end
parent+child: [] # the parent node declares an int output. check_subgraphs passes.
declared int, actually: str 'HELLO'
A subgraph binding that genuinely does not fit its node goes green, and the type error surfaces
wherever the parent's next step touches the value — far from the declaration that caused it.
That is the same distance-from-cause failure .claude/rules/deploy.md records for a stubbed
Dockerfile import.
Why it is this repo's recurring defect, exactly
The rule already exists and is already written down — _port_type refuses a multi-output
boundary with a careful message. What is missing is that nobody checks the thing being compared
against. A check narrower than its claim, the seventh time: the comparison is real, the oracle
is unvalidated, and the result reads as a verdict about the child.
Proposed fix
A check_boundary_types: for each edge leaving START, the carried variable's type must match
input_type; for each edge reaching END, output_type. Blocking, not NOT CHECKED — both
values are declared, so there is nothing unavailable to excuse a stated gap.
Two cases to get right, and both already have precedent in _port_type:
objecton either side is Four places the declaration outruns the check #1's item 3 — it opts out silently today. Same treatment:
NOT CHECKED — …, visible.- Several edges reach END (a step with 2 outputs, or two branches of a decision). They must
all match, and if they carry different types the design has no singleoutput_type— which is
the refusal_port_typealready words for subgraph boundaries.
⚠️ Blocks part of #12
A step battle wraps a StepSpec in a one-node graph and synthesizes exactly these two
fields. Nothing would catch getting that synthesis wrong. Land this first, or alongside.
Repro
# 1. the dead field
from examples.greeting import Greeting, trim_only
class Lying(Greeting):
name = "lying"
input_type, output_type = int, int
print(Lying().coherence_check(), Lying().coherence_check(trim_only))
print(repr(Lying().render(trim_only).run_sync(inputs=" a b ")))
# 2. the check that trusts it
from workflow_workbench import (END, START, EdgeSpec, GraphSpec, StepSpec, StrategySpec,
SubgraphBinding, VariableSpec)
text, num = VariableSpec("text", str), VariableSpec("num", int)
inner = StepSpec("inner", inputs=(text,), outputs=(text,))
class Child(GraphSpec):
name = "child"
input_type, output_type = str, int # the lie
nodes = (inner,)
edges = (EdgeSpec(source=START, target=inner, carries=text),
EdgeSpec(source=inner, target=END, carries=text))
async def shout(ctx) -> str:
return ctx.inputs.upper()
child_strategy = StrategySpec("child_s", {inner: shout})
produce = StepSpec("produce", inputs=(text,), outputs=(num,))
class Parent(GraphSpec):
name = "parent"
input_type, output_type = str, int
nodes = (produce,)
edges = (EdgeSpec(source=START, target=produce, carries=text),
EdgeSpec(source=produce, target=END, carries=num))
parent_strategy = StrategySpec(
"parent_s", {produce: SubgraphBinding(graph=Child(), strategy=child_strategy)})
print("child alone: ", Child().coherence_check(child_strategy))
print("parent+child: ", Parent().coherence_check(parent_strategy))
out = Parent().render(parent_strategy).run_sync(inputs="hello")
print("declared int, actually:", type(out).__name__, repr(out))Measured 2026-10-06 against 0643547, pydantic-graph 2.35.1. Relates to #1, #12, #9.
Activity
borisdev commentedon Oct 6, 2026
Fixed in #22 (stacked on #20).
check_boundary_typesis the 13th rule, and the eighth that needs nothing implemented. The twomeasurements in the body both go red now — the lying subclass of a real example, and the subgraph
that passed on its own claim.
Two things found on the way, both real and both in the PR:
_producescalledlist[int]vslist[int]undecidable.list[int] is list[int]isFalse, and a parameterised alias was never compared against a bare declared type. Fixing bothis what keeps this check clean on all 14 designs in
examples/rather than printing a permanentNOT CHECKEDonparallel.py— i.e. the alternative was editing an example to suit a new check._type_namerenderedlist[int]andlistidentically. Its docstring said generic aliaseshave no
__name__; since 3.10 they do, and it is the bare origin. So a finding comparing thosetwo types read as a complaint that
listis notlist— found by writing this check's message,which compares exactly that pair.
Two decisions left open rather than taken:
about=""on the findings. A port name would be a fifth kind of value in a fieldtest_every_finding_names_something_the_caller_can_look_upresolves — a vocabulary change._port_type's refusal still says "a subgraph binding needs ONE", which stops being the onlycaller when Three states, not two: when a stage should become a nested graph #12's step battle lands.
check_boundary_types: the declared boundary was never compared to the…