# mut.3 — codegen + e2e — Implementation Plan

> **Parent spec:** `docs/specs/2026-05-15-mut-local.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.

**Goal:** Replace the iter mut.1 dispatch stubs at
`crates/ailang-codegen/src/lib.rs:1683-1691` with real LLVM lowering:
mut-vars become `alloca` slots hoisted to the fn's entry block,
`assign` lowers to `store`, mut-var reads lower to `load`. Extend the
codegen-side `Term::Var` resolution ladder to consult mut-var allocas
before `self.locals`. Ship two end-to-end fixtures
(`examples/mut_counter.ail` + `examples/mut_sum_floats.ail`) and
two e2e test cases asserting expected stdout. Append a bullet to
DESIGN.md §"What is supported".

**Architecture:** Mut-var allocas land in the fn's entry block via
a side buffer `pending_entry_allocas: String` accumulated during
body lowering and flushed once into `self.body` at a tracked byte
position immediately after `start_block("entry")` emits the `entry:`
label. This is a deliberate departure from the existing alloca
sites (`match_lower.rs:114`, `lambda.rs:240/266`) which emit allocas
at the current body position — those allocas are one-shot per fn
invocation and don't suffer from being non-entry-block. Mut-var
allocas in deeply-nested blocks (e.g. inside `if`/`match`/`seq`)
must remain accessible across the whole fn body, so mem2reg
eligibility (entry-block placement) matters. Mut-var lookup at
codegen uses a per-fn `mut_var_allocas: BTreeMap<String, (String,
Type)>` mapping name → (alloca SSA name, AIL type). Push entries
on `Term::Mut`-entry, remove on exit; lexical scoping mirrors the
mut.2 typecheck-side stack.

**Tech Stack:** `ailang-codegen` only. No `ailang-core`,
`ailang-surface`, `ailang-check`, `ailang-prose`, or `ail` source
changes (one new test fn in `crates/ail/tests/e2e.rs`). Two new
example fixtures + one DESIGN.md amendment.

---

**Files this plan creates or modifies:**

- Create: `examples/mut_counter.ail` — sum 1..10 via mut, prints 55.
- Create: `examples/mut_sum_floats.ail` — Float twin (sum 1.0..10.0),
  prints the Float total.
- Modify: `crates/ailang-codegen/src/lib.rs:716-717` — add
  `mut_var_allocas: BTreeMap<String, (String, Type)>` and
  `pending_entry_allocas: String` and `entry_block_end_marker: Option<usize>`
  fields to `Emitter`.
- Modify: `crates/ailang-codegen/src/lib.rs:820-842` — initialise the
  three new fields in `Emitter::new`.
- Modify: `crates/ailang-codegen/src/lib.rs:1008-1034` — reset the
  three new fields per fn-body in `emit_fn`, and capture the
  entry-block byte marker after `start_block("entry")`.
- Modify: `crates/ailang-codegen/src/lib.rs:837-842` — extend
  `start_block` (or its caller) to record `entry_block_end_marker`
  on entry-block emission.
- Modify: `crates/ailang-codegen/src/lib.rs:1292-1357` (`Term::Var`
  arm) — prepend a mut-var lookup that emits a `load` and returns
  the SSA name.
- Modify: `crates/ailang-codegen/src/lib.rs:1683-1691` — replace
  the `Term::Mut`/`Term::Assign` `Internal` stubs with real lowering.
- Modify: `crates/ailang-codegen/src/lib.rs` (end of `emit_fn`) —
  flush `pending_entry_allocas` into `self.body` at the marker.
- Modify: `crates/ail/tests/e2e.rs` — add `mut_counter_prints_55`
  and `mut_sum_floats_prints_55_dot_0` test fns.
- Modify: `docs/DESIGN.md` — append a bullet to the "What **is**
  supported" subsection around line 2680.

---

## Task 1 — `Emitter` field additions + per-fn reset

**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:716-717` (struct fields)
- Modify: `crates/ailang-codegen/src/lib.rs:820-842` (`new`)
- Modify: `crates/ailang-codegen/src/lib.rs:1008-1034` (`emit_fn` reset)

- [ ] **Step 1: Add three fields to `Emitter`**

Locate the `Emitter` struct around `crates/ailang-codegen/src/lib.rs:716`.
After the existing field `current_param_modes: BTreeMap<String, ParamMode>`
(line 717), insert:

```rust
    /// Iter mut.3: per-fn map of mut-var name → (alloca SSA name,
    /// AIL element type). Populated when entering a `Term::Mut`
    /// block; entries removed when leaving the block. Consulted by
    /// the `Term::Var` arm of `lower_term` before `self.locals`,
    /// matching the typecheck-side mut-scope-stack precedence.
    mut_var_allocas: BTreeMap<String, (String, Type)>,

    /// Iter mut.3: side buffer for `alloca` instructions emitted
    /// during body lowering but hoisted to the fn's entry block.
    /// Flushed once into `self.body` at `entry_block_end_marker`
    /// after `lower_term` completes.
    pending_entry_allocas: String,

    /// Iter mut.3: byte position in `self.body` immediately after
    /// the `entry:` label, captured during `start_block("entry")`
    /// at fn-body emission. Used to splice `pending_entry_allocas`
    /// into the entry block once body lowering completes.
    entry_block_end_marker: Option<usize>,
```

- [ ] **Step 2: Initialise in `Emitter::new`**

Locate `Emitter::new` around `crates/ailang-codegen/src/lib.rs:820`.
Near the existing `closure_drops: BTreeMap::new(),` line (around 831),
add:

```rust
            mut_var_allocas: BTreeMap::new(),
            pending_entry_allocas: String::new(),
            entry_block_end_marker: None,
```

- [ ] **Step 3: Reset in `emit_fn`**

Locate the per-fn reset block in `emit_fn` (around
`crates/ailang-codegen/src/lib.rs:1008-1034` — where
`self.locals.clear()`, `self.counter = 0`, `self.moved_slots.clear()`,
`self.current_param_modes.clear()` already live). Append:

```rust
        self.mut_var_allocas.clear();
        self.pending_entry_allocas.clear();
        self.entry_block_end_marker = None;
```

- [ ] **Step 4: Verify build green**

Run: `cargo build --workspace 2>&1 | tail -10`
Expected: green (the three new fields are added but not yet
consumed; the only consumers are added in Tasks 2-3).

---

## Task 2 — `start_block` records the entry-block marker

**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:837-842` (`start_block`)

`start_block` is the canonical helper that emits `<label>:\n` and
positions the body for subsequent instruction emission. The entry
block is the first one emitted per fn. Marker capture happens here.

- [ ] **Step 1: Locate `start_block`**

Around `crates/ailang-codegen/src/lib.rs:837`, find:

```rust
    fn start_block(&mut self, label: &str) {
        self.body.push_str(label);
        self.body.push_str(":\n");
    }
```

(Exact body may differ; read the existing form first.)

- [ ] **Step 2: Capture the entry-block marker**

Replace with (or extend) the body so that when `label == "entry"`,
the byte position immediately after the `entry:\n` is captured:

```rust
    fn start_block(&mut self, label: &str) {
        self.body.push_str(label);
        self.body.push_str(":\n");
        if label == "entry" {
            self.entry_block_end_marker = Some(self.body.len());
        }
    }
```

The marker points to the byte index where the next character would
be written — i.e. the splice point for `pending_entry_allocas`.
Splicing at this position inserts the alloca block immediately after
the `entry:\n` label and before any subsequent body emission.

- [ ] **Step 3: Verify build green**

Run: `cargo build --workspace 2>&1 | tail -5`
Expected: green.

---

## Task 3 — `Term::Var` resolution prepends mut-var lookup

**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:1292-1357` (`Term::Var` arm of `lower_term`)

- [ ] **Step 1: Locate the `Term::Var` arm**

Around `crates/ailang-codegen/src/lib.rs:1292`, the existing arm
walks: `__unreachable__` → `self.locals.iter().rev().find(...)` →
`resolve_top_level_fn` → `resolve_const` → emit `UnknownVar`. The
mut-var lookup slots immediately before `self.locals`.

- [ ] **Step 2: Prepend mut-var lookup**

Insert before the `self.locals.iter().rev().find(...)` call:

```rust
            // Iter mut.3: mut-vars take precedence over let-bound
            // / param resolutions. Innermost-first (BTreeMap iterates
            // in sorted order, but per-fn entries are unique per
            // name with innermost-wins shadowing already enforced
            // at typecheck time, so a single lookup suffices here).
            if let Some((alloca_name, ail_ty)) = self.mut_var_allocas.get(name).cloned() {
                let llvm_ty = self.llvm_type(&ail_ty);
                let load_ssa = self.fresh_ssa_name("mut_load");
                self.body.push_str(&format!(
                    "  {load_ssa} = load {llvm_ty}, ptr {alloca_name}\n"
                ));
                return Ok(load_ssa);
            }
```

The exact helper names (`self.llvm_type`, `self.fresh_ssa_name`,
`self.body`, the return-tuple shape) must match the existing arms in
`lower_term` — the implementer reads adjacent arms (e.g. the
`self.locals.iter().rev().find()` branch immediately below) to copy
the canonical form. The example above is shape-correct but the
function-call sites (`llvm_type` / `fresh_ssa_name`) may be different
in this codebase; verify before writing.

Note: shadowing across nested mut blocks is handled by the
push/remove in Task 4, not by stack ordering here — when an inner
mut block shadows an outer's name, the inner's push overwrites the
`BTreeMap` entry; the outer's restored on inner-exit.

- [ ] **Step 3: Verify build green**

Run: `cargo build --workspace 2>&1 | tail -5`
Expected: green.

---

## Task 4 — `Term::Mut` codegen arm

**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:1683-1687` — replace the `Term::Mut` stub.

- [ ] **Step 1: Replace the stub**

Around `crates/ailang-codegen/src/lib.rs:1683`, replace:

```rust
            Term::Mut { .. } => {
                Err(CodegenError::Internal(
                    "Term::Mut not yet supported in codegen (deferred to iter mut.3)".into(),
                ))
            }
```

with the real lowering:

```rust
            Term::Mut { vars, body } => {
                // Saved-binding map for proper shadowing on exit.
                let mut saved: Vec<(String, Option<(String, Type)>)> = Vec::new();

                for v in vars {
                    let alloca_name = self.fresh_ssa_name(&format!("mut_{}", v.name));
                    let llvm_ty = self.llvm_type(&v.ty);

                    // Emit the alloca into the entry-block side
                    // buffer (hoisted, not at current body position).
                    self.pending_entry_allocas.push_str(&format!(
                        "  {alloca_name} = alloca {llvm_ty}\n"
                    ));

                    // Lower the init expression at the current body
                    // position. The mut-var binding is NOT yet
                    // visible during init (matches typecheck-side
                    // ordering at lib.rs:3576).
                    let init_ssa = self.lower_term(&v.init, /* whatever existing params */)?;

                    // Store the init result into the alloca at the
                    // current body position.
                    self.body.push_str(&format!(
                        "  store {llvm_ty} {init_ssa}, ptr {alloca_name}\n"
                    ));

                    // Now bind the name → alloca, saving any prior
                    // entry for restoration on block exit.
                    let prior = self.mut_var_allocas.insert(
                        v.name.clone(),
                        (alloca_name, v.ty.clone()),
                    );
                    saved.push((v.name.clone(), prior));
                }

                // Lower the body in the extended scope.
                let body_ssa = self.lower_term(body, /* whatever existing params */)?;

                // Restore bindings on exit.
                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); }
                    }
                }

                Ok(body_ssa)
            }
```

The exact `lower_term` call signature must match the existing arms
in the same `match` — the implementer reads an adjacent arm (e.g.
`Term::Let` or `Term::Seq`) for the canonical form. The init/store
ordering inside the loop is the load-bearing semantic: the spec
demands "vars are checked in order with the in-progress scope" at
typecheck (`lib.rs:3576`), and codegen mirrors this by binding only
AFTER the init is lowered.

- [ ] **Step 2: Build + verify the mut.ail typecheck-clean fns now don't reach codegen stub for Mut**

Run: `cargo build --workspace 2>&1 | tail -5`
Expected: green.

Run: `cargo run --quiet --bin ail -- build examples/mut.ail 2>&1 | head -20`
Expected: this command may fail at runtime for some of the six fns
that use Bool/Unit (not yet covered by Task 5 in lower_term's
`Term::Assign` arm) — but the build should NOT produce the
"Term::Mut not yet supported" error. If it does, the Task 4 arm
isn't reached; debug before continuing.

---

## Task 5 — `Term::Assign` codegen arm

**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:1688-1691` — replace the `Term::Assign` stub.

- [ ] **Step 1: Replace the stub**

Around `crates/ailang-codegen/src/lib.rs:1688`, replace:

```rust
            Term::Assign { .. } => {
                Err(CodegenError::Internal(
                    "Term::Assign not yet supported in codegen (deferred to iter mut.3)".into(),
                ))
            }
```

with the real lowering:

```rust
            Term::Assign { name, value } => {
                // Look up the mut-var's alloca and AIL type.
                let (alloca_name, ail_ty) = self.mut_var_allocas
                    .get(name)
                    .cloned()
                    .ok_or_else(|| CodegenError::Internal(
                        format!("Term::Assign {{ name: {name:?} }} reached codegen without a mut-var alloca — typecheck should have rejected this earlier")
                    ))?;

                let llvm_ty = self.llvm_type(&ail_ty);
                let value_ssa = self.lower_term(value, /* existing params */)?;

                self.body.push_str(&format!(
                    "  store {llvm_ty} {value_ssa}, ptr {alloca_name}\n"
                ));

                // Term::Assign's static type is Unit. Yield the
                // canonical Unit SSA value per the codegen
                // convention for Unit (verify against an adjacent
                // arm — likely a literal "i1 0" or similar; the
                // mut.ail mut_returns_unit fixture exercises this).
                Ok(self.unit_ssa()) // or whichever helper returns the canonical Unit SSA
            }
```

The `self.unit_ssa()` helper name is a placeholder; the implementer
identifies the canonical "Unit value" SSA emission convention from
an adjacent codegen arm (e.g. `Term::Lit { lit: Literal::Unit }`)
and uses whichever form matches.

- [ ] **Step 2: Verify build + mut.ail full pipeline**

Run: `cargo build --workspace 2>&1 | tail -5`
Expected: green.

Run: `cargo run --quiet --bin ail -- build examples/mut.ail 2>&1 | head -10`
Expected: build succeeds. (Whether the binary runs successfully is
exercised in Task 7 with the dedicated e2e fixtures.)

---

## Task 6 — Flush `pending_entry_allocas` at the end of `emit_fn`

**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs` — the end of `emit_fn` (just before the fn body is written out / `}` is emitted).

- [ ] **Step 1: Locate the emit_fn end**

Around `crates/ailang-codegen/src/lib.rs:1008+`, find where `emit_fn`
finishes assembling `self.body` and is about to flush it to
`self.ir` (or wherever the final IR text accumulates). This is the
splice point.

- [ ] **Step 2: Splice `pending_entry_allocas` at the marker**

Immediately before the body is written out:

```rust
        // Iter mut.3: splice any deferred entry-block allocas at
        // the captured marker (immediately after the `entry:\n`
        // label). If no mut-vars were emitted for this fn,
        // `pending_entry_allocas` is empty and this is a no-op.
        if !self.pending_entry_allocas.is_empty() {
            if let Some(marker) = self.entry_block_end_marker {
                let allocas = std::mem::take(&mut self.pending_entry_allocas);
                self.body.insert_str(marker, &allocas);
            } else {
                return Err(CodegenError::Internal(
                    "pending_entry_allocas accumulated but no entry-block marker was captured".into(),
                ));
            }
        }
```

This splices the alloca instructions into `self.body` at the byte
position recorded by Task 2's `start_block` extension. The result is
an entry block whose first instructions are `alloca` for each
mut-var encountered anywhere in the fn body, followed by whatever
the original entry-block instructions are.

- [ ] **Step 3: Verify build green**

Run: `cargo build --workspace 2>&1 | tail -5`
Expected: green.

---

## Task 7 — `examples/mut_counter.ail` + `examples/mut_sum_floats.ail`

**Files:**
- Create: `examples/mut_counter.ail`
- Create: `examples/mut_sum_floats.ail`

- [ ] **Step 1: Write `examples/mut_counter.ail`**

Create `examples/mut_counter.ail`:

```
(module mut_counter

  (fn main
    (doc "Iter mut.3 — sum 1..10 via mut/var/assign. Expected stdout: 55.")
    (type (fn-type (params) (ret (con Unit)) (effects IO)))
    (params)
    (body
      (app print
        (mut
          (var i (con Int) 1)
          (var sum (con Int) 0)
          (assign sum (loop_body i sum)))))))

(fn loop_body
  (doc "Helper — recursive sum 1..10. Replaced by a real loop in a future milestone.")
  (type (fn-type (params (con Int) (con Int)) (ret (con Int))))
  (params i acc)
  (body
    (if (app > i 10)
      acc
      (app loop_body (app + i 1) (app + acc i))))))
```

Wait — this requires mut to call a helper, which the mut codegen
supports (init / body / assign-value can be any Term). But the
example's *idiomatic* form is harder to express without `while`
(deferred). The simplest accurate form:

```
(module mut_counter

  (fn main
    (doc "Iter mut.3 — sum 1..10 via mut + recursive helper. Expected stdout: 55.")
    (type (fn-type (params) (ret (con Unit)) (effects IO)))
    (params)
    (body
      (app print
        (mut
          (var sum (con Int) 0)
          (assign sum (app sum_helper 1 10))
          sum))))

  (fn sum_helper
    (doc "Recursive helper — sum from lo through hi inclusive.")
    (type (fn-type (params (con Int) (con Int)) (ret (con Int))))
    (params lo hi)
    (body
      (if (app > lo hi)
        0
        (app + lo (tail-app sum_helper (app + lo 1) hi))))))
```

The mut block exercises one var (`sum`) with a real `assign` of a
non-trivial Term result. The recursive helper is the standing
tail-call iteration shape per Decision 8. Expected stdout: `55`.

- [ ] **Step 2: Write `examples/mut_sum_floats.ail`**

Create `examples/mut_sum_floats.ail`:

```
(module mut_sum_floats

  (fn main
    (doc "Iter mut.3 — Float twin of mut_counter. Expected stdout: 55.0 (or whatever the polymorphic-print form emits for Float 55.0).")
    (type (fn-type (params) (ret (con Unit)) (effects IO)))
    (params)
    (body
      (app print
        (mut
          (var sum (con Float) 0.0)
          (assign sum (app sum_helper 1.0 10.0))
          sum))))

  (fn sum_helper
    (doc "Recursive Float helper — sum from lo through hi inclusive.")
    (type (fn-type (params (con Float) (con Float)) (ret (con Float))))
    (params lo hi)
    (body
      (if (app > lo hi)
        0.0
        (app + lo (tail-app sum_helper (app + lo 1.0) hi))))))
```

Expected stdout: whatever the polymorphic Float `print` emits for
the value `55.0`. The implementer verifies against an existing
Float-print fixture (e.g. `examples/floats.ail`'s known output) to
fix the expected string.

- [ ] **Step 3: Verify both fixtures round-trip + typecheck clean**

Run: `cargo run --quiet --bin ail -- check examples/mut_counter.ail 2>&1`
Expected: `ok (...)`.

Run: `cargo run --quiet --bin ail -- check examples/mut_sum_floats.ail 2>&1`
Expected: `ok (...)`.

Run: `cargo test --workspace -p ailang-surface round_trip 2>&1 | tail -5`
Expected: PASS — the auto-glob round-trip picks up both new fixtures.

---

## Task 8 — e2e tests in `crates/ail/tests/e2e.rs`

**Files:**
- Modify: `crates/ail/tests/e2e.rs`

- [ ] **Step 1: Read the canonical e2e test pattern**

Open `crates/ail/tests/e2e.rs` and locate the `build_and_run`
helper (around lines 13-38 per the recon). Read the convention for
a `#[test]` that invokes it and asserts on stdout.

- [ ] **Step 2: Add the two test fns**

At the end of `crates/ail/tests/e2e.rs`, append:

```rust
#[test]
fn mut_counter_prints_55() {
    let stdout = build_and_run("mut_counter.ail");
    assert_eq!(stdout.trim(), "55", "mut_counter must print 55, got {stdout:?}");
}

#[test]
fn mut_sum_floats_prints_55_dot() {
    let stdout = build_and_run("mut_sum_floats.ail");
    // The exact float-print form is whatever the polymorphic print
    // emits for 55.0 — likely "55" if the formatter drops trailing
    // zeros, "55.0" if it preserves them. Verify against
    // examples/floats.ail's expected output before pinning.
    let expected = "55"; // placeholder — implementer reads the floats.ail e2e for the canonical form
    assert_eq!(stdout.trim(), expected, "mut_sum_floats must print {expected}, got {stdout:?}");
}
```

The "placeholder" comment marks a piece of work the implementer must
resolve at execution time by reading whichever existing Float-print
e2e test pins the expected stdout — the placeholder MUST NOT survive
into the committed file. Replace with the correct expected string
before the test passes.

- [ ] **Step 3: Run the new tests**

Run: `cargo test --workspace --test e2e mut_counter_prints_55 mut_sum_floats_prints_55_dot 2>&1 | tail -10`
Expected: both PASS.

Run: `cargo test --workspace 2>&1 | tail -5`
Expected: full workspace green.

---

## Task 9 — DESIGN.md amendment

**Files:**
- Modify: `docs/DESIGN.md` — append to "What **is** supported" subsection (around line 2680-2800).

- [ ] **Step 1: Locate the section**

Read `docs/DESIGN.md:2680-2800`. The section starts with:

```
What **is** supported (and used as the smoke test for the pipeline):

- Int, Bool, Unit, **Str**, **Float** as primitive types.
- `if`, `let`, function calls, recursion.
...
```

Find a suitable insertion point — likely after the "Anonymous lambdas
with capture" bullet (around line 2776) or after "Parameterised
ADTs" (around line 2788), depending on where the implementer judges
the new bullet thematically fits.

- [ ] **Step 2: Append the bullet**

Insert (with appropriate context):

```
- **Local mutable state.** `(mut (var <name> <type> <init>) ... <body>)`
  declares lexically-scoped mutable bindings whose types are restricted
  to the stack-resident scalars `Int`, `Float`, `Bool`, `Unit`. Inside
  the block, `(assign <name> <value>)` updates a mut-var; references
  to a mut-var name resolve to a `load` at codegen. Mut-vars are
  alloca-resident (hoisted to the fn's entry block, exercised by
  `examples/mut_counter.ail` and `examples/mut_sum_floats.ail`); they
  do not escape the enclosing mut block and do not introduce a `!Mut`
  effect onto the surrounding fn signature. Sealed-by-construction:
  the spec restricts var element types to non-RC-managed primitives
  and forbids first-class references, so Decision 10's "no shared
  mutable refs" invariant is preserved (each mut-var is uniquely held
  by its enclosing block). Future milestones on the Stateful-islands
  path lift the type restriction (heap-RC-managed Str + ADTs), add
  escaping references (`ref a` + `!Mut` effect), and introduce
  composable transducers (`Stateful a b` + `pipe`). Spec:
  `docs/specs/2026-05-15-mut-local.md`.
```

- [ ] **Step 3: Verify schema-drift tests still pass**

Run: `cargo test --workspace -p ailang-core schema_drift 2>&1 | tail -5`
Expected: PASS — the drift tests scope themselves to §"Data model"
(which is the §"Term (expression)" jsonc-blocked area, already
amended in mut.1), and this new bullet lives outside that scope.

---

## Self-review checklist

- [ ] **Spec coverage:** every section of spec §"Iteration mut.3 —
  Codegen + e2e" has a matching task. Cross-check: codegen arms for
  `Term::Mut` (Task 4) and `Term::Assign` (Task 5), entry-block-
  alloca-hoist mechanism (Tasks 1+2+6), `Term::Var` resolution
  prepending (Task 3), two example fixtures (Task 7), e2e tests
  (Task 8), DESIGN.md amendment (Task 9). All covered.

- [ ] **Placeholder scan:** grep for "TBD" / "TODO" / "implement
  later" / "similar to Task" / "appropriate error handling" /
  "fill in later". Task 8 Step 2 contains a deliberate
  "implementer reads the floats.ail e2e for the canonical form"
  placeholder that must be resolved before the test file is
  committed — the step explicitly names this. No other placeholders.

- [ ] **Type-name consistency:** `mut_var_allocas`,
  `pending_entry_allocas`, `entry_block_end_marker`,
  `CodegenError::Internal`, `LLVMBuildAlloca` (referenced in spec
  but in this codebase emitted as text via `push_str`), `Term::Mut`,
  `Term::Assign`, `MutVar`. Each appears identically across tasks.

- [ ] **Step granularity:** each step is 2-5 minutes (a single
  field addition; a single arm replacement; a single splice point).

- [ ] **No commit steps:** verified — no `git commit` instructions.

- [ ] **Boss decisions named:** the five recon open questions are
  resolved: (1) entry-block-hoist via side buffer +
  `String::insert_str` splice at marker (Tasks 1+2+6); (2) no LLVM
  helper wrapper — direct `push_str` (Task 4+5 code shape);
  (3) e2e in `crates/ail/tests/e2e.rs` (Task 8); (4) DESIGN.md
  bullet in "What **is** supported" subsection (Task 9 Step 1);
  (5) Unit codegen via the existing `Term::Lit { lit: Unit }` arm's
  canonical SSA emission (Task 5 Step 1 instruction).

---

## Acceptance gate for iteration mut.3

- `cargo build --workspace` green.
- `cargo test --workspace` green.
- `cargo run --bin ail -- check examples/mut.ail` clean.
- `cargo run --bin ail -- check examples/mut_counter.ail` clean.
- `cargo run --bin ail -- check examples/mut_sum_floats.ail` clean.
- `mut_counter.ail` builds and runs end-to-end via the e2e harness,
  printing `55`.
- `mut_sum_floats.ail` builds and runs end-to-end, printing the
  canonical Float-print form for `55.0` (resolved per Task 8 Step 2).
- DESIGN.md has the "Local mutable state" bullet under "What **is**
  supported".

After this iteration closes, the **mut-local milestone is closed**
end-to-end:
- Schema + surface from mut.1.
- Typecheck from mut.2.
- Codegen + e2e from mut.3.
The follow-on Stateful-islands milestones (ref-types, !Mut effect,
MutArray, Stateful + pipe) build on top of this. Audit (architect
drift + bench regression) follows.
