ADR-dataflow-006
Three-pivot boundary nodes β `Initialize` + `iterIn` + `iterOut`
Scope split. The original ADR-031 decided two related-but-separable things β (i) the boundary-node typology (three pivot types, ports_mode semantics, port synthesis at load) and (ii) the executor scheduling change (seed-discovery rule, pairedWith internal transfer, validate_graph_connectivity). This file covers (i) β the dataflow / boundary-node half. The executor-side half is ADR-executor-002. Both landed together; the split exists only to keep each field's decision log focused.
Context
Until this change the canvas had a single iteration-boundary node, iterIn, which conflated two roles: (1) the per-iteration boundary that broadcasts step_start and exposes the loop-carry bundle to the iteration body, and (2) the run-start collection pivot that received initial state (RGB, pose, topo state) from "seed" nodes wired directly into its input ports. This conflation produced three concrete problems. (a) iterIn fired multiple times within step 0 β once per seed-wire arrival β because the graph executor re-evaluates a node's readiness as each pending input lands, breaking step_start semantics and per-step logging. (b) Implicit ordering: nothing in the graph declared "initialization is complete β start the loop"; the engine discovered it ad-hoc. (c) The dual role made the boundary-node typology muddled β iterIn was both source and sink, depending on which edges were wired.
Decision
Introduce a third pivot, Initialize (node_type="initialize", ports_mode="sink", kind="control"), that mirrors iterOut's class shape β a sink that collects its input bundle from upstream seed-region nodes and is paired with an iterIn via a pairedWith config field. iterIn becomes output-only via a new ports_mode="source" (input ports stripped, _resolve_ports returns ([], outputs)); portResolution.ts gains a fourth branch as the symmetric inverse of "sink". The frontend's existing iterIn paired-drag handler (UnifiedGraphEditor.tsx) is extended to spawn the three-node trio (Initialize left of iterIn, iterOut right). The three production graphs (mapgpt_mp3d.json, navgpt_ce.json, straightforward.json) are migrated in place: a new init node receives every edge that previously targeted iterIn, with config.pairedWith referencing the iterIn id and config.ports matching iterIn's port shape.
Alternatives
(a) Keep iterIn as the single pivot but add a config flag like init_only=true β rejected; this would have only postponed the multi-fire-at-step-0 problem (still one node, still re-evaluated as each pending input lands) and wouldn't have produced a clean typology. (b) Make Initialize a regular operation node instead of a control pivot, wiring it directly into iterIn over a normal canvas edge β rejected; the value of the pairedWith mechanism is precisely that the transfer is executor-internal, identical to the iterOutβiterIn handoff already in place, so the loop body's wires never fan in from outside the loop. A canvas wire would re-introduce the "required ports declared but only fed at startup" ambiguity at the iterIn input.
Rationale
Each pivot gets exactly one job β Initialize collects step-0 state, iterIn broadcasts step boundaries and exposes loop-carry data, iterOut collects end-of-iter state β and the run-start data flow is declarative (the graph names which node feeds Initialize) rather than discovered (the executor scanning for nodes with no incoming edges and inferring intent). The backward-compat removal in Phase A (input ports stripped, ports_mode="source") makes the contract impossible to misuse β you cannot draw a wire into iterIn because the target handles do not exist β which collapses an entire category of graph-design questions ("should this go to Initialize or to iterIn?") to a structural answer. The new "source" ports mode is the symmetric counterpart of the existing "sink" mode and is implemented in three lines of portResolution.ts, with no other frontend file affected β port-derivation logic stays single-source-of-truth. Reusing the existing pairedWith mechanism (already used by iterOut) means no new config-field UX is required.
Affected docs
design-docs/graph-system.md (new "Graph Design Principles" section), core/glossary.md (new terms: Initialize, pairedWith, ports_mode="source"), core/architecture.md (Execution Engines section β three-pivot iteration model), core/codebase-map.md (new pivot class)
Refinement (2026-04-21) β iterIn port synthesis
iterIn loses its user-authored config.ports entirely. iterIn's config now carries only {version: 3, pairedWith}; its full port surface is synthesised at graph load by _synthesize_iterin_ports in graph_def.py and mirrored live in the canvas by synthesizeIterInPortsForId in portResolution.ts. Synthesis always prefixes by origin: paired Initialize.<X> β init_<X>, paired iterOut.<X> β iterout_<X>, direct canvas edges keep their targetHandle verbatim. A name X declared on both writers therefore produces two independent slots β there is no cross-writer merging, which retires the earlier "overlap trick" design question and makes writer-name collisions structurally impossible.
NodeInstance.port_slots is a single dict keyed by the prefixed handle name; all three writers land there via the unified _write_iterin_slot helper (Initialize transfer at graph_executor.py:373-398 applies the init_ prefix, iterOut transfer applies iterout_, direct canvas edges write verbatim). The per-port persist flag lives on the writer's port entry (Initialize.ports[*].persist, iterOut.ports[*].persist); iterIn's IterInExpandPanel UI aggregates them with origin badges and writes back through to the writer. Origin-dependent default: Initialize-origin ports default to persist=false (Step-0 one-shot), iterOut-origin to persist=true (loop-carried); defaults are applied at both backend synthesis and frontend mirror. Post-fire clear at graph_executor.py:411-416 deletes persist=false slots; persist=true slots retain their last write. LIST[T] fan-in concat is suppressed for iterIn slots.
Config version bumped to 3; legacy init_ports/loop_ports schemas hard-rejected at load with a migration-pointer. StripLayout.tsx renders iterIn with an init band above a dashed horizontal divider and an iterOut band below; handle count is unchanged vs the pre-refinement scheme (disjoint slots, not compressed). All five in-repo graphs migrated in place (outgoing iterIn edges rewritten to prefixed handles). An unshipped 2026-04-20 single-merged-slot proposal is superseded by this refinement. Not a new ADR β the three-pivot mental model and executor transfer paths are unchanged; only port-synthesis and persist-ownership shifted. See docs-site/docs/design-docs/loop-control-system.md + loop-control-logic-scratch.md Β§Β§4-6 for full narrative.
Forward reference (2026-05-09) β multi-scope iteration
This ADR decides the boundary-node typology for one (Initialize, iterIn, iterOut) triple per graph. Hierarchical agents need multiple coexisting triples in one flat graph (outer reasoning loop wrapping inner execution loop, etc.). That extension is ADR-dataflow-007 β multi-scope iteration, which is strictly additive: every existing single-scope graph executes byte-identically (gated by forest.is_single_scope == True). The three-pivot model in this ADR becomes the base case of multi-scope; the new abstraction is scope (= one triple plus its body, IO interface via portIn/portOut, and per-scope config on its iter_in).