Skip to content

Loop scope is one boolean where it needs per-construct binding contracts #472

Description

@jeremymanning

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions