Skip to content

A GraphSpec's declared input_type/output_type is never checked — and check_subgraphs trusts it #21

Description

@borisdev

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 GraphSpec declares input_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 str

Clean, 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:

  • object on 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 single output_type — which is
    the refusal _port_type already 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

  1. borisdev commented on Oct 6, 2026

    @borisdev
    OwnerAuthor

    Fixed in #22 (stacked on #20).

    check_boundary_types is the 13th rule, and the eighth that needs nothing implemented. The two
    measurements 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:

    • _produces called list[int] vs list[int] undecidable. list[int] is list[int] is
      False, and a parameterised alias was never compared against a bare declared type. Fixing both
      is what keeps this check clean on all 14 designs in examples/ rather than printing a permanent
      NOT CHECKED on parallel.py — i.e. the alternative was editing an example to suit a new check.
    • _type_name rendered list[int] and list identically. Its docstring said generic aliases
      have no __name__; since 3.10 they do, and it is the bare origin. So a finding comparing those
      two types read as a complaint that list is not list — 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 field
      test_every_finding_names_something_the_caller_can_look_up resolves — a vocabulary change.
    • _port_type's refusal still says "a subgraph binding needs ONE", which stops being the only
      caller when Three states, not two: when a stage should become a nested graph #12's step battle lands.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions