# Foreach Back-Edge Design ## Status Approved in conversation on 2026-09-04. This document specifies canonical foreach body-return semantics. It does not include the separately planned ergonomic Python DSL or authorize fork/gather implementation. ## Purpose Make the persisted workflow graph tell the truth about foreach control flow. An iteration body returns to its owning `ForeachNode` through an ordinary back-edge. It no longer routes to the workflow terminal `END` merely to make an internal child frame finish. The canonical shape is: ```text a -> foreach foreach.loop -> body body.ok -> foreach foreach.done -> c c -> END ``` This preserves a flat, inspectable control-flow graph. The runtime may continue to implement each item with a child execution frame and a parent barrier, but that scheduler detail no longer changes the visible meaning of `END`. ## Decisions - `ForeachNode.loop` enters one iteration body. - An edge from an iteration body to that iteration frame's owning `ForeachNode` returns the item to the controller. - Returning to the owner completes the item frame and wakes the blocked parent foreach frame. The child does not execute the `ForeachNode` again. - The parent foreach frame remains positioned at the controller. It admits the next serial or concurrent item, or emits `done`/`completed_with_errors` after its barrier finishes. - A foreach item path may not target `END` or an explicit `EndNode`. Workflow terminal routes are outside an iteration body. - `END` remains the workflow/subgraph terminal shorthand for workflow outcome `ok`; explicit `EndNode` remains the terminal for other workflow outcomes. - Existing `fail`, `skip`, and `collect` item-error policies are unchanged. - The core graph stays flat. Foreach does not gain a nested serialized body. - The existing manual `WorkflowBuilder.foreach()` and `connect()` interface remains the only authoring surface in this slice; no new authoring interface is added. ## Runtime Semantics The parent frame owns the foreach controller and resumable barrier. Taking its `loop` outcome creates an item frame whose metadata names that parent foreach. The item frame begins at the `loop` edge target and follows ordinary outcome edges. Before ordinary frame advancement, runtime flow checks whether an item frame's next target is its recorded owning foreach. If so, it records a foreach return: ```python owner = foreach_item_owner(frame) if owner is not None and next_node_id == owner.foreach_node_id: complete_item_frame(run, frame) wake_parent_for_child_progress(run, frame.id) return ``` This is an ownership check, not generic cycle detection. A root or unrelated frame targeting the same foreach node enters it normally. An item frame targeting a different foreach node also enters that node normally. Only a return to the frame's recorded owner completes the current item. The completed child records the owning foreach as its terminal graph location for trace and checkpoint inspection. Existing barrier code remains responsible for consuming its lineage-local writes, applying item-error policy, and waking or completing the parent. Serial and concurrent modes share the same return meaning: - serial mode has at most one live item frame and can admit the next item after its return; - concurrent mode may receive several independently identified item returns, buffers them by item identity, and completes only after its existing barrier policy is satisfied. ## Nested Foreach Nested foreach remains structured by frame ownership: ```text outer.loop -> inner inner.loop -> work work -> inner inner.done -> after_inner after_inner -> outer outer.done -> after_outer ``` The `work` frame returns only to `inner`, its immediate owner. Once the inner controller emits `done`, execution resumes in the outer item frame. Returning `after_inner -> outer` then completes the outer item. Runtime metadata already carries the immediate foreach owner. Static context analysis must evolve from a single active foreach identifier to an ownership stack so inner completion restores the outer item context. ## Validation and Analysis Validation performs structured abstract traversal over `(node_id, foreach_owner_stack)` rather than merely looking for graph cycles: - a `loop` edge from a foreach pushes that foreach onto the stack; - an edge targeting the top owner is an item return and ends that child traversal; - a `done` or `completed_with_errors` edge stays in the outer context; - `END` or `EndNode` reached with a non-empty foreach stack is invalid; - nested returns must target the immediate owner before an outer owner; - traversal memoizes node plus owner stack so valid cycles terminate analysis. A node may remain reachable in more than one execution context. Existing context-contract analysis can report fields as conditional in that case. The runtime must never infer edge meaning from graph history alone: frame ownership always decides whether targeting a foreach is entry or return. General unreachable-node detection is useful but is not part of this change. It cannot establish foreach ownership because any node reached from a body is connected by definition. General termination is also out of scope; finite structural checks cannot prove that data-dependent cycles eventually exit. ## State and Failure Behavior Back-edge return changes control representation, not state semantics. Iteration writes remain buffered in the item lineage. Serial behavior and the concurrent barrier continue to commit or merge those writes according to the accepted concurrent-foreach ADR and declared reducers. An ordinary node outcome named `error` remains domain control. An exception remains a runtime item failure handled by `fail`, `skip`, or `collect`. Neither kind of failure is encoded by the foreach back-edge itself. ## Migration This is a clean canonical migration. Repository tests, examples, user-facing docs, and generated fixtures change from: ```text foreach.loop -> body body -> END ``` to: ```text foreach.loop -> body body -> foreach ``` Configured local artifact stores contain no persisted foreach workflows at the time of design. There is therefore no demonstrated persisted-data requirement for keeping `body -> END` as a compatibility behavior. Validation rejects that old shape rather than silently preserving two canonical return forms. If real external persisted data is identified before implementation lands, it requires an explicit migration decision rather than a permanent implicit shim. Checkpoints from an in-progress old foreach execution are likewise not given a speculative compatibility path without real data. The implementation must not add parse-old behavior solely because repository fixtures previously used the old topology. ## Testing Focused tests must prove: - serial item return wakes the parent and admits the next item; - concurrent item returns preserve item identity and deterministic barrier commits; - a child targeting its owner returns instead of executing the foreach node; - a root frame targeting the same foreach enters it normally; - an item frame can enter a different nested foreach; - nested foreach restores the outer item context after inner completion; - item paths to `END` and explicit `EndNode` fail validation; - traces and serialized checkpoints retain an inspectable return location; - existing `fail`, `skip`, `collect`, interrupt, reducer-conflict, sync, and async behavior remains intact after fixture migration. ## Deferred Work - Ergonomic/context-manager Python authoring syntax. - `break`, `continue` nodes, target ports, or iteration return dispositions. - First-completed, first-error, first-success, cancellation, or race policies. - General unreachable-node and non-terminating-cycle diagnostics. - Fork/gather nodes and activation-token persistence. - Nested serialized workflow blocks.