ADR-dataflow-009
Two-sided iterOut โ fold the termination node into a stop port; add the final side + after-loop band
Symmetric completion of ADR-dataflow-008. That ADR made iterIn two-sided (init inputs on the left, loop-carry outputs on the right) and explicitly deferred the mirror move on iterOut. This ADR performs it: iterOut gains a stop halt input on its loop side and a final_* output side that fires exactly once at scope termination. The standalone termination node type and the config.post_loop / terminationCondition mechanisms are removed entirely.
Context
Termination and evaluation were authored as ordinary dataflow nodes, but their correctness requirements are phase-shaped: a termination check must be consulted exactly once per iteration, and an evaluate node's verdict must reflect the terminal iteration's values. The executor held this together with four special cases โ a termination defer guard, a settle-pass skip for termination nodes, a latched_done latch, and the config.post_loop flag with its pop-without-clear guard โ the same shape of accumulated special-casing that preceded the initialize removal in ADR-dataflow-008. The cost was real, not aesthetic: TODO #64 (the terminal step's success never reached the evaluate snapshot; smartway_ce recorded SR 0.000 with 5/20 episodes actually succeeding), the explore-eqa post-loop upstream-dependency collapse (SR 0.43 โ 0 when a re-fired evaluate resolved a node-fed input to None), a zombie terminationCondition field threaded through backend and frontend but read by nothing, and silent episode completion at step_count=0 still reporting completed.
Decision
Make iterOut two-sided and retire the termination node type (hard removal โ graphs carrying one are rejected at load with a migration hint, mirroring the initialize precedent). Concretely:
- stop port:
iterOutgains a class-level optionalBOOLinputstop. The executor's Decide phase readsresult["stop"]from the just-fired iterOut once per iteration; truthy terminates the scope. Unwired = budget-only loop.stopdoes not participate in loop-carry transfer, and a barestoparrival does not make iterOut ready (readiness still requires the carry ports). - Final side: at scope termination (stop, budget exhaustion, or best-effort on the error path) iterOut emits exactly once on
final_<name>handles โ a default mirror of allconfig.ports, no trimming option โ plus a constantfinal_stop=Trueas the standard verdict trigger. Verdict nodes (evaluate, graphOut) connect to the final side with ordinary canvas edges. - After-loop band: the set of after-loop nodes is the downstream closure of the root iterOut's
final_*edges โ derived from topology, replacing the authoredconfig.post_loopflag. Validator purity rule: band nodes may not take inputs from the loop body ("verdict inputs ride the pivot" โ expose them as iterOut carry ports). Eval graphs'metrics/successgraphOuts must be band-fed. - Verdict-required: an
eval_graphepisode that ends with no collected metrics is recorded asstatus="error", closing the silent-completion exit. - Deletions: the four executor special cases above, the
TerminationNodeclass and registry entry, theterminationConditionfield across backend and frontend, and termination-specific scope-analysis / layout logic.StopExecutionsurvives only as a node-side escape hatch.
Alternatives
(a) Keep termination as authoring sugar, lowered to the stop port at load โ rejected (user decision): it preserves a node type that adds no expressiveness, inflating the AAS search vocabulary the same way initialize did. (b) A finalPorts trimming option so authors could expose only a subset on the final side โ rejected (user decision): the final side defaults to mirroring all carry ports; an unconsumed final_* handle costs nothing. (c) Keep in-loop evaluate nodes with a latch for per-step metric streams โ rejected (user decision): per-step telemetry is the viewer sinks' job (step.info โ viewer, opt-in); the verdict comes only from the final side, so "evaluate fires mid-loop with stale terminal values" is no longer expressible. (d) Soft-deprecate with an execution-time absorb (the -008 treatment of initialize) โ rejected: all 24 in-repo graphs were migrated in the same change by tools/migrate_termination.py, so there is no legacy population to keep alive, and a hard validator error with a migration hint is clearer than silent rewriting.
Rationale
The refactor converts episode-verdict correctness from a scheduling accident into a structural guarantee: the final side emits the terminal iteration's values by construction, so the #64 class of bug (verdict snapshot lags the terminal step) cannot be written in the new vocabulary, and the band purity rule makes the explore-eqa class (band node starved by a loop-body dependency) a load-time validator error instead of a silent SR collapse. Four executor special cases disappear outright, and the pivot vocabulary stays at two node types with symmetric two-sided semantics โ pre-loop = iterIn's init side, in-loop = the body, after-loop = iterOut's final side โ completing the regularisation arc that ADR-dataflow-006 started and -008 advanced. Verified by 117 backend tests (including a #64 regression test asserting the after-loop consumer receives the terminal step's value, budget-exhaustion emission, and best-effort error-path emission), tsc --noEmit clean, and 24/24 + 1 composite in-repo graphs migrated and loading clean. Real-eval re-verification (smartway_ce expected SR โ 0.25) rides the existing TODO #64/#65 re-verification wave.
Affected docs
Updated in the same change-set (commits 7095f196 + 3760c002): core/glossary.html (Termination โ Removed; new stop port / Final side / After-loop band terms), design-docs/graph/graph-executor.html (Part I rewritten: guards 4 โ 2, Decide/Settle/Record, ยง4.6 trace, ยง5.1 after-loop band, ยง8 exit-paths table), capabilities/graph-execution-engine.html (ยง9, ยง10.5), design-docs/graph/loop-control-system.html (historical note), nodesets/env/template.html (decision E), nodesets/method/explore-eqa.html, core/roadmap.html (#62/#63/#64 notes), .claude/tutorials/skill-graph-json.md, .claude/commands/architect/myloop seeds + AAS mirror, decisions/dataflow/index.html + decisions/index.html (this entry).