# loop-recur.3 — Codegen (Component 5) + run-to-value E2E — Implementation Plan > **Parent spec:** `docs/specs/0034-loop-recur.md` > > **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` > to run this plan. Steps use `- [ ]` checkboxes for tracking. **Goal:** Replace the iter-1 `lower_term` `CodegenError::Internal` stub for `Term::Loop`/`Term::Recur` with real LLVM-IR lowering so a `sum_to`-class loop program builds and runs to the correct printed value, a deep-`n` variant is safe by construction, and an infinite loop compiles — the terminal iteration of the loop/recur milestone. **Architecture:** Loop binders are loop-carried values lowered as entry-block allocas via the established mut.3 `pending_entry_allocas` mechanism, registered in the existing `mut_var_allocas` map so the existing `Term::Var` load path resolves them with ZERO new Var-resolution code (representation-sharing — a named alloca slot; the iter-2 AST/typecheck binder distinction is post-typecheck- irrelevant to codegen). The loop header is a fresh basic block reached by an unconditional `br` from the pre-header (current) block; `recur` lowers each arg, stores into the binder allocas, and back-edges `br` to the header. A new `loop_frames` codegen stack (header label + binder slots) lets `recur` find its target, is saved/reset/restored at the single lambda-lowering boundary exactly as mut.3's `mut_var_allocas` triple. `recur`'s back-edge `br` is a block terminator: it sets the existing `block_terminated` field at `recur`'s own emit site (the "parallel setter" — tail-app's SET sites byte-unchanged), so the enclosing `if`/`match` join excludes the recur block exactly like a tail-app block — which is precisely how the loop's exit value is materialised (no separate loop-result phi/exit-block). `clang -O2` mem2reg promotes the binder allocas to the phi nodes the spec's implementation-shape describes. **Tech Stack:** `ailang-codegen` (lib.rs — `lower_term` `Term::Loop`/`Term::Recur` arms + the `loop_frames` field; lambda.rs — the save/reset/restore boundary); `examples/` (3 new `.ail` fixtures); `crates/ail/tests/e2e.rs` (3 run/build tests). --- ## Boss design calls baked into this plan (settled — do not re-litigate) The recon surfaced four genuine codegen forks. All four are resolved here on architectural-consistency / spec-pinned-invariant grounds (NOT effort — each names its substantive reason): 1. **Loop binders → entry-block alloca + `mut_var_allocas` reuse, NOT hand-emitted `phi`.** The spec's "phi" wording lives only in the explicitly-*secondary* §"Concrete code shapes" implementation-shape subsection, which the spec itself subordinates to the planner's exact-bytes authority and to the acceptance criteria (none of which mandate literal `phi` in the emitted text). The established in-repo loop-carried-value mechanism is alloca-in-entry-block + `clang -O2` mem2reg (mut.3 — which the spec explicitly invokes for the lambda-boundary handling). The linear-emit codegen architecture structurally cannot hand-emit a header `phi` whose back-edge predecessors are discovered *during* body lowering without a string-splice hack that has **no precedent** (the `if` arm sidesteps this by opening its join block *last* — a loop header cannot be opened last). mem2reg produces the identical optimized binary, so the spec's loop-carried-SSA *intent* is delivered. Rationale = architectural consistency + absence of a linear-emit `phi` precedent + identical -O2 output; effort is explicitly NOT the reason. 2. **Binders ride the existing `mut_var_allocas` map + the existing `Term::Var` load path (representation-sharing).** A loop binder's codegen representation — a named alloca slot, loaded on each `Term::Var` use, stored on `recur` — is structurally identical to a mut-var's. Reusing the map keeps `Term::Var` lowering **byte-unchanged** (a parallel map would force editing Var-resolution to consult it too — the opposite of the minimal-touch discipline). This is representation-only; the iter-2 AST/typecheck distinction (binders live in `locals`, not `mut_scope_stack`) is unaffected because codegen runs post-typecheck and needs only the value representation. mut.3's shipped+E2E-green mut-var read path is the proof the load works. 3. **The loop's exit value is the emergent product of the existing `if`/`match` join once `recur` sets `block_terminated` — no separate loop-result phi/exit-block.** The spec says "loop is an expression; its value is body's value on the exiting branch." The body is typically `(if c (recur …))`; the existing `if`-arm one-branch-terminated logic (`lib.rs:1606-1613`) already drops the terminated (recur) branch and returns the non-recur branch's value — *iff* recur sets `block_terminated`. The `Term::Loop` arm therefore returns `lower_term(body)`'s `(ssa,ty)` directly. Building a separate loop-result phi would duplicate machinery the `if`/`match` arm already provides and risk a double-terminator. The emergent interaction is correct *by* the existing `block_terminated` lockstep (Boss call 4), not by accident. An infinite loop (`(loop (i …) (recur …))`, body = bare recur) compiles because `recur` sets `block_terminated` → it propagates through the `emit_fn` fall-through guard (`lib.rs:1124 if !self.block_terminated`) exactly as a tail-app-terminated function — the spec's "typechecks AND compiles, no termination claim" acceptance. 4. **Reuse the single `block_terminated` field; `recur` sets it at its own emit site (a new SET call-site), zero edits to any existing SET or READ site.** `block_terminated` models exactly "this block emitted its terminator — skip a fall-through, exclude from a surrounding join." A back-edge `br` *is* a block terminator with exactly that semantics. A literal second field would force duplicating every READ site (`lib.rs:1124/1581/1594/1600-1612/1718`) to also check it — the opposite of the spec-pinned invariant "tail-app's use of the seam byte-unchanged". "Parallel setter" = `recur`'s own `self.block_terminated = true` call-site, parallel to (not replacing) the tail-app SET site at `lib.rs:2279`. Tail-app's SET and every READ site stay byte-identical. --- ## Files this plan creates or modifies - Modify: `crates/ailang-codegen/src/lib.rs` - `:725`-adjacent — add the `loop_frames` struct field (next to `mut_var_allocas: BTreeMap`). - `:855`-adjacent — init `loop_frames: Vec::new()` (next to `mut_var_allocas: BTreeMap::new()`). - `:1845-1847` — replace the iter-1 `Term::Loop {..} | Term::Recur {..} => Err(CodegenError::Internal(...))` stub with two real arms. - Modify: `crates/ailang-codegen/src/lambda.rs` - `:144`-adjacent — `let saved_loop_frames = std::mem::take(&mut self.loop_frames);` (next to `saved_mut_allocas`). - `:260`-adjacent — `self.loop_frames = saved_loop_frames;` (next to the `self.mut_var_allocas = saved_mut_allocas;` restore). - Create: `examples/loop_sum_to_run.ail` — runnable `sum_to` (loop/recur), `main` prints `sum_to 10` → `55`. - Create: `examples/loop_sum_to_deep.ail` — same, `sum_to 1000000` → `500000500000` (deep-`n` safe-by-construction). - Create: `examples/loop_forever_build.ail` — `main` + an infinite `loop` (no non-`recur` exit); build-only acceptance. - Modify: `crates/ail/tests/e2e.rs` — 3 test fns (2 build+run, 1 build-only). - No edit / byte-frozen: `verify_tail_positions` + every existing `block_terminated` SET/READ site + the tail-app/tail-do lowering (Decision 8); the iter-2 typecheck (`synth` Loop/Recur arms, `verify_loop_body`, `loop_stack`, the four `Recur*` variants); the iter-1 `synth_with_extras` Loop→body-type / Recur→Unit pass-through (codegen type synthesis, a separate path from `lower_term`); `hash_pin.rs` (loop_recur + iter13a pins) and the drift trio + `carve_out_inventory.rs` (iter-3 is codegen-only — no schema/AST/serde change; recon confirmed these stay green untouched, the planner adds NO anchor work). --- ## Task 1: `sum_to` run-to-value E2E (RED) → loop/recur LLVM-IR lowering (GREEN) **Files:** - Create: `examples/loop_sum_to_run.ail` - Modify: `crates/ail/tests/e2e.rs` - Modify: `crates/ailang-codegen/src/lib.rs`, `crates/ailang-codegen/src/lambda.rs` - [ ] **Step 1: Create the runnable positive fixture** Create `examples/loop_sum_to_run.ail` (mirrors `examples/mut_counter.ail`'s `main` + `(app print …)` shape; sum 1..10 = 55, matching the `sum.ail` → `"55"` e2e precedent): ``` (module loop_sum_to_run (fn main (doc "loop-recur iter 3 — sum 1..10 via loop/recur. Expected stdout: 55.") (type (fn-type (params) (ret (con Unit)) (effects IO))) (params) (body (app print (app sum_to 10)))) (fn sum_to (type (fn-type (params (con Int)) (ret (con Int)))) (params n) (body (loop (acc (con Int) 0) (i (con Int) 1) (if (app > i n) acc (recur (app + acc i) (app + i 1))))))) ``` - [ ] **Step 2: Add the failing E2E test** In `crates/ail/tests/e2e.rs`, after the `sum.ail` → `"55"` test, add: ```rust #[test] fn loop_recur_sum_to_runs_to_value() { let stdout = build_and_run("loop_sum_to_run.ail"); assert_eq!(stdout.trim(), "55"); } ``` Run: `cargo test -p ail --test e2e loop_recur_sum_to_runs_to_value 2>&1 | tail -8` Expected: FAIL — `ail build` fails: codegen hits the iter-1 stub `CodegenError::Internal("Term::Loop/Term::Recur lowering lands in loop-recur iter 3")`. - [ ] **Step 3: Add the `loop_frames` codegen field + init** In `crates/ailang-codegen/src/lib.rs`, immediately after the `mut_var_allocas: BTreeMap,` field (`:725`), add: ```rust /// loop-recur iter 3: stack of enclosing `Term::Loop` codegen /// frames. Each frame is `(header_block_label, binder_slots)` /// where `binder_slots` is `(binder_name, alloca_ssa, llvm_ty)` /// in declaration order. Pushed on `Term::Loop` body entry, /// popped on exit; `Term::Recur` reads `.last()` for its target /// header + the binder allocas to store into. Saved/reset/ /// restored at the lambda-lowering boundary exactly as the /// mut.3 `mut_var_allocas` triple (a `recur` cannot cross a /// lambda boundary). loop_frames: Vec<(String, Vec<(String, String, String)>)>, ``` In the constructor, immediately after `mut_var_allocas: BTreeMap::new(),` (`:855`), add: ```rust loop_frames: Vec::new(), ``` - [ ] **Step 4: Lambda-boundary save/reset + restore of `loop_frames`** In `crates/ailang-codegen/src/lambda.rs`, immediately after `let saved_mut_allocas = std::mem::take(&mut self.mut_var_allocas);` (`:144`-region), add: ```rust // loop-recur iter 3: a lambda thunk is its own fn frame — // a `recur` cannot target a loop enclosing the lambda // (iter-2 typecheck already rejects it; codegen resets so // the thunk's own loops work and the outer frames cannot // leak in). Save + reset (mem::take empties the Vec), // restored below alongside the mut.3 triple. let saved_loop_frames = std::mem::take(&mut self.loop_frames); ``` In the restore block, immediately after `self.mut_var_allocas = saved_mut_allocas;` (`:260`-region), add: ```rust self.loop_frames = saved_loop_frames; ``` - [ ] **Step 5: Replace the stub with the `Term::Loop` lowering arm** In `crates/ailang-codegen/src/lib.rs`, replace the iter-1 stub (`:1845-1847`, verbatim): ```rust Term::Loop { .. } | Term::Recur { .. } => Err(CodegenError::Internal( "Term::Loop/Term::Recur lowering lands in loop-recur iter 3".into(), )), ``` with the `Term::Loop` arm (the `Term::Recur` arm is Step 6 — both land in this same replacement; write Step 5's arm then Step 6's arm contiguously, replacing the single stub arm with two arms): ```rust Term::Loop { binders, body } => { // loop-recur iter 3: loop binders are loop-carried // values lowered as entry-block allocas (the mut.3 // `pending_entry_allocas` mechanism) registered in // `mut_var_allocas` so the existing `Term::Var` load // path resolves them with zero new Var code // (representation-sharing; Boss calls 1+2). The loop // header is a fresh block reached by an // unconditional `br` from the pre-header (current) // block; `recur` stores new values into the binder // allocas and back-edges here. `clang -O2` mem2reg // promotes the allocas to the phi nodes the spec's // implementation-shape describes. The loop's value // is the body's value on the non-`recur` exit, // materialised by the existing `if`/`match` join // once `recur` sets `block_terminated` (Boss call 3). let id = self.fresh_id(); let header = format!("loop.header.{id}"); let mut saved: Vec<(String, Option<(String, Type)>)> = Vec::new(); let mut frame_slots: Vec<(String, String, String)> = Vec::new(); for b in binders { let alloca_name = format!("%loopv_{}_{}", b.name, self.fresh_id()); let llvm_ty = llvm_type(&b.ty)?; self.pending_entry_allocas .push_str(&format!(" {alloca_name} = alloca {llvm_ty}\n")); let (init_ssa, init_ty) = self.lower_term(&b.init)?; if init_ty != llvm_ty { return Err(CodegenError::Internal(format!( "Term::Loop binder `{}`: init LLVM type {init_ty} != declared {llvm_ty}", b.name ))); } self.body.push_str(&format!( " store {llvm_ty} {init_ssa}, ptr {alloca_name}\n" )); let prior = self .mut_var_allocas .insert(b.name.clone(), (alloca_name.clone(), b.ty.clone())); saved.push((b.name.clone(), prior)); frame_slots.push((b.name.clone(), alloca_name, llvm_ty)); } self.body.push_str(&format!(" br label %{header}\n")); self.loop_frames.push((header.clone(), frame_slots)); self.start_block(&header); let body_result = self.lower_term(body); self.loop_frames.pop(); // Restore prior bindings unconditionally (mirrors the // mut.3 `Term::Mut` arm — defensive on the `?` path). for (name, prior) in saved.into_iter().rev() { match prior { Some(p) => { self.mut_var_allocas.insert(name, p); } None => { self.mut_var_allocas.remove(&name); } } } body_result } ``` - [ ] **Step 6: Add the `Term::Recur` lowering arm** Immediately after the `Term::Loop` arm from Step 5 (still inside the `match`, replacing the back half of the original single stub arm), add: ```rust Term::Recur { args } => { // loop-recur iter 3: re-enter the innermost // enclosing loop. Typecheck (iter 2) pinned arity == // binder count and per-arg type == binder type, so a // miss/mismatch here is an internal error (the // `Term::Assign`-arm precedent). ALL args are lowered // to SSA values BEFORE any store, so a recur arg that // reads a binder sees its OLD value (recur is a // simultaneous positional rebind, e.g. // `(recur (+ acc i) (+ i 1))`). The back-edge `br` is // a block terminator: set `block_terminated` at // recur's OWN emit site (the parallel setter — Boss // call 4; tail-app's SET sites byte-unchanged) so the // enclosing `if`/`match` join excludes this block // exactly like a tail-app block. recur never falls // through; its value is never used — return the // canonical Unit/dead SSA (`("0","i8")`, the // `Term::Assign` / both-if-branches-terminated // precedent). let (header, slots) = self .loop_frames .last() .cloned() .ok_or_else(|| { CodegenError::Internal( "Term::Recur reached codegen with no enclosing loop frame \ — typecheck should have rejected this earlier" .into(), ) })?; if args.len() != slots.len() { return Err(CodegenError::Internal(format!( "Term::Recur arg count {} != enclosing loop binder count {} \ — typecheck should have rejected this earlier", args.len(), slots.len() ))); } let mut lowered: Vec<(String, String)> = Vec::with_capacity(args.len()); for a in args { lowered.push(self.lower_term(a)?); } for ((arg_ssa, arg_ty), (_name, alloca_name, llvm_ty)) in lowered.into_iter().zip(slots.iter()) { if &arg_ty != llvm_ty { return Err(CodegenError::Internal(format!( "Term::Recur arg LLVM type {arg_ty} != binder type {llvm_ty} \ — typecheck should have rejected this earlier" ))); } self.body.push_str(&format!( " store {llvm_ty} {arg_ssa}, ptr {alloca_name}\n" )); } self.body.push_str(&format!(" br label %{header}\n")); self.block_terminated = true; Ok(("0".into(), "i8".into())) } ``` - [ ] **Step 7: Run the positive E2E to verify GREEN** Run: `cargo test -p ail --test e2e loop_recur_sum_to_runs_to_value 2>&1 | tail -8` Expected: PASS — `stdout.trim() == "55"`. (Binder allocas hoisted to the entry block; `Term::Var acc/i` load via the existing `mut_var_allocas` path; `recur` stores + back-edges; the `if` non-recur branch yields `acc`; `clang -O2` mem2reg promotes the allocas. sum 1..10 = 55.) - [ ] **Step 8: Confirm no regression** Run: `cargo build -p ailang-codegen 2>&1 | tail -2` Expected: PASS — `Finished`. Run: `cargo test -p ail --test e2e 2>&1 | grep -E '^test result:'` Expected: PASS — every pre-existing e2e test still green (the stub removal touched no built fixture; tail-app/mut e2e unaffected). --- ## Task 2: Deep-`n` safe-by-construction + infinite-loop-compiles **Files:** - Create: `examples/loop_sum_to_deep.ail`, `examples/loop_forever_build.ail` - Modify: `crates/ail/tests/e2e.rs` - [ ] **Step 1: Create the deep-`n` fixture** Create `examples/loop_sum_to_deep.ail` (sum 1..1_000_000 = 500000500000; a hand-written non-tail self-recursion to depth 1e6 would stack-overflow — the loop back-edge is iterative, safe by construction; this is the spec §"Testing strategy" clause-2 claim made executable): ``` (module loop_sum_to_deep (fn main (doc "loop-recur iter 3 — sum 1..1000000 iteratively. A non-tail hand-recursion to this depth would overflow; loop/recur is safe by construction. Expected stdout: 500000500000.") (type (fn-type (params) (ret (con Unit)) (effects IO))) (params) (body (app print (app sum_to 1000000)))) (fn sum_to (type (fn-type (params (con Int)) (ret (con Int)))) (params n) (body (loop (acc (con Int) 0) (i (con Int) 1) (if (app > i n) acc (recur (app + acc i) (app + i 1))))))) ``` - [ ] **Step 2: Add the deep-`n` E2E test** In `crates/ail/tests/e2e.rs`, after the Task-1 test, add: ```rust #[test] fn loop_recur_deep_n_safe_by_construction() { // 1..1_000_000 summed via the loop back-edge (no stack growth); // a hand-written non-tail self-recursion to this depth would // overflow. sum 1..1_000_000 = 500000500000 (fits i64). let stdout = build_and_run("loop_sum_to_deep.ail"); assert_eq!(stdout.trim(), "500000500000"); } ``` Run: `cargo test -p ail --test e2e loop_recur_deep_n_safe_by_construction 2>&1 | tail -6` Expected: PASS — `stdout.trim() == "500000500000"` (the loop runs 1e6 iterations via the back-edge with no stack growth; Task-1's mechanism already shipped, so this is the executable proof of the clause-2 correctness claim). - [ ] **Step 3: Create the infinite-loop build-only fixture** `examples/loop_forever.ail` (iter-2) has NO `main` (it is the typecheck-pin fixture). The "infinite loop compiles" acceptance needs a `main`-bearing build target. Create `examples/loop_forever_build.ail`: ``` (module loop_forever_build (fn main (doc "loop-recur iter 3 — an infinite loop (no non-recur exit) must COMPILE (typechecks AND compiles; no termination claim). Build-only; by design never returns, so the binary is never executed.") (type (fn-type (params) (ret (con Unit)) (effects IO))) (params) (body (app print (app spin 0)))) (fn spin (type (fn-type (params (con Int)) (ret (con Int)))) (params n) (body (loop (i (con Int) 0) (recur (app + i 1)))))) ``` - [ ] **Step 4: Add the build-only E2E test** In `crates/ail/tests/e2e.rs`, after the Step-2 test, add (build only — the binary by design never returns, so it is NOT executed; this asserts the spec "typechecks AND compiles, no termination claim"): ```rust #[test] fn loop_recur_infinite_loop_compiles() { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); let src = workspace.join("examples").join("loop_forever_build.ail"); let tmp = std::env::temp_dir().join(format!( "ailang_e2e_loopforever_{}", std::process::id() )); std::fs::create_dir_all(&tmp).unwrap(); let out = tmp.join("bin"); let status = Command::new(ail_bin()) .args(["build", src.to_str().unwrap(), "-o"]) .arg(&out) .status() .expect("ail build failed to run"); assert!( status.success(), "ail build must succeed on an infinite loop (typechecks AND compiles, no termination claim)" ); } ``` (`Path` and `Command` are already imported at the top of `e2e.rs` — `use std::path::Path; use std::process::Command;`. Do not re-import.) Run: `cargo test -p ail --test e2e loop_recur_infinite_loop_compiles 2>&1 | tail -6` Expected: PASS — `ail build` exits 0. (`spin`'s loop has no non-`recur` exit → `recur` sets `block_terminated` → it propagates through `emit_fn`'s `if !self.block_terminated` fall-through guard, so `spin` emits no `ret` and is a well-formed never-returning function; the unreachable `print`/`ret` in `main` after the non-returning `spin` call is LLVM-legal. The binary is never run.) --- ## Task 3: Regression gates — tail-app / hash / full suite byte-unchanged **Files:** none (verification only — no source changes). - [ ] **Step 1: `tail-app` / `tail-do` / Decision-8 non-regression** Run: `cargo test --workspace tail 2>&1 | grep -E '^test result:' | tail -5` Expected: PASS — every `tail-app`/`tail-do`/`verify_tail_positions` test byte-identical. (iter-3 added a *parallel* `block_terminated` SET call-site in `Term::Recur` and touched no existing SET/READ site; Boss call 4. This re-confirms the spec's frozen-tail-app acceptance criterion at the codegen level.) - [ ] **Step 2: Hash-pin + drift non-regression (codegen-only iter)** Run: `cargo test -p ailang-core --test hash_pin 2>&1 | grep -E '^test result:'` Expected: PASS — `loop_recur_schema_extension_preserves_pre_loop_recur_hashes` + `iter13a_schema_extension_preserves_pre_13a_hashes` green. (iter-3 changed only `ailang-codegen` + `examples/*.ail` + e2e tests — no schema/AST/serde change; the recon confirmed the hash pins and the drift trio + `carve_out_inventory` are not codegen-dependent and stay green untouched. The new fixtures are `.ail` not `.ail.json`, so they are round-trip-auto-covered, not carve-outs.) - [ ] **Step 3: Full workspace suite** Run: `cargo test --workspace 2>&1 | grep -E '^test result: ok' | awk '{p+=$4} END {print p" passed"}'; cargo test --workspace 2>&1 | grep -E 'FAILED|error\[|^test result: FAILED' | head` Expected: passed count ≥ the loop-recur.2 baseline (616) + the 3 new e2e tests; zero `FAILED`/`error[`. - [ ] **Step 4: Round-trip auto-coverage of the new `.ail` fixtures** Run: `cargo test -p ailang-surface --test round_trip 2>&1 | grep -E '^test result:'` Expected: PASS — `round_trip.rs` auto-discovers the three new `examples/*.ail` fixtures and proves parse→print→parse idempotency on each (the milestone's Roundtrip-Invariant gate, now covering runnable loop/recur programs). --- ## Acceptance criteria (this iteration) - The iter-1 `lower_term` `CodegenError::Internal` stub is gone; `Term::Loop`/`Term::Recur` lower to real LLVM IR. - `examples/loop_sum_to_run.ail` builds and runs to `55`; `examples/loop_sum_to_deep.ail` runs 1e6 iterations to `500000500000` (deep-`n` safe by construction — the clause-2 correctness claim made executable). - An infinite `loop` (`examples/loop_forever_build.ail`) **compiles** (`ail build` exit 0); it is never executed (no termination claim is made or enforced). - `tail-app`/`tail-do`/`verify_tail_positions`/Decision 8 byte-unchanged; every existing `block_terminated` SET/READ site byte-unchanged (recur adds a parallel SET call-site only); the iter-2 typecheck (`synth` arms, `verify_loop_body`, `loop_stack`, the four `Recur*` variants) byte-unchanged; the iter-1 `synth_with_extras` pass-through byte-unchanged. - Pre-existing canonical-JSON hashes bit-stable (iter-3 is codegen-only — no schema change); hash pins + drift trio + `carve_out_inventory` green untouched. - `cargo test --workspace` green; the three new `.ail` fixtures round-trip (Roundtrip Invariant holds for runnable loop/recur programs). ## Cross-references - Parent spec `docs/specs/0034-loop-recur.md` (Component 5 + Positive E2E = this iter; this is the milestone's TERMINAL iteration — after it ships the milestone CLOSES and the Boss runs `audit`, then `fieldtest` since loop/recur is user-visible Form-A surface; that pipeline is the Boss's post-iter call, NOT part of this plan). - mut.3 (`docs/specs/0029-mut-local.md`) is the codegen precedent: `pending_entry_allocas` entry-block hoist, `mut_var_allocas` save/restore, the lambda-boundary save/reset/restore triple. loop-recur.3 reuses the *mechanism*; the loop-specific additions (loop-header block, back-edge `br`, `loop_frames` stack, the parallel `block_terminated` SET site) are Boss calls 1-4 above. - iter-1 (`a179ec3`) shipped the `lower_term` Loop/Recur `CodegenError::Internal` stub this iter replaces + the `synth_with_extras` pass-through that stays. iter-2 (`1566ce0`) shipped the typecheck this iter relies on (binder typing pins the alloca types; the four `Recur*` codes mean a codegen-side arity/type miss is `CodegenError::Internal`, the typecheck-already-rejected precedent). - Boss calls 1-4 (alloca-not-hand-phi; `mut_var_allocas` reuse; emergent loop-exit via the `if`/`match` join; single `block_terminated` field + parallel SET site) are recorded here and must be mirrored into the per-iter journal at iter close.