Workflow graph integrity: a workflow executes exactly the graph it declares - #185
Merged
Conversation
Three parts of one rule: node ids are unique, an explicitly named entrypoint resolves, and every graph edge resolves to a declared node. The engine did none of them, and each failure is silent: entrypoint names nowhere ran node `first` instead next names nowhere continuation vanished at `if not node: continue` two nodes share an id one replaced the other in the node_map dict The edge fields are measured rather than read off the schema. The executor consumes five — entrypoint, next as scalar or list, branches[].next, after, and on_error — and the last two are not in the artifact kind schema at all, so a validator written from the schema would have covered three of five and looked finished. Two altitudes on purpose. Admission stops new invalid graphs; the engine checks again before it builds node_map, because a row can predate the check or arrive by import, and repairing one silently at execution is the defect rather than the fallback. Controls carry weight here: the valid fixture exercises all five edge kinds, an absent entrypoint stays legal because starting at the first node is the documented behaviour, a shapeless graph must not crash the validator on its way to saying so, and the workflows this system synthesises for itself must still run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
workflow_graph.graph_problems is pure and returns every problem rather than the first, so each caller raises in its own vocabulary: admission gives ArtifactValidationError and the engine BadRequestError. The engine asks before it builds node_map, because that is where two of these stop being visible — duplicate ids collapse into one key, and a dangling entrypoint used to be replaced by whatever node came first. The edge set was measured from the executor rather than read off the kind schema. It consumes five fields, and `after` and `on_error` are not in that schema at all, so a validator written from the schema would have covered three of five and looked finished. `on_error` is the one that matters most: it is the transition a workflow takes exactly when it can least afford to stop silently. Both altitudes are load-bearing here, unlike the operand rule in the patch tranche: the mutation removing each kills only its own witnesses. The two mutations that drop `after` and `on_error` from the edge set likewise kill only their own, so the schema-invisible pair is separated rather than counted twice. Eight mutations, all killed. A ninth was retired rather than left surviving: re-adding the engine's old entrypoint repair cannot fire once the check above it has refused such an entrypoint, so it changed no behaviour, which says the code it adds is dead rather than that the tests are weak. One control was measuring the harness. It asserted the workflows this system synthesises still run; they do not under those mocks, with the change stashed as well. It now asserts they are not refused, and a second witness checks both built-in graphs against the rule with no harness in the way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Two gaps in the tranche, plus a sibling, all reproduced.
Streaming is a separate graph execution path with its own copy of the repair
semantics — the same entrypoint fallback, the same `if not node: continue` —
and it never calls graph_problems. So the pre-existing or imported row this
tranche exists to protect fails closed in blocking chat and still silently
runs a different graph in streaming chat. The engine witnesses only drove
run(), so nothing said so.
Cardinality. graph_problems checks that a reference resolves and not that it
has the shape the executor reads. The executor takes a list only for `next`:
`after` is inserted as one pending node id and `on_error` is wrapped as one
next-node id, so a list in either position reaches node_map.get() as a list.
Neither field is in the artifact kind schema, so JSON Schema does not reject
the shape either. Measured: {"after": ["join"]} and {"on_error": ["join"]}
both pass admission with zero problems and fail at execution.
That is the second half of the lesson from measuring fields the schema does
not know about — their cardinality is unpinned for the same reason their
targets were.
The sibling: `id` has no minLength, graph_problems skips falsy ids, and
node_map drops them, so a declared node with an empty id disappears
unreported — the same silent-removal shape as a duplicate. An explicitly
empty entrypoint is likewise treated as though it were omitted, when omitted
means "start at the first node" and written-empty means the operator named
something that is not a node.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Streaming carried its own copy of the repair semantics — the same entrypoint fallback, the same `if not node: continue` — and never asked the rule, so an invalid row failed closed in blocking chat and ran a different graph in streaming chat. It asks now, before node_map, and keeps its own vocabulary: blocking raises, streaming emits validation_error and stops before a token or a trace reaches anyone. The witness asserts that ordering rather than merely that an error appears somewhere in the stream. Cardinality. The executor reads a list only for `next`; it inserts `after` as one pending node id and wraps `on_error` as one next-node id, so a list in either position arrives at node_map.get() as a list. Measured, both passed admission with zero problems and failed at execution. _EDGE_FIELDS is a mapping now, naming per field whether a list is legal there. branches[].next was a live contradiction: the kind schema advertised string-or-array while the switch executor appends one value and never flattens. SPEC 9 gives fan-out to parallel, so the schema was narrowed to match execution rather than the other way round. An id that cannot name a node is reported rather than skipped, and carries minLength 1 in the kind schema — the schema does not reach a row that predates it. An explicitly empty entrypoint is told apart from an omitted one: absent means start at the first node, written-empty means the operator named something that is not a node. Fifteen mutations, all killed. Two retired rather than left surviving, both because they added unreachable code. Four anchors went stale across this tranche's two passes and the driver reported each one instead of a survivor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Recorded after one sighting during the patch tranche and marked not reproduced. It recurred during the graph tranche, so it is intermittent rather than a one-off. The second trace widens the shape. The pool closes underneath a live request — POST /v1/files/upload — and two different call sites reach for a connection afterwards: contexts_covering_path during publish, and hold_live_user from the idempotency guard's exit. So it is not only a fire-and-forget write outliving its session; request work is still running when the pool goes. Also worth knowing for whoever picks it up: the test passes in isolation and logs the unhandled exception, so it can fail the lane without failing its own assertion, and grepping for the assertion will not find it. Unrelated to the patch and graph tranches by reachability, and the lane is green on a re-run. On the reviewer's standing instruction this now deserves its own concurrency/lifetime red rather than another observation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
The ordinary tool tail swaps `next` for `on_error` on an error result. The circuit-open branch builds its own error result, reads `next`, and returns before reaching that swap. Measured on a graph declaring `tool -> normal` / `on_error: recover`, with the breaker forced open: expected tool -> recover actual tool -> normal So an open breaker sends the turn down the *success* path, into nodes that assume outputs the failed node never produced. Same class as the rest of this file one level in: the declared graph says one thing and the runtime does another. The validator cannot see it, because the graph is valid — what is wrong is which edge execution chose. My own docstring for `on_error` asserts the behaviour this path does not have, which is what a comment is worth as evidence. The control matters as much: routing every tool node to `on_error` would pass the witness above and break every successful turn, so a closed circuit still taking `next` is asserted beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
The circuit-open branch had its own copy of "where does this node go next". It read `next`, never looked at `on_error`, and returned before the tail that does the swap — so a graph declaring `tool -> recover` on failure ran `tool -> normal` whenever the breaker was open, into nodes that assume outputs the failed node never produced. _successors is that decision, once, and both callers use it. A failure is a failure however it arose. A second witness came out of the mutations rather than review: removing on_error from the chooser killed only the circuit-open case, which meant the primary path — a tool that simply fails — had no witness of its own and was resting on the breaker case to notice. It has one now, and the two mutations separate: restoring the early-return copy kills one, removing the rule kills both. The control matters as much as either: routing every tool node to on_error would satisfy both refusals and break every successful turn, so a closed circuit still taking `next` is asserted beside them. Seventeen mutations, all killed. Full lane 3042 passed, 27 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
…type
Two seams left in "a workflow executes exactly the graph it declares", both
verified by running the code before anything changed.
`run_streaming` streams `llm.generic`, `llm.generic_chat_v1` and
`agent.files_v1` without calling `_execute_node_with_retry`, so neither
decision the blocking path makes around a tool call happens there. With the
breaker forced open, `generate_stream` was still called and the run traced
`['tool', 'normal']` with `status: ok` — an open breaker did not stop the
call, and the declared `on_error: recover` was never taken. With the backend
raising before the first token, the run traced nothing at all and ended on an
error event, again ignoring `recover`. The same graph on the blocking path
takes `recover` in both cases.
`graph_problems` asks which edges resolve, but not whether the declaring node
reads them. Measured, all five of these reported no problems:
{"id": "stop", "type": "end", "next": "side"}
{"id": "choose", "type": "switch", "next": "side", "branches": [...]}
{"id": "t", "type": "tool_call", "after": "side"}
{"id": "fan", "type": "parallel", "on_error": "side"}
{"id": "x", "type": "swich", "tool": "llm.generic"}
The first is the sharpest: publish `end -> side`, validation confirms the
edge resolves, execution stops at `end`. The last is the same shape one level
up — SPEC §9 names four node types and writes them as an enum, the kind
schema accepted any string, and `_execute_node` runs anything it does not
recognise as a tool call. The typo was accepted at admission and traced
`{"node": "x", "status": "ok"}`: it invoked the LLM.
14 failing witnesses. The controls pass already, which is what a control is
for: a legal shape per node type, a node with no type at all (the executor
defaults that to `tool_call`, so this altitude does too), a successful stream
still taking `next` with its tokens intact, and a streamed failure with no
error edge still ending the turn rather than being routed down `next`.
Also retargets the two list-cardinality witnesses onto the node types that
read those fields, so they keep measuring cardinality rather than passing for
the new reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Closes the two seams in "a workflow executes exactly the graph it declares". Streaming shares the tool-node control plane instead of carrying its own. `_circuit_open_result` is the breaker preflight as a method, asked by both `_execute_node` and the streaming branch, so an open breaker now stops a streamed LLM call — it did not stop one at all before, for the three tools every ordinary chat turn uses. A streamed failure goes through the same `_successors` chooser as every other tool failure, so a graph declaring `tool -> recover` takes `recover` whether the call was refused by the breaker or the backend raised before the first token. Token production stays streaming-specific: that is why the path exists. One deliberate asymmetry, with its own control and its own mutation: a streamed failure whose node declares no `on_error` still ends the stream as it always did. The chooser answers `next` when no error edge exists, so handing every failure to it would send a failed node down the success path. `_NODE_EDGES` replaces the global edge set with a per-node-type table, and the field set is derived from it, so adding a field to one type also asks every other type whether it reads it. An edge on a node whose type never reads it is now a problem, and so is `branches` anywhere but a switch. The node type is the same shape one level up: SPEC §9's four are an enum in the kind schema and a semantic check in `graph_problems`, because the schema does not reach a row that predates it. An absent type is read as `tool_call`, which is what `_execute_node` does with it. The rule found fifteen existing fixtures declaring `llm_call` or `respond` — node types this engine has never executed, in tests that never run the graph. Corrected to a real tool_call node rather than loosening the rule. 26 mutations, 26 killed, no survivors. The two streaming mutations separate as intended: bypassing the preflight dies only on the open-breaker witnesses, removing the handoff only on the `on_error` ones. Reverting the schema enum alone left the end-to-end admission test green, so the enum got a witness that exercises the JSON Schema validator by itself rather than being explained away. Full lane: 3065 passed, 27 skipped. CI's lint selection clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
…ond answer
Both verified by running the code before anything changed.
`_execute_parallel_nodes` calls `_execute_node_with_retry` and throws the
successor list away (`result, _ = await ...`), so how a node was reached
decides whether its declared edges mean anything. On this graph:
fan: parallel next=["choose"] after="join"
choose: switch true -> side
side: end
join: end
`graph_problems` returned `[]` and the run traced `['fan', 'join']`. `choose`
executed, returned `['side']`, and `side` never ran. The same shape applies to
a `tool_call` child's `next`/`on_error` and to a nested parallel's children
and `after`.
The permanent `VALID` fixture was a warning sign for this: its parallel fanned
into `work` and `other`, both declaring `next: "join"`, while the parallel
itself declared `after: "join"`. The fixture looked like it exercised a
child's `next` when `after` was doing all the work — a witness at the wrong
altitude. Its children now declare nothing, which is what the executor
supports, and `work` keeps `next`/`on_error` on the path that reads them.
The second finding is one the previous commit introduced. Its own test said
recovery after partial output was a separate question; the implementation
answered it by accident. A node that streams a token and then fails still
takes `on_error`, so the client received `['PARTIAL ', 'RECOVERED ANSWER']`
in one bubble and the run traced `['tool', 'recover', 'fin']`. The correct
precedent is one function away in the same file: the attachment agent tracks
`emitted_tokens` for exactly this reason.
8 failing witnesses. The controls pass already: a parallel child that declares
no control flow, the identical node reached by `after` instead, and — pinning
the premise the new rule is derived from rather than the rule itself — the
executor really does discard a child's successor, measured with the graph
check disabled.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
…ry window `_discarded_by_parallel` closes the third dimension of the graph invariant: not what a node type reads, but how the node was reached. A node named by `parallel.next` runs once and its successor list is discarded, so declaring `next`, `on_error`, `branches[].next` or nested children there publishes control flow that resolves at validation and executes as nothing. The check derives from the same `_NODE_EDGES` table — whatever the child's own type would read is what the parallel throws away — and it reads a scalar `next` as one child, because `_execute_node` does. This is the narrow reading, and the one SPEC §9 supports: "fan-out to multiple nodes, then join" means `parallel.next` names children and `after` owns the continuation. Making `parallel` a recursive subgraph executor is a specification decision rather than a bug fix, so this refuses the graphs that would need one instead of inventing the semantics. A witness pins that premise directly, running the refused graph with the check disabled: if the executor ever stops discarding, that test fails and says so. The streamed `on_error` handoff gains the boundary the last commit walked past. `emitted_tokens` is tracked per node, and recovery runs only when the node failed before producing anything; one token or more and the stream terminates as it always did. This is the policy `_stream_agent_files_node` already keeps, one function away in the same file, for the same reason — a token that has been yielded is on the reader's screen and nothing downstream can take it back. The `VALID` fixture is restructured. Its parallel children each declared `next: "join"` while the parallel declared `after: "join"`, so the fixture looked like it exercised a child's `next` when `after` was producing that continuation alone. A control that passes for a reason other than the one it claims is a witness at the wrong altitude. 31 mutations, 31 killed, no survivors. Two rules gained complementary pairs, because losing a rule and over-applying it are both wrong and only one fails loudly: extending the parallel rule to the `after` target kills the control that says the rule is about the context, and marking every streamed node as having emitted kills the zero-token recovery witnesses that "never recover" would otherwise satisfy. Full lane: 3076 passed, 27 skipped. CI's lint selection clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
…cess
Both measured against the running engine first.
`_discarded_by_parallel` asks what edges a child declares, and `end` declares
none — `_NODE_EDGES["end"]` is empty, so it passes cleanly. But `end` carries
control semantics without an edge: its meaning is its status. On the ordinary
path `status == "end"` stops the workflow; `_execute_parallel_nodes` reads
only `"error"` out of a child's status, so `"end"` is an ordinary successful
child. Measured:
fan: parallel next=["stop"] after="side"
stop: end
side: end
graph_problems []
runtime traced ['fan', 'side'] — the node named `end` ended nothing
SPEC §9.1 calls `end` the node that produces the final response, and
termination belongs on the `after` continuation where the ordinary loop can
see it.
Two execution budgets bound a run, and on exhaustion both fell through to the
ordinary result. Nothing pins node count at admission, so this is reachable
with a valid acyclic graph:
101-node switch chain 100 nodes ran, `n100` never did, status None,
content "No response generated."
2-node cycle 14 steps, "workflow_loop_detected" logged,
status None, same placeholder content
streaming, both message_done, no error event
The configuration says more work remains, the runtime silently omits it, and
the caller is told it worked — this tranche's sentence, with the runtime
rather than the graph on the wrong side of it.
5 failing witnesses. The controls pass already: a chain inside the budget
completes in both paths, an `end` reached by `after` is accepted, and — the
narrow one that keeps the step-budget rule honest — an `end` reached while
siblings are still queued is a legitimate completion, not an exhausted budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
… error `_parallel_child_problems` states the rule positively now: `parallel.next` names leaf `tool_call` nodes. The edge check that preceded it could not see `end`, because `end` declares no edge — its meaning is its status, and `_execute_parallel_nodes` reads only `"error"` out of a child's status, so an `end` child was an ordinary success the parent walked past. SPEC §9.1 gives `end` the final response and `parallel` a fan-out that joins, so a child does work and termination lives on the `after` continuation where the ordinary loop can see it. Both execution budgets fail closed. The step budget is the `while` condition and the visit budget is a `break`, and each fell through to the ordinary result — so a valid 101-node chain ran 100 nodes and returned a success whose content was the placeholder. Blocking now returns `status: "error"` naming the budget; streaming emits an error event instead of `message_done`. Reaching an `end` with siblings still queued stays a completion: the two are told apart by which one happened, not by whether work remains. Two of my own witnesses were vacuous and mutations found both. The control for that last distinction ended with `pending` already empty, so it exercised nothing — it uses a list `next` now and asserts the trace. The cycle fixture had two nodes and was witnessing the step budget, not the visit guard: computed, a two-node cycle reaches its eighth visit at step 15 and the step budget stops it at 14, while three nodes puts the guard at step 13 of 16. Asserting which budget ran out is what said so, and that assertion stays. One positive control needed the same repair for the same reason: its parallel fanned into an `end`, so under the new rule it would have passed on the child rule rather than on the field it was named for. 39 mutations, 39 killed, no survivors. The two halves of the parallel-child rule separate — admitting `end` kills only the `end` witness, dropping the edge loop kills only the tool_call children carrying successors — and so do the two budgets, which is what pinning the reason bought. Full lane: 3086 passed, 27 skipped. CI's lint selection clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Measured against the running engine, with the tool call itself stubbed so the
invocation count is the only variable.
`visited` is incremented only in the driving loop. A parallel child runs
inside `_execute_parallel_nodes`, which touches neither `visited` nor
`visited_nodes`, and which builds every child task before awaiting one
`asyncio.gather`. The validator permits any number of leaf `tool_call`
children and nothing caps fan-out:
graph max_steps tool invocations result
152 nodes, 150 children 100 150 success
3 nodes, "next": [leaf]*150 16 150 success
The second is the sharper one. Three nodes, a budget of sixteen, one repeated
child id — each occurrence is an execution, so a graph naming a single node
began a hundred and fifty concurrent tool calls and the outer loop recorded
two visits. These are real worker invocations, so this is an availability
finding rather than a bookkeeping one.
4 failing witnesses across both execution paths, including one that separates
the two halves of the rule: a fan-out of 40 that fits, followed by a chain
that only exceeds the budget once the children are charged for. Both controls
pass — a small fan-out still runs every child, blocking and streaming — so
"refuse every parallel" cannot satisfy the refusals.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
`ExecutionBudget` is one object per run, held by the driving loop and by the fan-out it dispatches. It lives in `workflow_limits` with the other shared limits, because `workflow_streaming` cannot import from `workflow` without a cycle and the two paths must not hold different limits. The reservation sits inside `_execute_parallel_nodes`, beside the `gather` it bounds rather than in the two callers, so a third caller cannot forget it. It is taken before the tasks are built: a batch this run cannot afford never begins any of it, rather than being cut off partway through. Each entry in `parallel.next` costs one, a repeated id included — each occurrence is an execution, whatever it is named. A fan-out constant would not have repaired the claim that `max_steps` bounds node executions; it would have replaced one unchecked number with another. Charging the children is what bounds the rest of the run, and that half has its own witness: a fan-out of forty that fits, followed by a chain that exceeds the budget only once the children are counted. The reservation also replaced an inference, which is a simplification. The loop used to conclude after the fact that leftover pending work meant the step budget had run out; now the reason is recorded where the refusal happens, so `reached_end` is gone and so is the mutation that probed it. 41 mutations, 41 killed. The two fan-out mutations kill exactly the four fan-out witnesses and neither control: making children free again, and reserving one execution for the whole batch — the second would satisfy a naive "the budget is consulted" check while leaving fan-out unbounded. Two anchors went stale in the rename and the driver reported them rather than counting survivors. It now accepts mutation names on the command line, so a retarget is re-measured in seconds. Full lane: 3092 passed, 27 skipped. CI's lint selection clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
A refused fan-out leaves its parent `parallel` node out of `workflow_trace`: the node executed — it produced the child list — but the caller breaks on `budget_exhausted` before appending it. The run fails closed and the reported failure is honest, so this is ledger completeness, not correctness, and it belongs wherever `workflow_trace` is qualified as a complete execution ledger. And one witness is written to expire. `test_an_ordinary_tool_failure_also_takes_on_error` manufactures a runtime failure with `no.such.tool.v1` because tool names are not reference-validated yet. Once they are, that graph is refused before it executes and the witness becomes invalid by design. The replacement is a real resolvable tool forced to fail — not a weakened reference rule. Recorded rather than pre-emptively changed, because it measures the right thing today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
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.
Reference validation, tranche 1. Scope is graph-internal integrity and the runtime that executes it — not external reference resolution, which is tranche 2.
The invariant
Every defect below is a violation reported as success. The engine did not crash on any of them; it ran a different workflow from the published one and said it worked.
What was wrong, measured
Each row was reproduced by running the engine before anything changed.
entrypoint: "nowhere"next(iter(node_map))— whatever node came firstnext: "nowhere"if not node: continue; the continuation vanishednode_mapcomprehension kept the laston_error: recover, breaker opentool -> normal, the success edge,status: "ok"endwithnext: "side"endstops the runswitchwithnextbranches[].nexttool_callwithafternext/on_errorparallelwithon_errornext/aftertype: "swich"parallel -> switch -> sidefan -> choose -> join;sidenever ranparallel -> end -> afterendended nothingnext: ["leaf"] * 150, 3 nodesStreaming was a second copy of all of it: its own entrypoint fallback, its own
if not node: continue, and — because it streams three tools without calling the blocking executor — no circuit-breaker preflight and noon_errorhandoff at all.What changed
liminallm/service/workflow_graph.py(new) — one pure validator, returning every problem rather than the first, so each caller raises in its own vocabulary.The edge fields were measured from the executor, not read off the kind schema. The executor consumes five;
afterandon_errorare not in that schema at all, so a validator written from it would have covered three of five and looked complete. Cardinality was measured the same way:nextis the only field read as either a string or a list._NODE_EDGESis a per-node-type table, because which fields a node reads is decided by its type, and the field set checked against it is derived from the table — adding a field to one type asks every other type whether it reads it._parallel_child_problemscovers a third dimension: not what a node reads, but how it was reached._execute_parallel_nodesdiscards a child's successor list and reads only"error"out of its status, so a parallel child is a leaftool_call— SPEC §9.1's "fan-out to multiple nodes, then join", with termination on theaftercontinuation where the ordinary loop can see it.Two validation altitudes. Admission stops new invalid graphs; the engine checks again before building
node_map, because a row can predate the check or arrive by import. The node type is an enum in the kind schema and a semantic check, for that reason. SPEC §9.1 already wrote it as an enum.One tool-node control plane.
_circuit_open_result(the breaker preflight) and_successors(thenext-versus-on_errorchooser) are shared by the blocking executor, its circuit-open branch, and streaming. Token production stays streaming-specific — that is why the path exists. A streamed failure takeson_erroronly if it failed before producing a token: once a token has reached the client, recovery would append a second answer to the same bubble, which is the boundary_stream_agent_files_nodealready keeps.ExecutionBudget— one object per run, inworkflow_limitswith the other shared limits, held by the driving loop and the fan-out it dispatches. The reservation sits beside thegatherit bounds and is taken before the tasks are built, so an over-budget batch never begins any of it. Each entry inparallel.nextcosts one, a repeated id included. Both exhaustion mechanisms now fail closed and name which budget ran out.Evidence
41 mutations, 41 killed, no survivors. Three were retired rather than left surviving, each because it changed no behaviour — a mutation with no effect says the code it adds is dead, not that the tests are weak.
Several rules earned a complementary pair, because losing a rule and over-applying it are both wrong and only one fails loudly:
aftertarget kills the control that says the rule is about the context, not the node;Sixteen anchors went stale across seven passes and the driver reported each as unmeasured rather than as a survivor.
Mutations found three of my own bad witnesses, which is the honest part of this record:
pendingalready empty, so it never exercised the case it was named for;parallelfanned into anend, which under the new rule would have passed on the child rule rather than the field it was named for.The permanent
VALIDfixture was the same shape: its parallel children each declarednext: "join"while the parallel declaredafter: "join", so it looked like it exercised a child'snextwhenafterwas doing all the work.One witness pins a premise rather than a rule: it runs the refused parallel-child graph with validation disabled and asserts the successor never executes. If
parallelever becomes a recursive subgraph executor, that test fails and says the rule needs revisiting.Full lane: 3092 passed, 27 skipped. CI's lint selection clean.
Fixture corrections
The node-type enum found 15 existing fixtures declaring
llm_callorrespond— node types this engine has never executed, in tests that never run the graph. Corrected to a realtool_callrather than loosening the rule.Not in this PR
Three findings are recorded in
docs/ISSUES.mdand deliberately left for their own tranches: thePoolClosedteardown race in the xdist lane, streamed failures never incrementing the circuit breaker (SPEC §18 accounting, not §9 graph fidelity), and a refused fan-out leaving its parent out ofworkflow_trace.🤖 Generated with Claude Code
https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Generated by Claude Code
Note
Overview
Workflows are no longer silently repaired or truncated at runtime. A new
graph_problemsvalidator (used at artifact admission and again beforerun/run_streaming) rejects dangling entrypoints/edges, duplicate or empty ids, wrong edge cardinality, node types and edges a type never reads, and invalid parallel children—matching what the executor actually consumes, includingafterandon_errorfields absent from JSON Schema.The workflow kind schema is tightened (four node
typevalues,minLengthon ids, scalarbranches[].next). Blocking and streaming now share_circuit_open_resultand_successors, so open breakers and tool failures followon_errorinstead of the success path; streaming only recovers onon_errorwhen no token was already emitted.ExecutionBudgetcharges parallel fan-out beforegather, and step/revisit exhaustion returnsstatus: error(or a streaming error event) instead of a placeholder success. Tests and fixtures switch obsolete node types to realtool_callgraphs;docs/ISSUES.mdrecords the campaign and follow-ups left out of this tranche.Reviewed by Cursor Bugbot for commit 186616b. Bugbot is set up for automated code reviews on this repo. Configure here.