# 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
