Files
lda-wf/docs/historical/superpowers/plans/2026-09-04-foreach-back-edges.md
T

851 lines
29 KiB
Markdown

# Foreach Back-Edges Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> superpowers:subagent-driven-development (recommended) or
> superpowers:executing-plans to implement this plan task-by-task. Steps use
> checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make foreach bodies return through validated back-edges to their
immediate owner, with unique static control regions and fresh persisted state
for every dynamic foreach activation.
**Architecture:** Add one pure control-region analysis module and make both
validation and context inventory consume its result. Keep dynamic execution
separate: foreach activation metadata owns a barrier and item identities for
one controller visit, while frame advancement recognizes an immediate-owner
back-edge as item completion.
**Tech Stack:** Python 3.14, Pydantic workflow models, dataclass runtime state,
pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
**Spec:**
[`docs/superpowers/specs/2026-09-04-foreach-back-edge-design.md`](../specs/2026-09-04-foreach-back-edge-design.md)
## Global Constraints
- Preserve the flat serialized workflow graph; do not add nested body models.
- Keep `END` as a workflow/subgraph terminal, never a foreach-item return.
- Give every reachable node use exactly one static foreach-owner stack.
- Reject unreachable workflow nodes and structurally impossible item returns.
- Permit ordinary cycles when their nodes remain in one control region.
- Create a fresh persisted activation for every dynamic foreach-controller
visit, including repeated visits in the same parent frame.
- Keep current `fail`, `skip`, `collect`, reducer, interrupt, sync, and async
behavior.
- Keep nested context innermost-only; inherited structured foreach context is
deferred.
- Do not add break/continue, fork/gather, retry execution, a step limit, a new
Python DSL, or compatibility behavior for unobserved persisted data.
- Add docstrings or comments around owner-stack traversal, return-edge handling,
activation lifecycle, and any fail-closed runtime checks.
- Do not modify or commit the user's dirty `docs/AGENTS.md`.
---
### Task 1: Build the Pure Control-Region Analyzer
**Files:**
- Create: `src/wf_core/analysis/control_regions.py`
- Modify: `src/wf_core/analysis/__init__.py`
- Test: `tests/core/test_foreach_control_regions.py`
**Interfaces:**
- Consumes: `Workflow`, `ForeachNode`, `EndNode`, `Edge`, and `END`.
- Produces:
```python
type ForeachOwnerStack = tuple[str, ...]
class ControlRegionIssueKind(StrEnum):
UNREACHABLE_NODE = "unreachable_node"
FOREACH_REGION_CONFLICT = "foreach_region_conflict"
INVALID_FOREACH_RETURN = "invalid_foreach_return"
INVALID_FOREACH_TERMINAL = "invalid_foreach_terminal"
EMPTY_FOREACH_BODY = "empty_foreach_body"
FOREACH_BODY_NO_RETURN = "foreach_body_no_return"
@dataclass(frozen=True, slots=True)
class ControlRegionIssue:
kind: ControlRegionIssueKind
path: str
message: str
@dataclass(frozen=True, slots=True)
class ControlRegionAnalysis:
owner_stack_by_node: dict[str, ForeachOwnerStack]
issues: tuple[ControlRegionIssue, ...]
```
Function:
`analyze_control_regions(workflow: Workflow) -> ControlRegionAnalysis`.
- `owner_stack_by_node` contains only nodes whose region is unambiguous. Later
context analysis must not grant foreach fields to a conflicted node.
- [ ] **Step 1: Write failing acceptance tests for legal regions**
Add explicit tests named:
- `test_closed_root_cycle_has_one_empty_control_region`
- `test_foreach_cycle_with_possible_return_is_valid`
- `test_conditional_foreach_paths_can_both_return`
- `test_nested_foreach_assigns_static_owner_stacks`
- `test_reentering_completed_foreach_keeps_one_static_region`
The nested assertion must be exact:
```python
assert analysis.owner_stack_by_node == {
"f1": (),
"f2": ("f1",),
"work": ("f1", "f2"),
"tail": ("f1",),
"after": (),
}
assert analysis.issues == ()
```
- [ ] **Step 2: Run legal-region tests and confirm the missing module fails**
Run:
```bash
uv run pytest -q tests/core/test_foreach_control_regions.py
```
Expected: collection fails because `wf_core.analysis.control_regions` does
not exist.
- [ ] **Step 3: Implement semantic traversal over node and owner stack**
Use a bounded worklist of `(node_id, owner_stack)` states. The special edge
handling must follow this order:
```python
if source_is_foreach_loop:
if edge.to == source.id:
issue(EMPTY_FOREACH_BODY, edge_path)
continue
target_stack = (*owner_stack, source.id)
else:
target_stack = owner_stack
if target_is_terminal and target_stack:
issue(INVALID_FOREACH_TERMINAL, edge_path)
elif edge.to == target_stack[-1]:
record_item_return(edge, target_stack[-1])
connect_to_resumed_owner(edge.to, target_stack[:-1])
elif edge.to in target_stack:
issue(INVALID_FOREACH_RETURN, edge_path)
else:
enqueue(edge.to, target_stack)
```
An immediate return resumes the owner controller in the popped stack for
structural analysis; it does not assign the owner node to its child's stack.
Ignore unknown sources and targets here because ordinary edge validation
already owns those diagnostics.
- [ ] **Step 4: Write failing tests for every invalid pressure case**
Add one explicit test per topology:
- `test_external_entry_into_foreach_body_is_region_conflict`
- `test_foreach_body_escape_is_region_conflict`
- `test_skipping_inner_foreach_owner_is_invalid_return`
- `test_entering_sibling_foreach_body_is_region_conflict`
- `test_empty_foreach_body_is_rejected`
- `test_closed_foreach_body_cycle_has_no_return`
- `test_foreach_body_cannot_target_end_token`
- `test_foreach_body_cannot_target_explicit_end_node`
- `test_every_unreachable_node_is_reported`
Assert issue kind and location, for example:
```python
assert (issue.kind, issue.path) == (
ControlRegionIssueKind.FOREACH_REGION_CONFLICT,
"nodes[b]",
)
```
- [ ] **Step 5: Implement conflicts, reachability, and returnability**
Record the first stack for each node. If a second distinct stack reaches the
same node, remove it from `owner_stack_by_node` and emit one region conflict.
After traversal, report every workflow node never reached from `start`.
Build semantic state adjacency while traversing. For every reached state with
a non-empty stack, require a graph path to a return edge for its current top
owner. Returns from deeper nested foreach bodies may resume their controllers
on the way. A closed root cycle remains valid because its stack is empty.
Suppress cascading no-return diagnostics when a region conflict, invalid
return, invalid terminal, or empty body already makes that state ambiguous.
- [ ] **Step 6: Run analyzer tests**
Run:
```bash
uv run pytest -q tests/core/test_foreach_control_regions.py
uv run ruff check src/wf_core/analysis/control_regions.py \
tests/core/test_foreach_control_regions.py
uv run basedpyright --level error src/wf_core/analysis/control_regions.py
```
Expected: all commands pass.
- [ ] **Step 7: Commit the analyzer**
```bash
git add src/wf_core/analysis/control_regions.py \
src/wf_core/analysis/__init__.py \
tests/core/test_foreach_control_regions.py
git commit -m "feat: analyze foreach control regions"
```
### Task 2: Make Context Inventory Consume Static Regions
**Files:**
- Modify: `src/wf_core/analysis/context_scopes.py`
- Modify: `tests/core/test_context_scopes.py`
**Interfaces:**
- Consumes: `analyze_control_regions(workflow)` from Task 1.
- Produces: unchanged public functions `context_fields_by_node(workflow)` and
`context_analysis_warnings(workflow)`.
- Keeps the existing runtime contract: the innermost foreach supplies
`loop_item`, `loop_index`, and its alias; completing it restores the outer
context. It does not expose all enclosing aliases.
- [ ] **Step 1: Rewrite context tests to canonical back-edges**
Replace successful item routes such as:
```python
{"from": "body", "outcome": "ok", "to": END}
```
with:
```python
{"from": "body", "outcome": "ok", "to": "each"}
```
For the nested fixture use:
```python
{"from": "inner_body", "outcome": "ok", "to": "inner"}
{"from": "after_inner", "outcome": "ok", "to": "outer"}
```
- [ ] **Step 2: Replace the mixed-reachability expectation**
Delete the test that expects one node to receive conditional loop fields when
reached both inside and outside a foreach. Add a test proving conflicted nodes
receive no guaranteed foreach fields and the analysis warning includes the
region conflict.
Keep the nested assertions:
```python
assert "outer_item" not in inner
assert inner["inner_item"].availability == "available"
assert after_inner["outer_item"].availability == "available"
assert "inner_item" not in after_inner
```
- [ ] **Step 3: Run the context tests and confirm old traversal fails**
Run:
```bash
uv run pytest -q tests/core/test_context_scopes.py
```
Expected: failures show that the single `FrameScope` traversal neither pops
canonical return edges nor consumes region-conflict diagnostics.
- [ ] **Step 4: Replace duplicate traversal with the analyzer result**
Remove the local breadth-first scope traversal. For each unambiguous node,
derive its active context from the final stack item:
```python
stack = analysis.owner_stack_by_node.get(node_id)
active_foreach_id = stack[-1] if stack else None
```
Pass the controller's own static stack into item-schema resolution so an
inner foreach may still declare `over="context.outer_item"`. Convert analyzer
diagnostics to bounded context warnings. Do not reintroduce multiple scopes
or conditional fields for a single node use.
- [ ] **Step 5: Run context and authoring-contract tests**
Run:
```bash
uv run pytest -q tests/core/test_context_scopes.py \
tests/wf_api/test_authoring_contracts.py
uv run ruff check src/wf_core/analysis/context_scopes.py \
tests/core/test_context_scopes.py
uv run basedpyright --level error src/wf_core/analysis
```
Expected: all commands pass.
- [ ] **Step 6: Commit context integration**
```bash
git add src/wf_core/analysis/context_scopes.py \
tests/core/test_context_scopes.py
git commit -m "refactor: derive context from foreach control regions"
```
### Task 3: Give Every Foreach Visit a Persisted Activation
**Files:**
- Modify: `src/wf_core/runtime/foreach_state.py`
- Modify: `src/wf_core/runtime/scheduler.py`
- Modify: `src/wf_core/runtime/lineage.py`
- Modify: `src/wf_core/runtime/ops/foreach.py`
- Modify: `src/wf_core/runtime/ops/nodes.py`
- Modify: `src/wf_core/runtime/step.py`
- Modify: `tests/core/test_foreach_barrier_state.py`
- Modify: `tests/core/test_scheduler.py`
- Modify: `tests/core/test_concurrent_foreach.py`
- Modify: `tests/core/test_concurrent_foreach_errors.py`
- Modify: `tests/core/test_concurrent_foreach_interrupts.py`
- Test: `tests/core/test_foreach_activations.py`
**Interfaces:**
- Produces:
```python
@dataclass(slots=True)
class ForeachActivationState:
id: str
foreach_node_id: str
barrier: ForeachBarrierState
@dataclass(frozen=True, slots=True)
class ForeachItemOwner:
parent_frame_id: str
foreach_node_id: str
activation_id: str
item_index: int
```
Functions:
- `load_or_begin_foreach_activation(frame: ExecutionFrame,
foreach_node_id: str, *, mode: Literal["serial", "concurrent"])
-> ForeachActivationState`
- `save_foreach_activation(frame: ExecutionFrame, activation:
ForeachActivationState) -> None`
- `close_foreach_activation(frame: ExecutionFrame, activation:
ForeachActivationState) -> None`
- `item_frame_owner(frame: ExecutionFrame) -> ForeachItemOwner | None`
- `ForeachIterationMetadata` gains required `activation_id: str`.
- Callers compare owner fields by name; remove tuple slicing and positional
unpacking.
- [ ] **Step 1: Write failing activation-lifecycle tests**
Add tests proving:
```python
first = load_or_begin_foreach_activation(frame, "each", mode="serial")
save_foreach_activation(frame, first)
restored = load_or_begin_foreach_activation(frame, "each", mode="serial")
assert restored.id == first.id
close_foreach_activation(frame, restored)
second = load_or_begin_foreach_activation(frame, "each", mode="serial")
assert second.id != first.id
assert second.barrier.next_index == 0
```
Also test malformed metadata, mode mismatch, closing a stale activation, and
JSON round-trip through `ExecutionFrame.metadata`.
- [ ] **Step 2: Run activation tests and confirm failure**
Run:
```bash
uv run pytest -q tests/core/test_foreach_activations.py
```
Expected: imports fail because activation lifecycle helpers do not exist.
- [ ] **Step 3: Implement the activation metadata seam**
Hide the JSON dictionary shape inside `foreach_state.py`. Persist, per parent
frame and foreach node id, a monotonically increasing visit sequence plus at
most one active activation. Derive opaque ids from parent frame id, foreach
node id, and the persisted sequence; callers must never parse them.
Closing removes the active barrier but preserves the next sequence. Do not
retain a compatibility reader for the old barrier-only shape because the spec
found no real persisted foreach data.
- [ ] **Step 4: Add activation identity to item metadata and helpers**
Require this shape:
```python
ForeachIterationMetadata(
foreach_node_id=step.id,
activation_id=activation.id,
loop_index=loop_index,
loop_item=item,
loop_alias=step.as_,
)
```
Child frame and lineage ids must include `activation.id`, so a later visit at
item index zero cannot collide with the first visit.
- [ ] **Step 5: Move runtime callers onto named owner and activation state**
Update lineage reads, node-result buffering, async batching, failure
collection, refill, and barrier commit to load the activation named by the
child. Fail closed when a child result names a closed or different active
activation.
- [ ] **Step 6: Update focused metadata tests**
Replace hand-written item metadata in `test_foreach_barrier_state.py` and
`test_scheduler.py` with required activation ids. Update hard-coded child
frame and lineage ids in the concurrent, error, and interrupt suites to
include the activation identity. Assert `item_frame_owner` returns
`ForeachItemOwner`, not a tuple.
- [ ] **Step 7: Run runtime-state tests**
Run:
```bash
uv run pytest -q tests/core/test_foreach_activations.py \
tests/core/test_foreach_barrier_state.py tests/core/test_scheduler.py \
tests/core/test_concurrent_foreach.py \
tests/core/test_concurrent_foreach_errors.py \
tests/core/test_concurrent_foreach_interrupts.py
uv run ruff check src/wf_core/runtime tests/core/test_foreach_activations.py \
tests/core/test_foreach_barrier_state.py tests/core/test_scheduler.py
uv run basedpyright --level error src/wf_core/runtime
```
Expected: all commands pass.
- [ ] **Step 8: Commit activation identity**
```bash
git add src/wf_core/runtime/foreach_state.py \
src/wf_core/runtime/scheduler.py src/wf_core/runtime/lineage.py \
src/wf_core/runtime/ops/foreach.py src/wf_core/runtime/ops/nodes.py \
src/wf_core/runtime/step.py tests/core/test_foreach_activations.py \
tests/core/test_foreach_barrier_state.py tests/core/test_scheduler.py \
tests/core/test_concurrent_foreach.py \
tests/core/test_concurrent_foreach_errors.py \
tests/core/test_concurrent_foreach_interrupts.py
git commit -m "feat: identify dynamic foreach activations"
```
### Task 4: Execute Immediate-Owner Back-Edges
**Files:**
- Modify: `src/wf_core/runtime/ops/flow.py`
- Modify: `src/wf_core/runtime/foreach_state.py`
- Modify: `src/wf_core/runtime/ops/foreach.py`
- Test: `tests/core/test_foreach_back_edges.py`
- Modify: `tests/core/test_concurrent_foreach.py`
- Modify: `tests/core/test_concurrent_foreach_async.py`
- Modify: `tests/core/test_concurrent_foreach_errors.py`
- Modify: `tests/core/test_concurrent_foreach_interrupts.py`
- Modify: `examples/raw_concurrent_foreach.py`
- Modify: `examples/authoring_concurrent_foreach.py`
- Modify: `examples/demo_workflow.py`
- Modify: `tests/authoring/test_demo_workflow.py`
**Interfaces:**
- Consumes: `ForeachItemOwner` and activation lifecycle from Task 3.
- Produces: `advance_frame` recognizes a target equal to the immediate
owner's foreach node as item completion before generic node advancement.
- Produces an internal helper that derives ancestor foreach owners from frame
ancestry for defensive non-local-return rejection.
- [ ] **Step 1: Write failing serial return tests**
Add tests with canonical edges:
```python
Edge.model_validate({"from": "each", "outcome": "loop", "to": "work"})
Edge.model_validate({"from": "work", "outcome": "ok", "to": "each"})
Edge.model_validate({"from": "each", "outcome": "done", "to": END})
```
Prove two items execute, the child finishes at `each`, the parent wakes, and
the final workflow outcome remains `ok`.
- [ ] **Step 2: Write failing cycle, nested, and re-entry runtime tests**
Add explicit tests named:
- `test_foreach_body_cycle_can_repeat_then_return`
- `test_conditional_body_can_return_on_either_outcome`
- `test_nested_foreach_returns_inner_then_outer`
- `test_reentering_foreach_uses_fresh_activation_and_item_frames`
- `test_subgraph_end_returns_to_subgraph_node_then_foreach_owner`
- `test_nonlocal_runtime_return_fails_closed_when_validation_is_bypassed`
The re-entry test must visit one foreach node twice in the root frame and
assert two distinct activation ids and two distinct item-zero frame ids.
The nested test must prove an inactive foreach is entered normally, while the
direct defensive test constructs an invalid frame chain without running
workflow preparation and asserts `WorkflowExecutionError`.
- [ ] **Step 3: Implement immediate-owner return in frame advancement**
Before `END` handling or ordinary enqueue:
```python
owner = item_frame_owner(frame)
if owner is not None and next_node_id == owner.foreach_node_id:
source_node_id = frame.node_id
frame.prior_outcome = outcome
frame.activated_incoming_edge = source_node_id
frame.node_id = owner.foreach_node_id
frame.status = FrameStatus.COMPLETED
frame.finished_at_node_id = owner.foreach_node_id
wake_parent_for_child_progress(run, frame.id)
run.sync_from_current_frame()
return
```
Add a comment explaining why the child does not execute the target node. If
an item targets `END`, or targets a foreach found below its immediate owner in
the active ancestor chain, raise `WorkflowExecutionError` defensively.
- [ ] **Step 4: Close activations before controller completion edges**
In both serial and concurrent completion paths, close the active activation
before calling `advance_frame` for `done` or `completed_with_errors`. This
makes a self-looping or later returning completion edge start a fresh visit.
- [ ] **Step 5: Migrate executable foreach fixtures**
Change item-success routes from `END` to their owner in every file listed for
this task. Keep controller completion routes to `END` or their real outer
continuation. For nested fixtures, return inner bodies to the inner foreach
and outer-tail nodes to the outer foreach.
- [ ] **Step 6: Prove concurrent, async, error, and interrupt behavior**
Run:
```bash
uv run pytest -q tests/core/test_foreach_back_edges.py \
tests/core/test_concurrent_foreach.py \
tests/core/test_concurrent_foreach_async.py \
tests/core/test_concurrent_foreach_errors.py \
tests/core/test_concurrent_foreach_interrupts.py \
tests/core/test_raw_canonical_workflow_example.py \
tests/authoring/test_concurrent_foreach_examples.py \
tests/authoring/test_demo_workflow.py
```
Assert resumed interrupts retain the same activation id. Add a direct
fail-closed test showing a completed activation cannot accept a result or
wake-up from another activation.
- [ ] **Step 7: Run runtime static checks**
Run:
```bash
uv run ruff check src/wf_core/runtime examples \
tests/core/test_foreach_back_edges.py \
tests/core/test_concurrent_foreach.py \
tests/core/test_concurrent_foreach_async.py \
tests/core/test_concurrent_foreach_errors.py \
tests/core/test_concurrent_foreach_interrupts.py
uv run basedpyright --level error src/wf_core/runtime
```
Expected: all commands pass.
- [ ] **Step 8: Commit runtime back-edges**
```bash
git add src/wf_core/runtime/ops/flow.py \
src/wf_core/runtime/foreach_state.py \
src/wf_core/runtime/ops/foreach.py \
examples/raw_concurrent_foreach.py \
examples/authoring_concurrent_foreach.py examples/demo_workflow.py \
tests/core/test_foreach_back_edges.py \
tests/core/test_concurrent_foreach.py \
tests/core/test_concurrent_foreach_async.py \
tests/core/test_concurrent_foreach_errors.py \
tests/core/test_concurrent_foreach_interrupts.py \
tests/authoring/test_demo_workflow.py
git commit -m "feat: return foreach items through owner back-edges"
```
### Task 5: Enforce Control Regions Through Public Validation
**Files:**
- Modify: `src/wf_core/validation/issues.py`
- Modify: `src/wf_core/validation/core.py`
- Modify: `tests/core/test_foreach_control_regions.py`
- Modify: `tests/core/test_foreach_policy.py`
- Modify: `tests/artifacts/test_draft_models.py`
- Modify: `tests/artifacts/test_draft_adapter.py`
**Interfaces:**
- Consumes: `ControlRegionAnalysis` from Task 1.
- Produces public `ValidationIssueCode` values matching every
`ControlRegionIssueKind` value.
- Keeps `Workflow.validate_structure()` and `ValidationReport` signatures
unchanged.
- [ ] **Step 1: Add failing public-validation assertions**
For every pressure-case test, call both the pure analyzer and
`workflow.validate_structure()`. Invalid cases must assert the public code and
offending path:
```python
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.FOREACH_REGION_CONFLICT
]
assert matching[0].path == "nodes[b]"
```
Legal cases assert `report.ok`. Add a test proving every unreachable node in
one component receives its own `UNREACHABLE_NODE` issue.
- [ ] **Step 2: Run public-validation tests and confirm failure**
Run:
```bash
uv run pytest -q tests/core/test_foreach_control_regions.py
```
Expected: analyzer tests pass, but public reports lack the new issue codes.
- [ ] **Step 3: Wire analysis into validation once**
Add enum members with exactly the analyzer values. Call
`analyze_control_regions(workflow)` after ordinary node and edge validation,
then translate each diagnostic:
```python
report.add(
ValidationIssueCode(issue.kind.value),
issue.path,
issue.message,
)
```
Do not add a second graph traversal inside validation.
- [ ] **Step 4: Canonicalize policy and draft fixtures**
Policy-only workflow helpers must include a distinct body node and route it
back to the foreach owner. Draft fixtures with:
```python
"routes": {"each_item": {"loop": "echo", "done": "__end__"},
"echo": {"ok": "__end__"}}
```
become:
```python
"routes": {"each_item": {"loop": "echo", "done": "__end__"},
"echo": {"ok": "each_item"}}
```
Parse-only policy fixtures still use a distinct body; do not preserve an
invalid `loop -> __end__` shortcut just because the test does not execute it.
- [ ] **Step 5: Run validation and draft suites**
Run:
```bash
uv run pytest -q tests/core/test_foreach_control_regions.py \
tests/core/test_foreach_policy.py tests/artifacts/test_draft_models.py \
tests/artifacts/test_draft_adapter.py tests/wf_api/test_drafts_service.py
```
If another fixture expected an unreachable node to be valid, either connect
it when it represents intended execution or change that test to assert
`UNREACHABLE_NODE` when disconnection is the behavior under test. Do not add
an allow-unreachable flag.
- [ ] **Step 6: Run core validation static checks**
Run:
```bash
uv run ruff check src/wf_core/validation \
tests/core/test_foreach_control_regions.py \
tests/core/test_foreach_policy.py tests/artifacts/test_draft_models.py \
tests/artifacts/test_draft_adapter.py
uv run basedpyright --level error src/wf_core/validation
```
Expected: all commands pass.
- [ ] **Step 7: Commit fail-closed validation**
```bash
git add src/wf_core/validation tests/core/test_foreach_control_regions.py \
tests/core/test_foreach_policy.py tests/artifacts/test_draft_models.py \
tests/artifacts/test_draft_adapter.py
git commit -m "feat: validate foreach control regions"
```
### Task 6: Finish Migration, Documentation, and Full Verification
**Files:**
- Modify: `docs/wf_core_architecture.md`
- Modify: `docs/wf_authoring_control_flow.md`
- Modify: `docs/current_roadmap.md`
- Modify: `docs/superpowers/specs/2026-09-04-foreach-back-edge-design.md`
- Move after all checks pass:
`docs/superpowers/plans/2026-09-04-foreach-back-edges.md` to
`docs/historical/superpowers/plans/2026-09-04-foreach-back-edges.md`
**Interfaces:**
- Documents the implemented graph and runtime interface; adds no new runtime
surface.
- Preserves historical reports and recorded agent-challenge outputs verbatim.
- [ ] **Step 1: Search for stale canonical foreach returns**
Run targeted searches:
```bash
rg -n 'child reaches `END`|body.*->.*END|record.*->.*END' \
docs skills examples tests src -g '*.md' -g '*.py' \
-g '!docs/historical/**' -g '!examples/agent_challenges/**'
rg -n '"outcome": "loop".*"to": END|"loop": "__end__"' \
tests examples src -g '*.py'
```
Classify each match: controller completion remains terminal; item-body
completion changes to the owner. Do not rewrite unrelated ordinary terminal
routes or immutable historical evidence.
- [ ] **Step 2: Update live architecture and authoring docs**
Replace the old architecture statement that item children reach `END` with:
```text
An item child returns by targeting its immediate owning foreach. The child
finishes at that owner location without executing the controller; the parent
activation consumes the result and continues or completes its barrier.
```
Add the canonical authoring example:
```python
g.connect(each, "loop", record)
g.connect(record, "ok", each)
g.connect(each, "done", END)
```
Document that region conflicts, unreachable nodes, body terminals, non-local
returns, empty bodies, and bodies without possible returns fail validation.
- [ ] **Step 3: Mark the design implemented and roadmap item complete**
Set the spec status to `Implemented on 2026-09-04`. Move the roadmap bullet
from active correction to recently completed runtime work. Keep fork/gather
explicitly deferred.
- [ ] **Step 4: Run the focused acceptance matrix**
Run:
```bash
uv run pytest -q tests/core/test_foreach_control_regions.py \
tests/core/test_context_scopes.py tests/core/test_foreach_activations.py \
tests/core/test_foreach_back_edges.py \
tests/core/test_concurrent_foreach.py \
tests/core/test_concurrent_foreach_async.py \
tests/core/test_concurrent_foreach_errors.py \
tests/core/test_concurrent_foreach_interrupts.py \
tests/core/test_subgraph_step.py tests/authoring/test_demo_workflow.py \
tests/authoring/test_concurrent_foreach_examples.py
```
Expected: every current pressure-case row passes. The future-fork row remains
documented and unimplemented because no fork node exists.
- [ ] **Step 5: Run repository verification**
Run:
```bash
uv run pytest -q
uv run ruff check
uv run ruff format --check
uv run basedpyright --level error
pnpx markdownlint-cli2 \
'docs/superpowers/specs/2026-09-04-foreach-back-edge-design.md' \
'docs/wf_core_architecture.md' \
'docs/wf_authoring_control_flow.md'
git diff --check
```
Expected: all commands pass. If repository-wide Markdown files retain known
unrelated lint debt, do not run an unsafe global auto-fix; report it and keep
this slice's edited documents clean.
- [ ] **Step 6: Commit implementation documentation**
```bash
git add docs/wf_core_architecture.md docs/wf_authoring_control_flow.md \
docs/current_roadmap.md \
docs/superpowers/specs/2026-09-04-foreach-back-edge-design.md
git commit -m "docs: publish foreach back-edge semantics"
```
- [ ] **Step 7: Archive the completed plan**
After every prior task is complete and committed:
```bash
git mv docs/superpowers/plans/2026-09-04-foreach-back-edges.md \
docs/historical/superpowers/plans/2026-09-04-foreach-back-edges.md
git add docs/historical/superpowers/plans/2026-09-04-foreach-back-edges.md
git commit -m "docs: archive foreach back-edge plan"
```
Search for and update any live links to the plan's historical path before
committing. Leave the live implemented spec in `docs/superpowers/specs/`.