# iter loop-recur.1 — Additive AST-Node Foundation (Term::Loop / Term::Recur / LoopBinder) **Date:** 2026-05-17 **Started from:** a5eebcd5fd9d703cc4f8a6374f95e00061d86c55 **Status:** DONE **Tasks completed:** 7 of 7 ## Summary `loop` / `recur` become real, parseable, printable, round-trippable, hash-stable, strictly-additive AST nodes across all six crates plus the drift tests — with NO typecheck semantics and NO real codegen lowering (those are loop-recur iters 2 and 3). Added `Term::Loop { binders, body }`, `Term::Recur { args }`, and `struct LoopBinder` (mirroring `MutVar` exactly); Form-A `(loop (NAME TYPE INIT)* BODY+)` / `(recur ARG*)` parse + print + grammar/notes; prose render / count_free_var / subst_var arms; ~30 structural pass-through walker arms; the two semantic dispatch points stubbed with `CheckError::Internal` (`synth`) / `CodegenError::Internal` (`lower_term`) exactly as mut.1 did; DESIGN.md + form_a.md schema blocks; drift/coverage/hash anchors; and the spec's worked `sum_to` program as a green `examples/loop_sum_to.ail` round-trip fixture. `cargo test --workspace` 608 green / 0 red; the iter13a + new loop-recur hash pins both green (additivity proven — pre-existing canonical-JSON hashes byte-stable); all `tail`-filtered tests green (Boss-call-1 operational evidence). ## Per-task notes - iter loop-recur.1.1: Core AST — `Term::Loop`/`Term::Recur` variants + `struct LoopBinder` + 2 serde round-trip unit tests. RED-first (`error[E0599]`); GREEN after Task 4 (desugar/workspace are *in* ailang-core, so the lib's own exhaustive matches gate it — see Concerns). Both serde tests green. - iter loop-recur.1.2: Surface parser (`parse_loop`/`parse_recur` + head dispatch + unknown-head message), printer arms, form_a.md grammar+notes. Binder/body boundary disambiguated via an `is_term_head_kw` discriminator (the plan's flagged "one genuine parser judgement"). `parse_loop_and_recur_round_trip_via_term` green (exactly 2 binders + `if` body + 2-arg recur). - iter loop-recur.1.3: Prose projection — `write_term_prec`, `count_free_var`, `subst_var_with_term` Loop/Recur arms (verbatim plan code, lexical-shadow-aware mirroring the Mut arms). - iter loop-recur.1.4: ailang-core walkers — 9 desugar.rs sites + 2 workspace.rs sites. `cargo build -p ailang-core` GREEN. - iter loop-recur.1.5: ailang-check — 14 steps across 7 files including the Boss-call-1 `verify_tail_positions` two mandatory additive arms and the Boss-call-2 `synth` `CheckError::Internal` stub. `cargo build -p ailang-check` GREEN. - iter loop-recur.1.6: ailang-codegen + ail/main.rs — `lower_term` `CodegenError::Internal` stub + real structural pass-through at the pure analysis walkers (lambda/escape ×3) + `synth_with_extras` pass-through + main.rs walk_term/rewrite_term. `cargo build --workspace` GREEN. - iter loop-recur.1.7: DESIGN.md + form_a.md schema blocks; design_schema_drift + spec_drift + schema_coverage anchors; new loop_recur hash pin; `examples/loop_sum_to.ail` positive fixture. Full suite 608/0; tail-app non-regression green. ## Boss design calls (mirrored from the plan header at iter close) 1. **`verify_tail_positions` "byte-unchanged" = tail-app role unchanged, NOT source frozen.** `verify_tail_positions` (`crates/ailang-check/src/lib.rs`) is an exhaustive no-`_`-wildcard `match`-on-`Term`; the two additive variants *require* two new arms there or `ailang-check` does not compile. The added arms only define how the tail-app walker descends through two brand-new node kinds that no pre-existing fixture contains — zero behaviour change for any pre-existing construct. The acceptance evidence is the tail-app non-regression test (all `tail`-filtered tests green), NOT a frozen-source diff. mut.1 set the exact precedent (it added `Term::Mut`/`Term::Assign` arms inside the same function). Settled; not reopened — the spec-compliance phase did NOT emit `unclear`/BLOCKED on the anticipated "spec says byte-unchanged but the function changed" tension, per the plan header's authority. 2. **Codegen IS in iter-1 scope as stubs/pass-throughs (mirrors mut.1 site-for-site).** "No codegen in iter 1" means no real loop-header/phi/back-edge lowering (that is iter 3). But `ailang-codegen` carries no-wildcard exhaustive `Term` matches; the workspace would not compile without arms. Resolved exactly as mut.1: `CodegenError::Internal` stub at the `lower_term` dispatch + real structural pass-through at the pure analysis walkers (lambda.rs collect_captures, escape.rs walk/escapes/ collect_free_vars) and `synth_with_extras`. No real loop lowering written this iter. ## Concerns - DONE_WITH_CONCERNS (iter loop-recur.1.1/1.2/1.4): three plan-pseudo-vs-reality substitutions, all of the recurring "specs need concrete code / plan pseudo vs reality" class (`feedback_specs_need_concrete_code`, `feedback_plan_pseudo_vs_reality`), each resolved by preserving the arm/test's evident intent and fixing to the real codebase: 1. **Task-1 serde expected-bytes literal.** The plan asserted `"type":{"t":"con","name":"Int","args":[]}`; the real canonical `Type::Con` form is `{"k":"con","name":"Int"}` (discriminator `"k"`, empty `args` omitted — established since mut.2). The test's stated intent (pin one-binder Loop canonical bytes; prove `binders` has no `skip_serializing_if`) is preserved; only the mis-transcribed expected string was corrected. NOT a bug-dodge — `{"k":"con",…}` is the canonical form every Type fixture uses; the plan author mis-recalled the Type JSON. 2. **Bare `Int` vs `(con Int)` in binder triples.** The spec's worked example and the plan's Step-1 test source / Step-6 fixture write `(loop (acc Int 0) …)` with bare `Int`. The real `parse_type` parses a bare `Int` ident as `Type::Var{"Int"}` (a type *variable*), not the Int constructor — semantically wrong and inconsistent with every `mut` fixture (`(var x (con Int) 0)`), the spec schema, and the Task-1 serde pin. Corrected to `(con Int)` in the parser test source and the `examples/loop_sum_to.ail` fixture. This is the LLM-natural AND correct Form-A type (not a fixture-adapts-to-bug case — every sibling fixture uses `(con Int)`). 3. **`ailang_core::ast::LoopBinder` path inside ailang-core.** The plan's literal desugar.rs/workspace.rs arms used the downstream-crate path `ailang_core::ast::LoopBinder`; inside ailang-core itself (`use crate::ast::*;`) the correct path is unqualified `LoopBinder` (consistent with the adjacent bare `MutVar` usage). `subst_call_with_extras`'s plan arm also used `(target, extras)`; the real signature is `(name, lifted, extras)` — mirrored from the local Mut arm per the plan's own instruction. ailang-check (downstream) correctly keeps the `ailang_core::ast::LoopBinder` path. ## Boss addendum (post-orchestrator, at commit) Concern 2 surfaced that the bare-`Int`-in-binder imprecision is not just in the plan/test but in the **committed specs themselves**: `docs/specs/2026-05-17-loop-recur.md`'s headline clause-1 worked example + the must-fail `bad_recur` fixture, and `docs/specs/2026-05-17-llm-surface-discipline.md` §5's illustrative anchor, all wrote `(loop (acc Int 0) (i Int 1) …)`. Bare `Int` parses as `Type::Var{"Int"}`, so the spec's *headline evidence* — the program an LLM author would copy verbatim — does not parse to the typed loop it claims. Same doc-honesty class as the mono.rs header / effect-doc-honesty: a spec must show code that actually parses to what it asserts. Boss forward-fixed all three snippets to `(con Int)` (the established convention every `mut` fixture + the spec's own schema use); folded into this iter's commit. No design, semantics, schema, or load-bearing assumption changed — a surface-syntax precision fix, so no re-brainstorm / re-grounding. ## Known debt - No additional E2E fixture written (Phase 3). iter-1 ships NO typecheck and NO codegen by design; an `ail run` e2e would only exercise the deliberately-temporary `synth`/`lower_term` `Internal` stubs (a negative-value test asserting a transient error). The milestone invariant worth protecting at iter-1 — additivity (hash pins), round-trip (the new fixture), drift trio, tail-app non-regression — is fully covered by tests added in Tasks 1/2/7. The spec's positive sum_to-runs-to-a-value E2E is explicitly iter-3 scope. ## Files touched - AST + core walkers: `crates/ailang-core/src/ast.rs`, `desugar.rs`, `workspace.rs` - Surface: `crates/ailang-surface/src/parse.rs`, `print.rs`; `crates/ailang-core/specs/form_a.md` - Prose: `crates/ailang-prose/src/lib.rs` - Check: `crates/ailang-check/src/{lib,lift,mono,linearity, uniqueness,reuse_shape,pre_desugar_validation}.rs` - Codegen + CLI: `crates/ailang-codegen/src/{lambda,escape,lib}.rs`, `crates/ail/src/main.rs` - Docs/drift/coverage/hash: `docs/DESIGN.md`, `crates/ailang-core/tests/{design_schema_drift,spec_drift, schema_coverage,hash_pin}.rs`, `crates/ail/tests/codegen_import_map_fallback_pin.rs` (recon-undercount: an integration-test exhaustive `Term` match not in the plan's site inventory; same additive-arm class, mirrors the Mut defensive-recursion shape — Boss-call-2-class "recon under-scoped it") - New fixture: `examples/loop_sum_to.ail` ## Stats bench/orchestrator-stats/2026-05-17-iter-loop-recur.1.json