ADR-dataflow-007
Multi-scope iteration β N coexisting (Initialize, iterIn, iterOut) triples in one flat graph
Scope split. This change decides both the dataflow / boundary-node side (multiple three-pivot triples coexist in one graph; nesting is topology-determined; portIn/portOut serves as the structural IO interface for inner scopes) and the executor side (per-scope step counters / budgets / terminations; settle-loop scope isolation; portOut latch propagation on inner-scope termination). This file covers the dataflow half. The executor half is ADR-executor-003. Both landed together.
Context
ADR-dataflow-006 introduced the three-pivot iteration model β exactly one (Initialize, iterIn, iterOut) triple per graph, one global step_counter, one global termination set. Hierarchical agents (VoxPoser-class, HRL planners, tree-search rollouts, multi-phase exploration) need two coexisting iteration cadences in the same agent: an outer reasoning loop (e.g. "for each sub-task: re-plan trajectory") wrapping an inner execution loop ("for each waypoint: execute one motion primitive, check progress"). Until this change there were two ways to express that on the canvas β both bad. (a) Hide the inner loop inside one fat node (the v1 VoxPoser approach): voxposer__sub_task_runner packed composer + voxel maps + path planner + per-step waypoint dispense behind a single execute(), defeating "the graph IS the agent" β the inner cadence was invisible to the canvas, untraceable per-iter, and impossible to reuse. (b) Add a subgraphLoop containment node that holds an inner graph with its own three-pivot β rejected because containment nests executor instances rather than nesting iteration scopes inside a single executor, which forks the engine model and prevents arbitrary cross-scope wires. The structural problem is real: a flat graph with N pivot triples needs the framework to figure out which body nodes belong to which iteration cadence, and how data crosses between cadences with well-defined semantics.
Decision
(1) Allow N (Initialize, iterIn, iterOut) triples in one graph, paired pairwise via pairedWith (existing field; no schema change). The set of triples plus their wires defines a scope forest: analyze_scopes() (agentcanvas/backend/app/agent_loop/scope_analysis.py) computes one ScopeInfo per iter_in (the canonical scope id), with topology-determined parent / children. (2) Topology-determined nesting: scope B is nested in scope A iff B's iter_in and iter_out both lie in A's BFS-reachable interior (forward from A.iter_in, backward from A.iter_out, intersected). No author-side scope_id labels β the analyzer reads structure. (3) portIn / portOut nodes serve as the structural IO interface for non-outermost scopes β the third role for these nodes alongside their existing composite-boundary role (flatten_graph) and the eval_graph portOut harvest at graph-scope. portIn is the inner scope's "parameter slot"; portOut is the inner scope's "return slot." Cross-scope wires that bypass these IO nodes (direct outer-body β inner-body) are rejected by validate_graph_connectivity β the structural-IO requirement applies between two distinct author scopes, not between graph-scope and any author scope (the legacy seed/sink wires env_reset β Initialize, iter_out β evaluate continue to work unchanged). (4) Per-scope config lives on each iter_in node: each iter_in carries its own step_budget and terminationCondition (config schema unchanged β these fields are read by the executor per-scope). The outer iter_in's config gets a derivative nested_scope_ids: [child_iter_in_id, ...] written by analyze_scopes() as a UI lookup hint (frontend renders inner config under the outer panel). (5) Termination scope binding is topology-auto: a Termination node belongs to the innermost scope feeding it (resolved by Step 5.5 reassignment over its incoming sources). Inner termination ends only the inner scope; outer / outermost-scope termination ends the run. Single-scope graphs (all 17 in-repo) preserve byte-identical behaviour because is_single_scope == True triggers the executor's legacy-fast-path.
Alternatives
(a) Author-supplied scope_id labels on every node β rejected; pushes the framework's structural concern onto every graph author and silently breaks when labels drift from wires. (b) A new subgraphLoop containment node holding an inner graph with its own three-pivot β rejected by user as "not clean enough"; nests executor instances rather than scopes, prevents arbitrary cross-scope state access, and reintroduces the composite-boundary role overload onto a different node type. (c) Allow direct cross-scope wires (no portIn/portOut requirement) β rejected; without a structural boundary the analyzer can't distinguish "inner scope's IO" from "incidental data flow," scope re-entry semantics become ill-defined (does the wire fire on every outer iter or just once?), and authoring loses a navigable function-like surface. (d) Make portOut on inner scope propagate every inner-iter (streaming semantics) β rejected; outer scope sees N values per outer iter, breaking the function-call mental model. The decision is latched: outer sees the LAST value at inner termination only, mirroring a function return.
Rationale
The decision keeps the existing three-pivot mental model intact and adds exactly one new abstraction β scope (= one (Initialize, iterIn, iterOut) triple plus its body, IO nodes, and config). Authors compose multiple scopes in a flat graph the same way they compose multiple subgraphs today. Topology-auto nesting means the framework already "sees" the structure the author drew; no parallel ID system to maintain. Mandating portIn/portOut at every inner-scope boundary turns each inner scope into a complete reusable sub-function β the same waypoint-execution scope shape can be dropped into a SpatialNav agent, an HRL planner, or a tree-search rollout without re-authoring the IO interface. Per-scope config on each iter_in (rather than on a synthetic "scope object") keeps the canonical state next to the runtime entity that uses it, mirrors how three-pivot config already lives. Backward compat is non-negotiable and structural: is_single_scope == True means len(forest.scopes) <= 1, which collapses every existing graph to the legacy fast-path with no per-scope dispatch overhead.
Affected docs
design-docs/loop-control-system.md (new "Multi-scope iteration" section after the three-pivot section), design-docs/graph-system.md (add scope to authoring vocabulary), core/glossary.md (new terms: scope, inner scope, outer scope, outermost scope, scope forest, nested scope), core/codebase-map.md (new module agent_loop/scope_analysis.py), core/architecture.md (Execution Engines β note multi-scope), .claude/tutorials/skill-graph-json.md (new "Multi-scope graphs" worked example using VoxPoser v2)
Companion executor decisions
The runtime side β per-scope _ScopeState (counter / budget / terminated / portout buffer); settle-loop scope isolation (only drains nodes in the just-cycled scope); portOut latch propagation when an inner scope terminates; pull-check restricted to the just-cycled scope's terminations; per-scope step_budget exhaust path that propagates portOut latches and lets outer continue β is in ADR-executor-003. The dataflow change above is what authors see; the executor change is what makes it run.
Validation
validate_graph_connectivity calls analyze_scopes() and surfaces the analyzer's error list. Cross-author-scope wires that bypass portIn/portOut are rejected; duplicate pairedWith claims are rejected; unpaired iter_ins are rejected. All 17 existing single-scope graphs in workspace/graphs/ validate cleanly through the new pipeline (zero new errors). 19 unit tests cover the analyzer (12) and the executor's multi-scope behaviour (7); end-to-end validation via voxposer_libero_v2.json (outer = per-subtask reasoning, inner = per-waypoint execution) ran 1 outer iter Γ 15 inner iters on real LIBERO sim with correct portIn(trajectory) outerβinner routing, latched portOut(episode_success/step_index/...) innerβouter propagation, and eval-harvest of success / metrics from graph-scope portOut sinks.