Two defects, one cause
The template validator treats "is this inside a loop?" as a single boolean, and applies it to every field of a loop-shaped step.
1. The loop source expression is validated in loop scope
- id: process
for_each: "{{ item.children }}" # accepted today; cannot ever work
parameters:
text: "{{ item.name }}" # correct
The iterable must resolve before an item exists. template_validator.py:575 computes is_loop on the step dict and passes in_loop_context or is_loop to every child, including the for_each value itself.
2. All loop constructs share one union of bindings
LOOP_VARIABLES is the union across constructs and in_loop_context is one boolean, so a name bound by one construct is accepted in all of them. {{ is_last }} in a while loop passes validation and does not resolve at run time.
This was introduced by #470 and is corrected in #469's comment thread — the PR description claimed no false negative was possible, which was wrong.
What the runtime actually binds
Read from the source, not the docs:
| construct |
bare names bound |
site |
for_each / foreach |
item, index, is_first, is_last |
control_system.py:304 registers these from metadata["loop_context"]; context_manager.py:241 supplies item, index, loop_id |
while |
iteration, index, is_first, position, loop_state, loop_id |
loops.py:571-584 |
create_parallel_queue |
item, index, queue, queue_size, is_first, is_last, parallel_queue_id, parent_task |
parallel_queue_task.py:300-307 |
action_loop |
loop_id, iteration, is_first, has_previous, total_duration, termination_reason |
action_loop_context.py:292-297 |
Note while binds neither item nor is_last, and only create_parallel_queue binds queue/queue_size.
Also unresolved
position, remaining, has_next, has_prev and length are documented in docs/loop_variables.md and bound by the runtime, but are absent from LOOP_VARIABLE_NAMES, so they remain falsely rejected. The #470 fix was narrower than the defect it addressed.
Proposed shape
Per-construct contracts declaring which fields are the source (outer scope) and which are the body (loop scope), plus that construct's bindings. validate_template takes the in-scope binding set rather than a boolean.
The runtime's own registration is spread across the four sites above with no single source of truth; the contract should be the one declaration those sites are checked against, so validator and runtime cannot drift again.
Tests required
item in an outer for_each expression is rejected
item inside per-item parameters is accepted
- a binding unsupported by
while is rejected
- loop variables do not escape after the loop
- nested loops shadow correctly
- a declared input named
item remains usable
- every claimed alias validates, compiles, runs, and produces the expected artifact
- CLI and Python API agree
The existing #470 test only asserts that errors mentioning item are absent; it does not require complete validation or runtime success.
Two defects, one cause
The template validator treats "is this inside a loop?" as a single boolean, and applies it to every field of a loop-shaped step.
1. The loop source expression is validated in loop scope
The iterable must resolve before an item exists.
template_validator.py:575computesis_loopon the step dict and passesin_loop_context or is_loopto every child, including thefor_eachvalue itself.2. All loop constructs share one union of bindings
LOOP_VARIABLESis the union across constructs andin_loop_contextis one boolean, so a name bound by one construct is accepted in all of them.{{ is_last }}in awhileloop passes validation and does not resolve at run time.This was introduced by #470 and is corrected in #469's comment thread — the PR description claimed no false negative was possible, which was wrong.
What the runtime actually binds
Read from the source, not the docs:
for_each/foreachitem,index,is_first,is_lastcontrol_system.py:304registers these frommetadata["loop_context"];context_manager.py:241suppliesitem,index,loop_idwhileiteration,index,is_first,position,loop_state,loop_idloops.py:571-584create_parallel_queueitem,index,queue,queue_size,is_first,is_last,parallel_queue_id,parent_taskparallel_queue_task.py:300-307action_looploop_id,iteration,is_first,has_previous,total_duration,termination_reasonaction_loop_context.py:292-297Note
whilebinds neitheritemnoris_last, and onlycreate_parallel_queuebindsqueue/queue_size.Also unresolved
position,remaining,has_next,has_prevandlengthare documented indocs/loop_variables.mdand bound by the runtime, but are absent fromLOOP_VARIABLE_NAMES, so they remain falsely rejected. The #470 fix was narrower than the defect it addressed.Proposed shape
Per-construct contracts declaring which fields are the source (outer scope) and which are the body (loop scope), plus that construct's bindings.
validate_templatetakes the in-scope binding set rather than a boolean.The runtime's own registration is spread across the four sites above with no single source of truth; the contract should be the one declaration those sites are checked against, so validator and runtime cannot drift again.
Tests required
itemin an outerfor_eachexpression is rejectediteminside per-itemparametersis acceptedwhileis rejecteditemremains usableThe existing #470 test only asserts that errors mentioning
itemare absent; it does not require complete validation or runtime success.