Derive loop scopes and bindings from the runtime, not from a table (#472) - #479
Merged
Conversation
) #473 replaced one "inside a loop" boolean with per-construct contracts. The direction was right and the tables were wrong, because they were written from reading the source and checked only against themselves: every test asked whether the validator agreed with `loop_contracts`, and both sides came from the same file. That proves consistency and says nothing about truth. So the tables are now read off running pipelines, and `tests/test_loop_runtime_parity.py` re-derives them the same way on every run: a name a contract declares must render in a real execution, and a name it withholds must fail to. The suite is hermetic -- filesystem tool only, no model, no network. What that turned up: * `foreach` is not an alias of `for_each`. `for_each` writes its two files; `foreach` gives `Schema validation failed: 325 errors`, and in a shape that does compile the body runs once with nothing bound. #473 declared it supported on the strength of a comment. Removed (#475). * An `action_loop`'s body was validated outside the loop it is the body of. The `action_loop` key holds the actions, and `_build_iteration_context` builds iteration state before they run. * A `while` condition is not a `for_each` iterable. It is re-evaluated every iteration against `iteration` and `loop_state`, so `while: "{{ iteration < 3 }}"` -- the ordinary way to write a bounded loop -- was being rejected. * `create_parallel_queue` mixes phases inside one object: `on` generates the queue, the nested actions run per item. A flat tuple of source fields cannot say that, so scope is now keyed by field path. * A step declaring two loop constructs was silently resolved by declaration order in this module, which no engine agreed to. It is now an error. * The `$` spelling is an observed list, not `"$" + every binding`: `{{ $position }}` raises `unexpected char '$'` at run time while `{{ $item }}` resolves (#474). Four runtime defects found while probing, filed rather than fixed here: #475 (dead `foreach`), #476 (`action_loop`'s required `until` is never evaluated, so the loop always runs once), #477 (constructs leak each other's names as stale values -- a debug dict became the template surface), #478 (`create_parallel_queue` reports success while skipping an unresolved write). Where the runtime leaks a name with no meaning for the construct, this withholds it rather than blessing it, so validation is deliberately stricter than the runtime in exactly the cases #477 covers. That is a language decision, recorded in the module docstring. `docs/loop_variables.md` documented `{{ $position }}` (a compile error), an `as:` key nothing reads, and "both formats work identically". Rewritten from the verified tables, with the two known defects marked inline. 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.
Follow-up to #473, which had the right architecture and the wrong tables.
Why the previous tables could not be trusted
Every test in #473 asked whether the validator agreed with
core/loop_contracts.py. Both sides of that question came from the same file, so the suite proved the table was applied consistently and nothing about whether it was true.tests/test_loop_runtime_parity.pycloses that. For every name a contract declares, it executes a pipeline and requires the name to render; for names withheld, it requires them not to. Hermetic — filesystem tool only, no model, no network. Two mutations to the table fail it, and it caught a cheater test of my own along the way (the first version wrote the bare name into the file instead of{{ name }}, so all 31 "binding renders" assertions passed vacuously).What running it found
foreachis not an alias offor_each. #473 declared it one, on the strength of a comment.for_each: "['A','B']"writes0.txtand1.txt; the same file withforeach:givesSchema validation failed: 325 errors, and in a shape that does compile the body runs once with nothing bound.compiler/control_flow_compiler.py:119branches onfor_eachonly. Removed from the contracts (#475).An
action_loop's body was validated outside the loop it is the body of. #473 declaredaction_loopanduntilsource fields. Theaction_loopkey holds the actions, and_build_iteration_contextbuilds iteration state before they run, so{{ iteration }}in an action-loop body — the one place iteration state certainly exists — was rejected.A
whilecondition is not afor_eachiterable. It is re-evaluated every iteration against the contextshould_continueassembles atcontrol_flow/loops.py:428, which holdsiterationandloop_state. Sowhile: "{{ iteration < 3 }}"was being rejected. It is also narrower than the body:indexandpositionrender in awhilebody and fail in its condition, so this is not simply "conditions are body scope".create_parallel_queuemixes phases inside one object.ongenerates the queue; the nested actions run per item. A flat tuple of source fields put the whole object in one scope. Scope is now keyed by field path (create_parallel_queue.on), matched by longest prefix so narrowing one field leaves its siblings alone.A step declaring two loop constructs was resolved by declaration order inside
loop_contracts, an order no engine agreed to. Nowambiguous_loop_construct.The
$spelling is an observed list, not a derived one.{{ $position }}raisesunexpected char '$'at run time;{{ $item }}resolves. Deriving the set as"$" + every bindingaccepted the first (#474).Runtime defects found while probing — filed, not fixed here
foreachparses and silently does nothingaction_loop'suntil:is required and never evaluated —until: "false"withmax_iterations: 4still runs onceLoopContext.get_debug_info()is registered wholesale as the template surfacecreate_parallel_queuereports success while skipping a write whose template did not resolveNew evidence added to #474: the named-loop syntax
{{ $outer_loop.item }}runs correctly (verified) and cannot passvalidateat all, so there is no valid spelling for reaching an outer loop from a nested one.One decision worth challenging
Where the runtime leaks a name that has no meaning for a construct —
{{ iteration }}rendersNoneinside afor_each;{{ index }}renders0in an iterable that has not run — this withholds it rather than blessing it. Validation is therefore deliberately stricter than the runtime in exactly the cases #477 covers.The alternative is to accept whatever renders, which converts a bug into documented behaviour. I picked strictness because a
Nonethat silently reaches a template is the failure mode this project keeps paying for, but it is a language decision and it is reversible.Docs
docs/loop_variables.mdadvertised{{ $position }}(a compile error), anas:key nothing reads, and "both formats work identically". Rewritten from the verified tables, one section per construct, with #476 and #474 marked inline where they affect what a reader would write.Verification
ruff check src/orchestrator --select E9,F63,F7,F82,F821,F823,F601,F811— cleanaction_loopgiven source scope; ambiguity check removed; loops detected on any dict rather than steps; enclosing bindings subtracted instead of carriedOne regression caught during the work and fixed: removing Jinja's own
loopfrom the per-scope sets broke the data-flow validator, which consults the union to know{{ loop.index }}is not a missing task. It belongs in the union and in no contract.Not self-merging
This touches the template language and validation behaviour, which the last review asked be gated on independent approval — and #473, the PR this corrects, was self-merged. Leaving it for review; say the word and I'll merge.