7.8 KiB
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:
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.loopenters one iteration body.- An edge from an iteration body to that iteration frame's owning
ForeachNodereturns 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
ForeachNodeagain. - The parent foreach frame remains positioned at the controller. It admits the
next serial or concurrent item, or emits
done/completed_with_errorsafter its barrier finishes. - A foreach item path may not target
ENDor an explicitEndNode. Workflow terminal routes are outside an iteration body. ENDremains the workflow/subgraph terminal shorthand for workflow outcomeok; explicitEndNoderemains the terminal for other workflow outcomes.- Existing
fail,skip, andcollectitem-error policies are unchanged. - The core graph stays flat. Foreach does not gain a nested serialized body.
- The existing manual
WorkflowBuilder.foreach()andconnect()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:
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:
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
loopedge 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
doneorcompleted_with_errorsedge stays in the outer context; ENDorEndNodereached 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:
foreach.loop -> body
body -> END
to:
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
ENDand explicitEndNodefail 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,continuenodes, 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.