Give each loop construct its own bindings and scope (#472) - #473
Merged
Conversation
Validation treated "inside a loop" as one boolean and one union of
names. Both halves were wrong.
The boolean reached every field of a loop-shaped step, including the
iterable that introduces the loop, so this was accepted:
- id: process
for_each: "{{ item.children }}" # no item exists yet
parameters:
text: "{{ item.name }}" # correct
The iterable must resolve before there is an item to bind. A loop
variable in it can never work, and validation said nothing.
The union gave every construct every name. `while` binds no `is_last`;
only `create_parallel_queue` binds `queue`. So `{{ is_last }}` inside a
`while` loop passed validation and then failed to render -- the false
negative #470 introduced while removing a false positive, and which
that PR's description claimed was impossible. The correction is posted
on #469.
core.loop_contracts holds one table per construct, each declaring the
fields evaluated before the loop exists and the names bound inside it.
The tables were read from the runtime -- context_manager, loops.py,
parallel_queue_task and action_loop_context -- rather than from
docs/loop_variables.md, which claims names the runtime does not bind in
every construct.
A rejection now names what the construct does bind, so it is
actionable rather than a puzzle.
template_globals derives its flat sets from the contracts instead of
keeping a second copy: ALL_LOOP_VARIABLES is ALL_BINDINGS by identity,
and LOOP_STEP_KEYS is tuple(LOOP_CONTRACTS), so a key cannot be
recognised as a loop without declaring what it binds.
Strengthening #470's alias test to require full validity, rather than
merely the absence of errors naming `item`, immediately failed for all
five constructs: it probed every one with `item`, which only passed
because every construct saw the union. It now probes each construct
with a name its own contract binds.
Mutations: routing source fields through body scope fails exactly the
two source-scope tests; giving every construct the union fails exactly
the four per-construct tests.
Blocking suite: 916 passed. Catalogue: all 52 still validate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #472. Addresses critical finding 2 from review.
Two defects, one cause
Validation treated "inside a loop" as one boolean and one union of names.
The iterable was validated in loop scope
_validate_object_templatescomputedis_loopfrom the step dict and passed it to every child, including the field that introduces the loop. The iterable must resolve before there is an item to bind.Every construct got every name
whilebinds nois_last; onlycreate_parallel_queuebindsqueue. Accepting the union let{{ is_last }}pass inside awhileloop and then fail to render.That false negative arrived with #470, whose description claimed it was impossible. Corrected on #469.
The contracts
core/loop_contracts.py— one table per construct, read from the runtime rather than fromdocs/loop_variables.md, which claims names the runtime does not bind in every construct:for_each/foreachfor_each,foreachitem,index,is_first,is_lastwhilewhile,untiliteration,index,is_first,position,loop_state,loop_idcreate_parallel_queuecreate_parallel_queueitem,index,queue,queue_size,is_first,is_last, …action_loopaction_loop,untilloop_id,iteration,is_first,has_previous, …Verified behaviour:
itemin thefor_eachiterablesteps[0].for_eachitemin the bodyis_lastin awhileloop_variable_wrong_constructiterationin awhilequeue_sizein a parallel queuequeue_sizein afor_eachA rejection names what the construct does bind, so it is actionable.
One declaration
template_globalsnow derives from the contracts instead of holding a second copy.ALL_LOOP_VARIABLES is ALL_BINDINGSis asserted by identity — an equal copy can drift, and drift is what produced #469.LOOP_STEP_KEYSistuple(LOOP_CONTRACTS), so a key cannot be recognised as a loop without declaring what it binds.Strengthening #470's test exposed that it was wrong
Review noted the #470 test "only checks that errors mentioning
itemare absent; it does not require complete validation."Requiring full validity made it fail for all five constructs — it probed every one with
item, andwhile/action_loopbind noitem. The weaker assertion had been satisfied by a pipeline rejected for an unrelated reason. Each construct is now probed with a name its own contract binds.Verification
Still open from the review
compile-and-runcoverage for every alias, and CLI/Python-API agreement, are not in this PR — they need the runtime harness rather than the validator, and belong with theexamples/supported/work. Theposition/remaining/has_next/has_prevgap is now closed forwhile(which bindsposition); the others remain absent from every construct's table because no runtime site binds them bare.🤖 Generated with Claude Code