# mut.4-tidy — Implementation Plan

> **Parent spec:** `docs/specs/0029-mut-local.md` (covered by
> audit-mut-local findings; no new spec).
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.

**Goal:** Close the mut-local milestone audit by addressing the
architect's drift findings and the bench check_ms regression.
Specifically: (a) add a typecheck diagnostic that rejects
lambda-captures-of-mut-var (`CheckError::MutVarCapturedByLambda`)
and a negative fixture; (b) replace the codegen `lambda.rs` blame-
typechecker comment + Internal-error path with an `unreachable!`
once the typecheck gate is in place; (c) clean stale mut.1-stub
history comments in DESIGN.md §"Term" and
`crates/ailang-codegen/src/lib.rs`; (d) short-circuit the empty
`mut_scope_stack` walk in the `Term::Var` arm of `synth` to
eliminate the per-Var-resolution overhead for programs with no mut
blocks.

**Architecture:** Architect's two `[high]` drift items are downstream
of one spec gap: `docs/specs/0029-mut-local.md` does not
enumerate "lambda body referencing an enclosing mut-var" as a
diagnostic. The fix has three sites — typecheck adds the diagnostic
in `Term::Lam`'s synth arm, codegen replaces its blame-comment path
with `unreachable!`, and the spec gets an "Out of scope" amendment
noting that mut-vars cannot escape into closures in this milestone.
The bench check_ms regression has one root cause — the
`mut_scope_stack.iter().rev().find_map(...)` walk at every
`Term::Var` resolution runs even when the stack is empty (every
program with no mut blocks); short-circuiting on `is_empty()` makes
the path zero-cost for the existing corpus.

**Tech Stack:** `ailang-check`, `ailang-codegen`, `docs/`. Plus one
new negative fixture and one driver-test extension.

---

**Files this plan creates or modifies:**

- Create: `examples/test_mut_var_captured_by_lambda.ail.json` —
  negative fixture: a lambda inside a mut block references a
  mut-var from the enclosing scope.
- Modify: `crates/ailang-check/src/lib.rs:638-746` — add
  `CheckError::MutVarCapturedByLambda { name }` variant + `code()`
  arm `mut-var-captured-by-lambda` + `ctx()` arm + Display.
- Modify: `crates/ailang-check/src/lib.rs` (`Term::Lam` arm of
  `synth`) — walk the lambda's body for free Vars that resolve to
  the enclosing `mut_scope_stack`; reject with
  `MutVarCapturedByLambda` if any match.
- Modify: `crates/ailang-check/src/lib.rs` (`Term::Var` arm of
  `synth`) — short-circuit when `mut_scope_stack.is_empty()`.
- Modify: `crates/ailang-codegen/src/lambda.rs:88-99` (capture-not-
  in-locals path) — replace the `CodegenError::Internal(... blames
  typechecker ...)` with `unreachable!("typecheck rejects
  lambda-capture-of-mut-var via MutVarCapturedByLambda since
  mut.4-tidy")`.
- Modify: `crates/ailang-codegen/src/lambda.rs:466-467` — remove
  the doc-comment promise that mut.2 typecheck rejects lambda
  captures (it does as of mut.4); rewrite to reflect current
  reality.
- Modify: `crates/ailang-check/src/lift.rs:379` — same comment
  cleanup.
- Modify: `crates/ailang-codegen/src/lib.rs:1749-1758` — remove the
  stale mut.1-stub comment block above the real Term::Mut arm.
- Modify: `docs/DESIGN.md:2369-2402` (§"Term (expression)" mut/assign
  jsonc block) — remove the trailing paragraph describing the
  mut.1-stub dispatch state; replace with a paragraph describing the
  current end-to-end mut.3 state.
- Modify: `docs/specs/0029-mut-local.md` (§"Out of scope" or
  equivalent) — add a bullet naming "Lambda capture of a mut-var"
  as explicitly rejected, with the diagnostic name.
- Modify: `crates/ailang-check/tests/mut_typecheck_pin.rs` — add
  driver test for the new fixture asserting the
  `mut-var-captured-by-lambda` diagnostic code.
- Modify: `crates/ailang-core/tests/carve_out_inventory.rs` —
  EXPECTED extended 12 → 13 for the new negative fixture.

---

## Task 1 — `CheckError::MutVarCapturedByLambda`

**Files:**
- Modify: `crates/ailang-check/src/lib.rs:638-746`

- [ ] **Step 1: Write the failing pin test**

In `#[cfg(test)] mod tests`:

```rust
#[test]
fn mut_var_captured_by_lambda_diagnostic_has_kebab_code() {
    let e = CheckError::MutVarCapturedByLambda { name: "sum".into() };
    assert_eq!(e.code(), "mut-var-captured-by-lambda");
    let _ = e.ctx();
}
```

- [ ] **Step 2: Run to verify red**

Run: `cargo test --workspace -p ailang-check mut_var_captured_by_lambda_diagnostic_has_kebab_code`
Expected: FAIL — variant does not exist.

- [ ] **Step 3: Add the variant + code() + ctx() + Display**

In the `CheckError` enum body, immediately after
`UnsupportedMutVarType`, insert:

```rust
    /// Iter mut.4-tidy: `Term::Lam` reached typecheck inside a
    /// `Term::Mut` scope, and the lambda's body references a mut-var
    /// declared in that enclosing scope. Spec
    /// `docs/specs/0029-mut-local.md` §"Out of scope": mut-vars
    /// cannot escape — they are alloca-resident and lexically scoped.
    /// Capturing one into a closure would require either lifting it
    /// to the heap (deferred milestone) or rejecting the capture.
    /// This milestone takes the latter route.
    #[error("[mut-var-captured-by-lambda] mut-var `{name}` cannot be captured by a lambda — mut-vars are alloca-resident and do not escape their enclosing mut block. Either move the lambda outside the mut block, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")]
    MutVarCapturedByLambda { name: String },
```

Then the `code()` arm immediately after `UnsupportedMutVarType`'s:

```rust
            CheckError::MutVarCapturedByLambda { .. } => "mut-var-captured-by-lambda",
```

And the `ctx()` arm:

```rust
            CheckError::MutVarCapturedByLambda { name } => json!({
                "name": name,
            }),
```

- [ ] **Step 4: Verify**

Run: `cargo test --workspace -p ailang-check mut_var_captured_by_lambda_diagnostic_has_kebab_code`
Expected: PASS.

---

## Task 2 — Reject lambda-captures-of-mut-var in `Term::Lam` synth arm

**Files:**
- Modify: `crates/ailang-check/src/lib.rs` (`Term::Lam` arm of `synth`)

The arm is around `lib.rs:3374-3407` per the architect's report.
The lambda's body is typechecked in a fresh `locals` scope with the
lambda's params bound — but `mut_scope_stack` carries over from the
caller. The fix is: BEFORE descending into the lambda body, scan
the body for free Vars whose names resolve in `mut_scope_stack`.
If any do, reject.

- [ ] **Step 1: Write the failing pin (positive — lambda outside mut still works)**

Add to `#[cfg(test)] mod tests`:

```rust
#[test]
fn lambda_outside_mut_still_typechecks_clean() {
    // (lam (params (typed x (con Int))) (ret (con Int)) (body (app + x 1)))
    let term = Term::Lam {
        params: vec!["x".into()],
        param_types: vec![Type::int()],
        ret_type: Type::int(),
        effects: vec![],
        body: Box::new(Term::App {
            func: Box::new(Term::Var { name: "+".into() }),
            args: vec![
                Term::Var { name: "x".into() },
                Term::Lit { lit: Literal::Int { value: 1 } },
            ],
            // tail-call default field name
        }),
    };
    let (_ty, _) = synth_in_builtins_env(&term);
    // No assertion on the returned type beyond that it doesn't panic
    // / fail — this is a regression test that lambda synth wasn't
    // broken by the new lambda-body free-var scan.
}
```

This test should PASS even before Task 2 Step 2 — it's a regression
guard.

- [ ] **Step 2: Write the failing negative pin**

```rust
#[test]
fn lambda_inside_mut_capturing_mut_var_emits_mut_var_captured_by_lambda() {
    // (mut (var x (con Int) 0)
    //   (let f (lam (params) (ret (con Int)) (body x))     ;; captures x
    //     0))
    let term = Term::Mut {
        vars: vec![ailang_core::ast::MutVar {
            name: "x".into(),
            ty: Type::int(),
            init: Term::Lit { lit: Literal::Int { value: 0 } },
        }],
        body: Box::new(Term::Let {
            name: "f".into(),
            value: Box::new(Term::Lam {
                params: vec![],
                param_types: vec![],
                ret_type: Type::int(),
                effects: vec![],
                body: Box::new(Term::Var { name: "x".into() }),
            }),
            body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
        }),
    };
    let err = synth_in_builtins_env_expect_err(&term);
    match err {
        CheckError::MutVarCapturedByLambda { name } => assert_eq!(name, "x"),
        other => panic!("expected MutVarCapturedByLambda, got {:?}", other),
    }
}
```

(`synth_in_builtins_env_expect_err` is whatever helper the existing
tests use to drive synth and unwrap an Err — match the convention.)

- [ ] **Step 3: Run to verify red**

Run: `cargo test --workspace -p ailang-check lambda_inside_mut_capturing_mut_var_emits_mut_var_captured_by_lambda`
Expected: FAIL — currently the lambda body resolves `x` against the
outer `mut_scope_stack` and types as Int with no diagnostic.

- [ ] **Step 4: Add the scan to `Term::Lam`'s synth arm**

In the `Term::Lam { params, param_types, body, .. }` arm of `synth`,
BEFORE descending into the body (i.e. before the call to
`synth(body, &lambda_locals, mut_scope_stack, ...)`), insert:

```rust
// Iter mut.4-tidy: reject lambda-captures-of-mut-var.
// Walk the lambda body for free Vars (names not bound by the
// lambda's own params) that match any mut-var on the enclosing
// stack. If any match, reject with MutVarCapturedByLambda.
if !mut_scope_stack.is_empty() {
    let captured = collect_free_vars_in_term(body);
    let lambda_param_names: std::collections::BTreeSet<&str> =
        params.iter().map(|s| s.as_str()).collect();
    for free_name in &captured {
        if lambda_param_names.contains(free_name.as_str()) {
            continue;
        }
        for frame in mut_scope_stack.iter() {
            if frame.contains_key(free_name) {
                return Err(CheckError::MutVarCapturedByLambda {
                    name: free_name.clone(),
                });
            }
        }
    }
}
```

The helper `collect_free_vars_in_term(t: &Term) -> Vec<String>` may
already exist (the codebase has free-var walkers in `desugar.rs:1154`-
area). If a directly-callable helper does not exist, write a small
local one:

```rust
fn collect_free_vars_in_term(t: &Term) -> Vec<String> {
    fn walk(t: &Term, bound: &mut Vec<String>, out: &mut Vec<String>) {
        match t {
            Term::Var { name } => {
                if !bound.contains(name) && !out.contains(name) {
                    out.push(name.clone());
                }
            }
            Term::Let { name, value, body } => {
                walk(value, bound, out);
                bound.push(name.clone());
                walk(body, bound, out);
                bound.pop();
            }
            Term::LetRec { name, body, in_term, .. } => {
                bound.push(name.clone());
                walk(body, bound, out);
                walk(in_term, bound, out);
                bound.pop();
            }
            Term::Lam { params, body, .. } => {
                let n = bound.len();
                for p in params { bound.push(p.clone()); }
                walk(body, bound, out);
                bound.truncate(n);
            }
            Term::Mut { vars, body } => {
                for v in vars { walk(&v.init, bound, out); }
                let n = bound.len();
                for v in vars { bound.push(v.name.clone()); }
                walk(body, bound, out);
                bound.truncate(n);
            }
            Term::Assign { name, value } => {
                // Assign's name IS a use of the binding — counts as a free var if not bound.
                if !bound.contains(name) && !out.contains(name) {
                    out.push(name.clone());
                }
                walk(value, bound, out);
            }
            Term::App { func, args, .. } => {
                walk(func, bound, out);
                for a in args { walk(a, bound, out); }
            }
            Term::If { cond, then, else_ } => {
                walk(cond, bound, out);
                walk(then, bound, out);
                walk(else_, bound, out);
            }
            Term::Match { scrutinee, arms } => {
                walk(scrutinee, bound, out);
                for arm in arms {
                    // pattern bindings... for simplicity, scan body
                    // without removing pattern-bound names — over-
                    // approximates free vars (worst-case: emits the
                    // diagnostic when none was needed). The over-
                    // approximation is conservative-safe for
                    // capture-rejection; a false-positive can be
                    // tightened in a future iter.
                    walk(&arm.body, bound, out);
                }
            }
            Term::Seq { lhs, rhs } => {
                walk(lhs, bound, out);
                walk(rhs, bound, out);
            }
            Term::Clone { value } => walk(value, bound, out),
            Term::ReuseAs { source, body } => {
                walk(source, bound, out);
                walk(body, bound, out);
            }
            Term::Ctor { args, .. } => for a in args { walk(a, bound, out); },
            Term::Do { args, .. } => for a in args { walk(a, bound, out); },
            Term::Lit { .. } => {}
        }
    }
    let mut bound = Vec::new();
    let mut out = Vec::new();
    walk(t, &mut bound, &mut out);
    out
}
```

(The exact match-arm shape must be exhaustive against the post-mut.1
Term enum. Pattern bindings inside Term::Match arms are
over-approximated for the first cut; a tightening pass can land in a
future tidy iter.)

- [ ] **Step 5: Verify**

Run: `cargo test --workspace -p ailang-check lambda_outside_mut_still_typechecks_clean lambda_inside_mut_capturing_mut_var_emits_mut_var_captured_by_lambda`
Expected: both PASS.

Run: `cargo test --workspace 2>&1 | grep -E 'FAILED|failures' | head -5`
Expected: no failures introduced. (If any existing test broke,
likely cause: the over-approximation in pattern bindings — check
which test, decide whether to tighten the helper or to add a
specific bound-name extraction for Term::Match arms.)

---

## Task 3 — Short-circuit empty `mut_scope_stack` in `Term::Var` arm

**Files:**
- Modify: `crates/ailang-check/src/lib.rs:2743-2747`

- [ ] **Step 1: Locate the existing prepend**

Find the iter mut.2 prepend in the `Term::Var` arm of `synth`,
introduced at `lib.rs:2743-2747`:

```rust
for frame in mut_scope_stack.iter().rev() {
    if let Some(ty) = frame.get(name) {
        return Ok((ty.clone(), /* ... */));
    }
}
```

- [ ] **Step 2: Wrap in an `is_empty` guard**

```rust
if !mut_scope_stack.is_empty() {
    for frame in mut_scope_stack.iter().rev() {
        if let Some(ty) = frame.get(name) {
            return Ok((ty.clone(), /* ... */));
        }
    }
}
```

This is a single-instruction short-circuit. For the existing test
corpus (zero mut blocks outside the four mut*.ail fixtures), this
collapses the prepend cost to one `is_empty()` check per Var
resolution — effectively zero overhead.

- [ ] **Step 3: Verify the existing pin tests still pass**

Run: `cargo test --workspace -p ailang-check 2>&1 | tail -5`
Expected: PASS.

- [ ] **Step 4: Re-run `compile_check.py` to confirm regression closes**

Run: `bench/compile_check.py 2>&1 | tail -10`
Expected: zero `REGRESSION` lines on check_ms. (If the short-circuit
brings check_ms back within tolerance, the bench exit drops to 0
for this script. If not, additional optimization is needed — but
the prepend cost is the only known introduced overhead in mut.2.)

---

## Task 4 — Codegen comment cleanup + unreachable blame fix

**Files:**
- Modify: `crates/ailang-codegen/src/lambda.rs:88-99`
- Modify: `crates/ailang-codegen/src/lambda.rs:466-467`
- Modify: `crates/ailang-codegen/src/lib.rs:1749-1758`

- [ ] **Step 1: `lambda.rs:88-99` — replace Internal blame with unreachable!**

Locate the existing path where a lambda capture is not found in
`self.locals`. The current arm raises `CodegenError::Internal` with
a message blaming the typechecker. Replace with:

```rust
            None => {
                // Iter mut.4-tidy: post-Task-2 the typechecker
                // rejects this via `CheckError::MutVarCapturedByLambda`.
                // Reaching this path means typecheck was skipped or
                // a bug let the AST through — both are bugs in
                // upstream layers, not in codegen.
                unreachable!(
                    "lambda capture {name:?} not in locals — \
                     typecheck should have rejected via MutVarCapturedByLambda",
                );
            }
```

- [ ] **Step 2: `lambda.rs:466-467` — comment cleanup**

The existing comment promises mut.2 rejects lambda captures of
mut-vars. Update to:

```rust
// Mut-var captures by lambdas are rejected at typecheck by
// CheckError::MutVarCapturedByLambda (mut.4-tidy). codegen does
// not see them.
```

- [ ] **Step 3: `lib.rs:1749-1758` — remove stale mut.1-stub comment**

The architect's report names this site as still describing the
mut.1 stub state. Read the current comment and either delete it (if
purely historical) or rewrite to reflect the mut.3 reality. The new
comment should NOT mention "stub", "deferred to mut.3", or
"Internal-error". A one-line comment summarising the arm's purpose
("Lower a mut-block: per-var alloca emit + body lower + scope
exit") is sufficient.

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

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

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

---

## Task 5 — `lift.rs` comment cleanup

**Files:**
- Modify: `crates/ailang-check/src/lift.rs:379`

- [ ] **Step 1: Update the comment**

The existing comment promises mut.2 rejects lambda captures of
mut-vars. Update to match the mut.4-tidy reality, in the same form
as Task 4 Step 2.

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

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

---

## Task 6 — DESIGN.md amendment

**Files:**
- Modify: `docs/DESIGN.md:2369-2402` (§"Term (expression)" mut/assign block)

- [ ] **Step 1: Update the trailing paragraph**

The current jsonc block ends with a paragraph describing the
mut.1-stub state. Replace with:

```
Both shapes ship in fully-lowered form as of iter mut.3 (2026-05-15):
typecheck binds mut-vars in a lexical scope stack, codegen lowers
them as entry-block allocas, and `Term::Assign` emits a `store`
yielding the canonical Unit SSA. Mut-vars must be of one of the
stack-resident scalar types {Int, Float, Bool, Unit}; capturing a
mut-var into a lambda is rejected at typecheck via
`CheckError::MutVarCapturedByLambda`. See
`docs/specs/0029-mut-local.md`.
```

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

Run: `cargo test --workspace -p ailang-core schema_drift 2>&1 | tail -5`
Expected: PASS.

---

## Task 7 — Spec amendment

**Files:**
- Modify: `docs/specs/0029-mut-local.md` (§"Out of scope" or §"Acceptance criteria")

- [ ] **Step 1: Add bullet to §"Out of scope"**

In the §"Out of scope" subsection, append a bullet:

```
- **Lambda capture of a mut-var.** A lambda body whose free vars
  include a mut-var of an enclosing `Term::Mut` is rejected at
  typecheck with `CheckError::MutVarCapturedByLambda`. Mut-vars are
  alloca-resident and lexically scoped; lifting them to the heap
  for closure-capture would require ref-types and the `!Mut`
  effect, both of which are deferred to the follow-on
  Stateful-islands milestone (the layered effect-handler + ref-type
  combo). The rejection diagnostic was added in iter mut.4-tidy.
```

---

## Task 8 — Negative fixture + driver test extension

**Files:**
- Create: `examples/test_mut_var_captured_by_lambda.ail.json`
- Modify: `crates/ailang-check/tests/mut_typecheck_pin.rs`
- Modify: `crates/ailang-core/tests/carve_out_inventory.rs`

- [ ] **Step 1: Write the fixture**

Create `examples/test_mut_var_captured_by_lambda.ail.json`:

```json
{
  "name": "test_mut_var_captured_by_lambda",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "ty": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Int" } },
      "params": [],
      "body": {
        "t": "mut",
        "vars": [
          { "name": "x", "type": { "k": "con", "name": "Int" }, "init": { "t": "lit", "lit": { "kind": "int", "value": 0 } } }
        ],
        "body": {
          "t": "let",
          "name": "f",
          "value": {
            "t": "lam",
            "params": [],
            "paramTypes": [],
            "retType": { "k": "con", "name": "Int" },
            "effects": [],
            "body": { "t": "var", "name": "x" }
          },
          "body": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
        }
      }
    }
  ]
}
```

Verify the exact root-key + field-name shape against an existing
sibling fixture (e.g. `examples/test_mut_assign_out_of_scope.ail.json`).

- [ ] **Step 2: Add driver test**

In `crates/ailang-check/tests/mut_typecheck_pin.rs`:

```rust
#[test]
fn lambda_capturing_mut_var_emits_mut_var_captured_by_lambda() {
    let codes = check_fixture("test_mut_var_captured_by_lambda.ail.json");
    assert_eq!(codes, vec!["mut-var-captured-by-lambda".to_string()]);
}
```

- [ ] **Step 3: Update carve-out inventory**

In `crates/ailang-core/tests/carve_out_inventory.rs`, bump the
EXPECTED count from 12 → 13 (the new fixture joins the existing
twelve `.ail.json` carve-outs).

- [ ] **Step 4: Run all driver tests**

Run: `cargo test --workspace -p ailang-check mut_typecheck_pin 2>&1 | tail -5`
Expected: all six tests PASS (five from mut.2 + the new one).

Run: `cargo test --workspace -p ailang-core carve_out_inventory 2>&1 | tail -3`
Expected: PASS.

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

---

## Self-review checklist

- [ ] **Drift coverage:** all four architect items (two `[high]` +
  two `[medium]`) plus the bench check_ms regression are addressed.
  Architect `[low]` items (prose render of MutVar) deliberately
  deferred per architect's recommendation.

- [ ] **Placeholder scan:** grep for "TBD" / "TODO" / "implement
  later" / "similar to Task" / "appropriate error handling" — no
  hits in this plan.

- [ ] **Type-name consistency:** `CheckError::MutVarCapturedByLambda`,
  `mut-var-captured-by-lambda` (kebab code), `collect_free_vars_in_term`
  (local helper), `mut_scope_stack`, `unreachable!` — each appears
  identically across tasks.

- [ ] **Step granularity:** each step is 2-5 minutes.

- [ ] **No commit steps:** verified.

---

## Acceptance gate for iter mut.4-tidy

- `cargo build --workspace` green.
- `cargo test --workspace` green, including the new negative-fixture
  pin and the carve_out_inventory bump.
- `bench/compile_check.py` check_ms regression closed (within
  tolerance vs. baseline).
- `bench/check.py` and `bench/cross_lang.py` unchanged from their
  pre-mut.4 state (tail-noise envelope holds; runtime stable).
- All architect-flagged comments rewritten to reflect the post-mut.3
  reality.
- The follow-on Stateful-islands milestone is now unblocked per the
  architect's gate.
