# mut.1 — schema + surface — Implementation Plan

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

**Goal:** Land the AST extension for `Term::Mut`, `Term::Assign`, and
the nested `MutVar` struct; wire canonical-JSON serde; add Form A
`(mut …)` / `(var …)` / `(assign …)` productions in the surface parser
and printer; extend the four drift / coverage tests; amend
DESIGN.md §"Term (expression)" and `crates/ailang-core/specs/form_a.md`
with the new shapes; and add `examples/mut.ail` as the round-trip
fixture. After this iteration, `Term::Mut` and `Term::Assign` exist as
first-class AST nodes that round-trip cleanly between Form A text and
canonical JSON, but typecheck still rejects them with an internal-error
stub (typecheck recognition lands in mut.2).

**Architecture:** Two new `Term` variants behind the existing
`#[serde(tag = "t")]` machinery — no per-variant serde wiring required.
A new `pub struct MutVar { name, ty, init }` adjacent to `Arm` in
`ast.rs`. Form A surface gains three productions parsed positionally
(`(mut …)` peels leading `(var …)` entries then right-folds the
remaining body sequence into `Term::Seq`; `(assign N V)` is a fixed
positional pair). Every `Term` exhaustive `match` in the workspace
(~25 sites carrying a `Term::ReuseAs` arm today) gains a new arm —
substantive (structural recurse) for every site except the two
dispatch entry points (`check_term` in `ailang-check/src/lib.rs` and
`lower_term` in `ailang-codegen/src/lib.rs`), which stub with
`CheckError::Internal` / `CodegenError::Internal` per the spec's
"out of iteration" boundary.

**Tech Stack:** `ailang-core` (AST + serde + drift tests),
`ailang-surface` (parse + print), `ailang-check` (walker arms +
typecheck dispatch stub), `ailang-codegen` (walker arms + codegen
dispatch stub), `ailang-prose` (walker arms), `ail` (CLI walker arms).

---

**Files this plan creates or modifies:**

- Create: `examples/mut.ail` — round-trip fixture (empty mut, single-
  var, two-var, nested, scalar return-type variants).
- Modify: `crates/ailang-core/src/ast.rs:386-525` — `Term` enum
  extension + new `MutVar` struct.
- Modify: `crates/ailang-core/src/desugar.rs` — nine `Term::ReuseAs`-
  adjacent walker arms gain substantive `Term::Mut` + `Term::Assign`
  arms at lines 340, 531, 1154, 1298, 1419, 1477, 1519, 1548, 2660.
- Modify: `crates/ailang-core/src/workspace.rs:1245, 1367` — two
  walker arms (one of them visits embedded types in `MutVar.ty`).
- Modify: `crates/ailang-core/specs/form_a.md:253-300` — add `(mut …)`,
  `(var …)`, `(assign …)` productions.
- Modify: `crates/ailang-surface/src/parse.rs:1183-1254, 1525-1566` —
  dispatcher + two new production helpers `parse_mut` / `parse_assign`,
  plus the EBNF prologue at `parse.rs:7-86`.
- Modify: `crates/ailang-surface/src/print.rs:401-548` — two new arms
  in `write_term` (exhaustive match).
- Modify: `crates/ailang-check/src/lib.rs:242, 2572, 3403, 3430` —
  four exhaustive-match sites; line 242 (`substitute_rigids_in_term`),
  line 2572 (`check_term` dispatch — STUBBED to return
  `CheckError::Internal`), line 3403 (deep walker), line 3430
  (variant-name string emitter).
- Modify: `crates/ailang-check/src/lift.rs:373, 731` — two walker arms.
- Modify: `crates/ailang-check/src/linearity.rs:555, 778, 900` —
  three walker arms.
- Modify: `crates/ailang-check/src/mono.rs:1220, 1576` — two walker
  arms.
- Modify: `crates/ailang-check/src/pre_desugar_validation.rs:119` —
  one walker arm.
- Modify: `crates/ailang-check/src/reuse_shape.rs:248` — one walker
  arm.
- Modify: `crates/ailang-check/src/uniqueness.rs:329` — one walker
  arm.
- Modify: `crates/ailang-codegen/src/escape.rs:187, 358, 469` —
  three walker arms.
- Modify: `crates/ailang-codegen/src/lambda.rs:428` — one walker arm.
- Modify: `crates/ailang-codegen/src/lib.rs:1649, 2909` — line 1649
  (`lower_term` dispatch — STUBBED to return `CodegenError::Internal`),
  line 2909 (drop emitter — substantive).
- Modify: `crates/ailang-prose/src/lib.rs:885, 1079, 1187` — three
  walker arms.
- Modify: `crates/ail/src/main.rs:1510, 2703` — two walker arms.
- Modify: `crates/ailang-core/tests/design_schema_drift.rs:43-166` —
  two new exemplars + two new match arms.
- Modify: `crates/ailang-core/tests/schema_coverage.rs:28-232` —
  two new `VariantTag` entries + `EXPECTED_VARIANTS` extension + two
  new `visit_term` arms.
- Modify: `crates/ailang-core/tests/spec_drift.rs:90-145` — two new
  exemplars + two new match arms.
- Modify: `docs/DESIGN.md:2318-2368` — append jsonc schemas for
  `Term::Mut` and `Term::Assign` after the `reuse-as` block (line 2364);
  add the prose paragraph naming the iteration that introduces them.
- Test: `crates/ailang-core/src/ast.rs` unit tests (new) — canonical-
  bytes pin for empty-`vars` `Term::Mut` serialisation.
- Test: `crates/ailang-surface/src/parse.rs` unit tests (new) —
  positive parses for `(mut)` + `(assign)` shapes; negative parse for
  `(mut)` with no body.

---

## Task 1 — AST extension: `Term::Mut`, `Term::Assign`, `MutVar`

**Files:**
- Modify: `crates/ailang-core/src/ast.rs:386-525`

**Files: write a failing test first.** Pin canonical-bytes for the
empty-`vars` case (the spec's deliberate non-omission).

- [ ] **Step 1: Add the canonical-bytes pin to `ast.rs`**

In `#[cfg(test)] mod tests` near the end of `crates/ailang-core/src/ast.rs`,
add:

```rust
#[test]
fn term_mut_empty_vars_serialises_with_explicit_vars_field() {
    // Spec 2026-05-15-mut-local §"Canonical-form invariants":
    // the `vars` array stays present rather than being omitted.
    let t = Term::Mut {
        vars: Vec::new(),
        body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
    };
    let bytes = serde_json::to_string(&t).expect("serialise");
    assert_eq!(
        bytes,
        r#"{"t":"mut","vars":[],"body":{"t":"lit","lit":{"kind":"int","value":0}}}"#,
    );
}

#[test]
fn term_assign_round_trips_through_json() {
    let t = Term::Assign {
        name: "x".into(),
        value: Box::new(Term::Lit { lit: Literal::Int { value: 42 } }),
    };
    let bytes = serde_json::to_string(&t).expect("serialise");
    assert_eq!(
        bytes,
        r#"{"t":"assign","name":"x","value":{"t":"lit","lit":{"kind":"int","value":42}}}"#,
    );
    let back: Term = serde_json::from_str(&bytes).expect("deserialise");
    match back {
        Term::Assign { name, value } => {
            assert_eq!(name, "x");
            match *value {
                Term::Lit { lit: Literal::Int { value: 42 } } => {}
                _ => panic!("inner literal mismatch"),
            }
        }
        _ => panic!("variant mismatch"),
    }
}
```

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

Run: `cargo test --workspace -p ailang-core term_mut_empty_vars_serialises_with_explicit_vars_field`
Expected: compile FAIL with `error[E0599]: no variant or associated item named Mut found for enum Term`.

- [ ] **Step 3: Add the two variants and the `MutVar` struct to `ast.rs`**

Locate the `Term` enum at `crates/ailang-core/src/ast.rs:386-513`.
After the existing `Term::ReuseAs` variant (currently ending around
line 512), and before the closing `}` of the enum, insert:

```rust
    /// Iter mut.1: local mutable-state block. `vars` declares zero or
    /// more lexically-scoped mutable bindings (initialised in order);
    /// `body` is a single Term evaluated in scope of all vars. The
    /// block's static type is `body`'s static type. `vars` stays
    /// present in canonical JSON even when empty.
    Mut {
        vars: Vec<MutVar>,
        body: Box<Term>,
    },

    /// Iter mut.1: in-block update of a mut-var. Only legal as a
    /// sub-term of a `Term::Mut` whose `vars` includes a var with the
    /// same `name`. Static type is Unit.
    Assign {
        name: String,
        value: Box<Term>,
    },
```

Then, adjacent to the existing `pub struct Arm { ... }` (around line
515-525), insert:

```rust
/// Iter mut.1: a mutable binding inside `Term::Mut`. Lives as a
/// nested field of `Term::Mut`, not as a standalone Term variant —
/// mut-vars are not first-class values.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MutVar {
    pub name: String,
    #[serde(rename = "type")]
    pub ty: Type,
    pub init: Term,
}
```

The `#[serde(rename = "type")]` matches the spec's JSON-AST schema
(`{ "name": "<id>", "type": Type, "init": Term }`). The struct's
derives match `Arm`'s for cross-tree consistency.

- [ ] **Step 4: Run to verify green for the canonical-bytes test, while red for downstream walker sites**

Run: `cargo test --workspace -p ailang-core term_mut_empty_vars_serialises_with_explicit_vars_field`
Expected: PASS (this test does not touch downstream walkers).

Run: `cargo build --workspace 2>&1 | head -40`
Expected: many `error[E0004]: non-exhaustive patterns: &Term::Mut { .. } not covered` errors across `ailang-core::desugar`, `ailang-check`, `ailang-codegen`, `ailang-surface`, `ailang-prose`, `ail`. These are the walker sites Task 2 and Task 3 fix.

---

## Task 2 — Substantive walker arms in pre-typecheck + helper crates

Adds `Term::Mut` and `Term::Assign` arms to every `Term` exhaustive
match outside the two dispatch entry points. Each arm structurally
recurses; no behaviour changes for existing Term variants.

The shape per arm depends on whether the walker rebuilds a `Term`
tree (`substitute_*` family — returns a new Term) or only visits
(linearity / coverage walkers — returns `()`).

**Rebuilder shape** (for sites that map `Term → Term`):

```rust
Term::Mut { vars, body } => Term::Mut {
    vars: vars.iter().map(|v| MutVar {
        name: v.name.clone(),
        ty: walk_ty(&v.ty, /* whatever context */),
        init: walk(&v.init, /* whatever context */),
    }).collect(),
    body: Box::new(walk(body, /* whatever context */)),
},
Term::Assign { name, value } => Term::Assign {
    name: name.clone(),
    value: Box::new(walk(value, /* whatever context */)),
},
```

**Visitor shape** (for sites that walk without rebuilding):

```rust
Term::Mut { vars, body } => {
    for v in vars {
        visit_ty(&v.ty);
        visit(&v.init);
    }
    visit(body);
}
Term::Assign { value, .. } => {
    visit(value);
}
```

Some sites visit types separately; others don't. The implementer
matches the existing arm conventions at each site (e.g. if the
existing `Term::ReuseAs` arm at the site only visits `source` and
`body` without touching types, the new `Term::Mut` arm visits
`vars[i].init` and `body` without `visit_ty` — same convention).

**Files:**
- Modify: `crates/ailang-core/src/desugar.rs` — nine walker arms.
- Modify: `crates/ailang-core/src/workspace.rs` — two walker arms
  (one of them visits embedded types; see the `walk_term_embedded_types`
  call at line 1245 — that one must visit `vars[i].ty`).
- Modify: `crates/ailang-check/src/lib.rs` lines 242, 3403, 3430 —
  three of the four sites in this file (line 2572 is the dispatch
  stub handled in Task 3).
- Modify: `crates/ailang-check/src/lift.rs` lines 373, 731.
- Modify: `crates/ailang-check/src/linearity.rs` lines 555, 778, 900.
- Modify: `crates/ailang-check/src/mono.rs` lines 1220, 1576.
- Modify: `crates/ailang-check/src/pre_desugar_validation.rs` line 119.
- Modify: `crates/ailang-check/src/reuse_shape.rs` line 248.
- Modify: `crates/ailang-check/src/uniqueness.rs` line 329.
- Modify: `crates/ailang-codegen/src/escape.rs` lines 187, 358, 469.
- Modify: `crates/ailang-codegen/src/lambda.rs` line 428.
- Modify: `crates/ailang-codegen/src/lib.rs` line 2909 (the second
  site in this file; line 1649 is the codegen dispatch stub in
  Task 3).
- Modify: `crates/ailang-prose/src/lib.rs` lines 885, 1079, 1187.
- Modify: `crates/ail/src/main.rs` lines 1510, 2703.

**Site-by-site disposition:**

- [ ] **Step 1: `desugar.rs:340` — `collect_used_in_term`**

Visitor-shape arm. Recurse into `vars[i].init` and `body` for
`Term::Mut`; recurse into `value` for `Term::Assign`. No types visited
(this walker tracks Var usages only).

- [ ] **Step 2: `desugar.rs:531` — `subst_var`**

Rebuilder-shape arm. The walker substitutes `name → replacement`
inside a Term. For `Term::Mut`: substitute inside each var's `init`
and the `body`; var *declarations* are not Var references and do not
get rewritten. For `Term::Assign`: substitute inside `value`. The
`name` field of `Term::Assign` is a Var-declaration reference (not
a Var-use); the spec treats var declarations as fixed by lexical
position, so this walker does NOT rename them.

- [ ] **Step 3: `desugar.rs:1154` — free-vars walker**

Visitor-shape returning a `BTreeSet<String>`. For `Term::Mut`: union
the free vars of each `vars[i].init` plus the body's free vars,
then **subtract the var names** (they are bound). For `Term::Assign`:
the value's free vars plus the assigned `name` itself (the var being
assigned is a *use* of the binding, so it counts as a free var
unless bound by an enclosing `Term::Mut`).

- [ ] **Step 4: `desugar.rs:1298` — `subst_call_with_extras`**

Rebuilder-shape. Same as Step 2 but inside a more involved context
(extra args). Match the `Term::ReuseAs` arm's structure at this site
and apply to `Term::Mut`/`Term::Assign`.

- [ ] **Step 5: `desugar.rs:1419` — analogous rewriter**

Rebuilder-shape. Per the recon, this is the analogous companion of
Step 4. Same pattern.

- [ ] **Step 6: `desugar.rs:1477` — `find_non_callee_use`**

Visitor-shape. Detects whether a name appears in non-callee position.
For `Term::Mut`: walk children. For `Term::Assign`: walk `value`; the
assigned `name` itself is a non-callee use (mut-vars cannot be in
callee position because their type is restricted to scalars in
mut.2 — but this site cannot rely on mut.2; for now, treat `name`
as a use to keep the conservative-safe semantics).

- [ ] **Step 7: `desugar.rs:1519` and `desugar.rs:1548` — `any_nested_ctor` and `any_let_rec`**

Visitor-shape returning `bool`. For `Term::Mut`: any var init or the
body. For `Term::Assign`: the value.

- [ ] **Step 8: `desugar.rs:2660` — `any_lit_pattern`**

Visitor-shape returning `bool`. Same pattern as Step 7.

- [ ] **Step 9: `workspace.rs:1245` — `walk_term_embedded_types`**

Visitor-shape that visits every `Type` embedded in a Term tree.
For `Term::Mut`: visit each `vars[i].ty` plus recurse into init and
body. For `Term::Assign`: recurse into `value` (no embedded types).

- [ ] **Step 10: `workspace.rs:1367` — generic Term walker**

Visitor-shape per the existing arms at this site.

- [ ] **Step 11: `ailang-check/src/lib.rs:242` — `substitute_rigids_in_term`**

Rebuilder-shape. For `Term::Mut`: rebuild with each `vars[i].ty`
substituted via `substitute_rigids_in_type`, each `init` via
`substitute_rigids_in_term`, and `body` rebuilt. For `Term::Assign`:
rebuild with `value` recursed.

- [ ] **Step 12: `ailang-check/src/lib.rs:3403` — deep walker**

Match the existing arm convention at this site. Inspect
`substitute_rigids_in_term` (Step 11) for the canonical rebuilder
shape if rebuilding; if visiting-only, use the visitor shape.

- [ ] **Step 13: `ailang-check/src/lib.rs:3430` — variant-name string emitter**

The existing `Term::ReuseAs { .. } => "reuse-as"` arm at line 3430
indicates a `Term → &'static str` walker. Add:

```rust
Term::Mut { .. } => "mut",
Term::Assign { .. } => "assign",
```

- [ ] **Step 14: `ailang-check/src/lift.rs:373` — letrec lift walker (rebuilder)**

Rebuilder-shape. Same pattern as Step 11.

- [ ] **Step 15: `ailang-check/src/lift.rs:731` — `term_has_letrec`**

Visitor-shape returning `bool`. For `Term::Mut`: any `vars[i].init`
or body. For `Term::Assign`: the value.

- [ ] **Step 16: `ailang-check/src/linearity.rs:555` — linearity walker**

Visitor-shape per the existing arms at this site. Mut-vars are not
linear values (they're alloca slots, not RC-managed) but the walker
should still recurse into `init` / `body` / `value` to track any
linearity-relevant operations inside them.

- [ ] **Step 17: `ailang-check/src/linearity.rs:778` — analogous**

Same pattern as Step 16.

- [ ] **Step 18: `ailang-check/src/linearity.rs:900` — analogous**

Same pattern as Step 16.

- [ ] **Step 19: `ailang-check/src/mono.rs:1220` — monomorphiser walker**

Rebuilder-shape. Substitute types in `vars[i].ty` using the existing
mono substitution; recurse into init and body. For Term::Assign:
recurse into value.

- [ ] **Step 20: `ailang-check/src/mono.rs:1576` — second mono walker**

Same as Step 19.

- [ ] **Step 21: `ailang-check/src/pre_desugar_validation.rs:119`**

Visitor-shape. Whatever invariant the existing arms validate, apply
to the new variants by recursing into children. Mut-vars do not
participate in pre-desugar validation (they don't exist yet in
nested-ctor / letrec patterns); a simple recurse-children arm
suffices.

- [ ] **Step 22: `ailang-check/src/reuse_shape.rs:248`**

Visitor-shape. The existing `Term::ReuseAs` arm at this site is the
load-bearing case for reuse-shape inference; `Term::Mut`/`Term::Assign`
do not participate (no allocating-ctor inside mut-vars in mut.1
because var types are scalar). Arm: recurse into children for
defensive depth, but emit no reuse-shape information.

- [ ] **Step 23: `ailang-check/src/uniqueness.rs:329`**

Visitor-shape. Mut-vars are not RC-managed values in mut.1; the
uniqueness analyser treats them as not-RC-tracked. Arm: recurse into
init/body/value children.

- [ ] **Step 24: `ailang-codegen/src/escape.rs:187` — escape walker A**

Visitor-shape. Mut-vars are alloca-resident by spec (§"Codegen") and
do not introduce escape edges. Arm: recurse into init/body/value
children. The walker should NOT mark any allocation site inside the
mut-var tree as escaping just because it lives inside a mut block —
the recursion preserves whatever escape decision the inner Terms
make on their own.

- [ ] **Step 25: `ailang-codegen/src/escape.rs:358` — escape walker B**

Same as Step 24.

- [ ] **Step 26: `ailang-codegen/src/escape.rs:469` — escape walker C**

Same as Step 24.

- [ ] **Step 27: `ailang-codegen/src/lambda.rs:428`**

Visitor-shape. Mut-vars cannot be captured by lambdas in mut.1
(mut.2 enforces this via the typecheck pass; for now the codegen
arm just recurses children defensively).

- [ ] **Step 28: `ailang-codegen/src/lib.rs:2909` — drop emitter**

Visitor-shape per the existing arms at this site (the drop emitter
walks Term values to discover drop points for RC types). Mut-vars
are scalar in mut.1, so no drops are emitted for them; the arm
recurses into children.

- [ ] **Step 29: `ailang-prose/src/lib.rs:885` — prose walker A**

Rebuilder or visitor shape per the existing arms. Prose is a pretty-
printer; the new arms should print a minimal but valid prose form
or, if the prose surface is not yet ready for mut/assign, emit a
placeholder text (e.g. `"<mut-block>"`). Prose-projection coverage
is not a gate for mut.1; minimal-correctness is enough.

- [ ] **Step 30: `ailang-prose/src/lib.rs:1079` — prose walker B**

Same as Step 29.

- [ ] **Step 31: `ailang-prose/src/lib.rs:1187` — prose walker C**

Same as Step 29.

- [ ] **Step 32: `ail/src/main.rs:1510` — CLI describe / prose-merge A**

Whatever the existing `Term::ReuseAs` arm does at this site, apply
analogously. Likely a visitor for `ail describe` or similar; recurse
children.

- [ ] **Step 33: `ail/src/main.rs:2703` — CLI describe / prose-merge B**

Same as Step 32.

- [ ] **Step 34: Verify build green after Tasks 2's body**

Note: this verification is the gate for Task 2's completion **after**
Task 3 lands. The compiler still complains about the two dispatch
stubs (Task 3) — proceed to Task 3 before re-running.

---

## Task 3 — Dispatch-entry stubs in `ailang-check` and `ailang-codegen`

Two sites stub `Term::Mut` and `Term::Assign` with an internal-error
return per the spec §"Iteration mut.1" out-of-iteration boundary.
After this task plus Task 2, the workspace builds green.

**Files:**
- Modify: `crates/ailang-check/src/lib.rs:2572` — `check_term` dispatch.
- Modify: `crates/ailang-codegen/src/lib.rs:1649` — `lower_term` dispatch.

- [ ] **Step 1: `check_term` dispatch stub at `lib.rs:2572`**

The `Term::ReuseAs` arm at this site is the canonical typechecker
treatment. Add immediately after it:

```rust
Term::Mut { .. } => {
    return Err(CheckError::Internal {
        message: "Term::Mut not yet supported in typecheck (deferred to iter mut.2)".into(),
    });
}
Term::Assign { .. } => {
    return Err(CheckError::Internal {
        message: "Term::Assign not yet supported in typecheck (deferred to iter mut.2)".into(),
    });
}
```

(The exact `CheckError::Internal` field name may be `msg` or
`message` — verify against the existing variant declaration around
line 620 and match it.)

- [ ] **Step 2: `lower_term` dispatch stub at `codegen/src/lib.rs:1649`**

The `Term::ReuseAs` arm at this site is the canonical codegen
treatment. Add immediately after it:

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

(Verify the exact `CodegenError::Internal` shape — single-field
tuple variant `Internal(String)` vs. struct variant — and match.)

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

Run: `cargo build --workspace 2>&1 | tail -20`
Expected: no errors. (Warnings about unused fields in the new arms
are acceptable and will retire when typecheck / codegen land in
mut.2 / mut.3.)

- [ ] **Step 4: Verify the AST tests still pass**

Run: `cargo test --workspace -p ailang-core ast::tests`
Expected: PASS — including the two canonical-bytes tests from
Task 1.

---

## Task 4 — Form A surface: parser + printer

**Files:**
- Modify: `crates/ailang-surface/src/parse.rs:7-86, 1183-1254, 1525-1566`
- Modify: `crates/ailang-surface/src/print.rs:401-548`

- [ ] **Step 1: Write the failing parser test for the empty-mut form**

Add to the `#[cfg(test)] mod tests` block at the end of
`crates/ailang-surface/src/parse.rs`:

```rust
#[test]
fn parses_empty_mut_with_int_body() {
    let src = "(mut 0)";
    let t: Term = parse_term(src).expect("parse");
    match t {
        Term::Mut { vars, body } => {
            assert!(vars.is_empty(), "vars should be empty");
            match *body {
                Term::Lit { lit: Literal::Int { value: 0 } } => {}
                other => panic!("expected Lit Int 0, got {:?}", other),
            }
        }
        other => panic!("expected Term::Mut, got {:?}", other),
    }
}

#[test]
fn parses_mut_with_one_var_and_one_assign_and_final() {
    let src = "(mut (var x (con Int) 0) (assign x (app + x 1)) x)";
    let t: Term = parse_term(src).expect("parse");
    match t {
        Term::Mut { vars, body } => {
            assert_eq!(vars.len(), 1);
            assert_eq!(vars[0].name, "x");
            // Body should be a Seq of (assign x ...) and x.
            match *body {
                Term::Seq { lhs, rhs } => {
                    match *lhs {
                        Term::Assign { name, .. } => assert_eq!(name, "x"),
                        other => panic!("expected Assign, got {:?}", other),
                    }
                    match *rhs {
                        Term::Var { name } => assert_eq!(name, "x"),
                        other => panic!("expected Var x, got {:?}", other),
                    }
                }
                other => panic!("expected Seq, got {:?}", other),
            }
        }
        other => panic!("expected Term::Mut, got {:?}", other),
    }
}

#[test]
fn rejects_mut_with_no_body() {
    let src = "(mut)";
    let err = parse_term(src).expect_err("must reject empty mut");
    let msg = format!("{}", err);
    assert!(msg.contains("mut"), "error mentions mut: {msg}");
    assert!(msg.contains("body") || msg.contains("expression"),
            "error mentions missing body: {msg}");
}

#[test]
fn rejects_mut_with_only_vars_no_final_expression() {
    let src = "(mut (var x (con Int) 0))";
    let err = parse_term(src).expect_err("must reject vars-only mut");
    let msg = format!("{}", err);
    assert!(msg.contains("body") || msg.contains("expression"),
            "error mentions missing body: {msg}");
}
```

The helper `parse_term` for tests should mirror the convention used
by the existing `Term::Clone` / `Term::ReuseAs` test set in the same
file (see lines 1769-1908). If `parse_term` does not exist with that
exact name, wrap the existing test-entry function (`parse_module` →
extract single term) the same way prior tests do.

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

Run: `cargo test --workspace -p ailang-surface parses_empty_mut_with_int_body`
Expected: FAIL — parser does not recognise `mut` head.

- [ ] **Step 3: Add `parse_mut` and `parse_assign` helpers**

Locate the term-head dispatcher around `crates/ailang-surface/src/parse.rs:1194-1221`.
After the `"reuse-as"` arm, add:

```rust
"mut" => self.parse_mut(span)?,
"assign" => self.parse_assign(span)?,
```

Adjacent to the existing `parse_reuse_as` helper around line 1531,
add the two new helpers:

```rust
fn parse_mut(&mut self, head_span: Span) -> Result<Term, ParseError> {
    // (mut (var NAME TYPE INIT)* BODY_STMT* FINAL_EXPR)
    //
    // Reads zero or more (var ...) entries off the front, then 1+
    // trailing terms; the trailing terms are right-folded into
    // Term::Seq with the final term as the seed.

    let mut vars: Vec<MutVar> = Vec::new();
    let mut body_stmts: Vec<Term> = Vec::new();

    while self.peek_is_open_paren_with_head("var") {
        let var_span = self.expect_open_paren()?;
        self.expect_head("var")?;
        let name = self.parse_ident()?;
        let ty = self.parse_type()?;
        let init = self.parse_term()?;
        self.expect_close_paren(var_span)?;
        vars.push(MutVar { name, ty, init });
    }

    while !self.peek_is_close_paren() {
        body_stmts.push(self.parse_term()?);
    }

    if body_stmts.is_empty() {
        return Err(ParseError::at(
            head_span,
            "(mut ...) requires at least one body expression after vars",
        ));
    }

    // Right-fold: [s1, s2, ..., sN, final] becomes Seq(s1, Seq(s2, ... Seq(sN, final)))
    let mut body = body_stmts.pop().expect("non-empty after the check above");
    while let Some(s) = body_stmts.pop() {
        body = Term::Seq { lhs: Box::new(s), rhs: Box::new(body) };
    }

    Ok(Term::Mut {
        vars,
        body: Box::new(body),
    })
}

fn parse_assign(&mut self, _head_span: Span) -> Result<Term, ParseError> {
    // (assign NAME VALUE)
    let name = self.parse_ident()?;
    let value = self.parse_term()?;
    Ok(Term::Assign {
        name,
        value: Box::new(value),
    })
}
```

The exact names of the helper methods on `self` (`peek_is_open_paren_with_head`,
`expect_open_paren`, `expect_head`, `parse_ident`, `parse_type`,
`parse_term`, `peek_is_close_paren`, `expect_close_paren`,
`ParseError::at`) must match the existing parser's API. Inspect
`parse_reuse_as` (around line 1531) and `parse_seq` (around line 1386)
to read off the canonical names; substitute as needed.

- [ ] **Step 4: Run to verify parser tests pass**

Run: `cargo test --workspace -p ailang-surface parses_empty_mut_with_int_body parses_mut_with_one_var_and_one_assign_and_final rejects_mut_with_no_body rejects_mut_with_only_vars_no_final_expression`
Expected: ALL PASS.

- [ ] **Step 5: Add print arms to `print.rs`**

Locate `write_term` around `crates/ailang-surface/src/print.rs:401`.
The exhaustive match has a `Term::ReuseAs` arm around line 540-548
matching the source structure. After it, add:

```rust
Term::Mut { vars, body } => {
    self.write_str("(mut")?;
    for v in vars {
        self.write_str(" (var ")?;
        self.write_str(&v.name)?;
        self.write_str(" ")?;
        self.write_type(&v.ty)?;
        self.write_str(" ")?;
        self.write_term(&v.init)?;
        self.write_str(")")?;
    }
    // Body is a Term — may be Seq-shaped or a single expression.
    // Walk the right-spine of Seq and emit each lhs as a top-level
    // statement, then the final rhs as the trailing expression.
    let mut cursor: &Term = body;
    loop {
        match cursor {
            Term::Seq { lhs, rhs } => {
                self.write_str(" ")?;
                self.write_term(lhs)?;
                cursor = rhs;
            }
            other => {
                self.write_str(" ")?;
                self.write_term(other)?;
                break;
            }
        }
    }
    self.write_str(")")?;
}
Term::Assign { name, value } => {
    self.write_str("(assign ")?;
    self.write_str(name)?;
    self.write_str(" ")?;
    self.write_term(value)?;
    self.write_str(")")?;
}
```

- [ ] **Step 6: Update the EBNF prologue at `parse.rs:7-86`**

The parser file has a Form A EBNF block as a doc-comment header.
Locate the `term` alternation around line 39-68 and add three new
alternations:

```
        | "(" "mut" (var-decl)* term+ ")"
        | "(" "assign" ident term ")"

  var-decl ::= "(" "var" ident type term ")"
```

Place these adjacent to the existing `Term::ReuseAs` line in the
alternation.

---

## Task 5 — Drift + coverage test extensions

The schema-drift, schema-coverage, and spec-drift tests are designed
to RED when a `Term` variant is added without anchoring it in the
relevant artefacts. After Tasks 1-4, the build is green but these
three tests are red. Task 5 makes them green.

**Files:**
- Modify: `crates/ailang-core/tests/design_schema_drift.rs:43-166`
- Modify: `crates/ailang-core/tests/schema_coverage.rs:28-232`
- Modify: `crates/ailang-core/tests/spec_drift.rs:90-145`
- Modify: `crates/ailang-core/specs/form_a.md:253-300`
- Modify: `docs/DESIGN.md:2318-2368`

- [ ] **Step 1: Extend `design_schema_drift.rs`**

In the `exemplars: Vec<(&str, Term)>` constructor at lines 44-140,
after the entry for `"(reuse-as"`, append:

```rust
(
    r#""t": "mut""#,
    Term::Mut {
        vars: Vec::new(),
        body: Box::new(Term::Lit { lit: Literal::Unit }),
    },
),
(
    r#""t": "assign""#,
    Term::Assign {
        name: "x".into(),
        value: Box::new(Term::Lit { lit: Literal::Unit }),
    },
),
```

In the exhaustive match at lines 145-159 (the `Term::X => "x"`
variant-name mapping), after the `Term::ReuseAs { .. } => "reuse-as"`
arm, append:

```rust
Term::Mut { .. } => "mut",
Term::Assign { .. } => "assign",
```

- [ ] **Step 2: Extend `schema_coverage.rs`**

In the `VariantTag` enum at lines 28-70, after `TermReuseAs`, add:

```rust
TermMut,
TermAssign,
```

In `EXPECTED_VARIANTS` at lines 76-111, append:

```rust
VariantTag::TermMut,
VariantTag::TermAssign,
```

In `visit_term` at lines 156-232, after the `Term::ReuseAs` arm
(around line 226), append:

```rust
Term::Mut { vars, body } => {
    observed.insert(VariantTag::TermMut);
    for v in vars {
        visit_type(&v.ty, observed);
        visit_term(&v.init, observed);
    }
    visit_term(body, observed);
}
Term::Assign { value, .. } => {
    observed.insert(VariantTag::TermAssign);
    visit_term(value, observed);
}
```

- [ ] **Step 3: Extend `spec_drift.rs`**

In the exemplar list around `tests/spec_drift.rs:90-117`, after the
`"(reuse-as"` entry, append `"(mut"` and `"(assign"` entries with
canonical exemplars matching the Step 1 shape.

In the exhaustive match at lines 124-138, after `Term::ReuseAs { .. } => "reuse-as"`,
append:

```rust
Term::Mut { .. } => "mut",
Term::Assign { .. } => "assign",
```

- [ ] **Step 4: Extend `form_a.md` (in `crates/ailang-core/specs/`)**

Around lines 281-283 in `crates/ailang-core/specs/form_a.md`, after
the entries for `seq` / `clone` / `reuse-as`, add three lines:

```
(mut (var NAME TYPE INIT)* BODY)
(var NAME TYPE INIT)               ;; legal only inside (mut ...)
(assign NAME VALUE)                ;; legal only inside (mut ...)
```

After the existing per-term notes around lines 298-300, append a
short paragraph:

```
The body of `(mut ...)` is a flat sequence of zero or more Unit-
typed statements followed by exactly one final expression of any
type. The parser desugars this sequence into a right-folded
`Term::Seq` chain inside `Term::Mut.body`; the canonical JSON-AST
always sees a single `body: Term`. A `(mut ...)` form with neither
vars nor body is rejected at parse; `(var ...)` and `(assign ...)`
outside a `(mut ...)` are rejected at parse for `var` and at
typecheck for `assign` (mut.2). See spec
`docs/specs/0029-mut-local.md`.
```

- [ ] **Step 5: Extend DESIGN.md §"Term (expression)"**

Locate the existing jsonc schema block in
`docs/DESIGN.md:2318-2368`. After the `reuse-as` block (around
line 2364), append:

```jsonc
// Iter mut.1: local mutable-state block. `vars` declares zero or
// more lexically-scoped mutable bindings (initialised in order);
// `body` is a single Term in scope of all vars. Block static type
// is body's type. `vars` stays present even when empty
// (hash-stable when omitted is NOT applied here — the field is
// always serialised).
{ "t": "mut",
  "vars": [ { "name": "<id>", "type": Type, "init": Term }, ... ],
  "body": Term }

// Iter mut.1: in-block update of a mut-var. Legal only as a
// sub-term of a `Term::Mut` whose `vars` includes a var with the
// same `name`. Static type Unit. See
// `docs/specs/0029-mut-local.md`.
{ "t": "assign",
  "name": "<id>",
  "value": Term }
```

The order matters: the `assign` block comes immediately after the
`mut` block to mirror the order in the enum declaration and the
exemplar list in `design_schema_drift.rs`.

- [ ] **Step 6: Run the three drift tests**

Run: `cargo test --workspace -p ailang-core schema_drift`
Expected: PASS — the `design_md_anchors_every_term_variant` and the
`data_model_section_is_bounded` tests both green.

Run: `cargo test --workspace -p ailang-core schema_coverage`
Expected: still RED on `every_ast_variant_is_observed_in_the_fixture_corpus`
because `examples/mut.ail` does not yet exist. Continue to Task 6
to ship it.

Run: `cargo test --workspace -p ailang-core spec_drift`
Expected: PASS.

---

## Task 6 — `examples/mut.ail` fixture + round-trip test

The round-trip test in `crates/ailang-surface/tests/round_trip.rs`
auto-globs every `examples/*.ail` fixture; once the fixture exists,
it gets picked up automatically. Same for `schema_coverage.rs`'s
fixture-corpus scan.

**Files:**
- Create: `examples/mut.ail`

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

Create `examples/mut.ail` with the following content:

```
(module mut

  (fn mut_empty
    (doc "Iter mut.1 — empty mut block; body is a single Int literal.")
    (type (fn-type (params) (ret (con Int))))
    (params)
    (body (mut 0)))

  (fn mut_single_var
    (doc "Iter mut.1 — one var, one assign, final expression reads the var.")
    (type (fn-type (params) (ret (con Int))))
    (params)
    (body
      (mut
        (var x (con Int) 0)
        (assign x (app + x 1))
        x)))

  (fn mut_two_vars
    (doc "Iter mut.1 — two vars, two assigns, final expression combines them.")
    (type (fn-type (params) (ret (con Float))))
    (params)
    (body
      (mut
        (var sum (con Float) 0.0)
        (var count (con Int) 0)
        (assign sum (app + sum 1.0))
        (assign count (app + count 1))
        (app + sum (app int_to_float count)))))

  (fn mut_nested_shadow
    (doc "Iter mut.1 — outer var shadowed by inner mut block's var of the same name.")
    (type (fn-type (params) (ret (con Int))))
    (params)
    (body
      (mut
        (var x (con Int) 10)
        (assign x (app + x 1))
        (mut
          (var x (con Int) 100)
          (assign x (app + x 1))
          x))))

  (fn mut_returns_bool
    (doc "Iter mut.1 — exercise Bool as a supported scalar var type.")
    (type (fn-type (params) (ret (con Bool))))
    (params)
    (body
      (mut
        (var flag (con Bool) false)
        (assign flag true)
        flag)))

  (fn mut_returns_unit
    (doc "Iter mut.1 — exercise Unit as the supported scalar zero case.")
    (type (fn-type (params) (ret (con Unit))))
    (params)
    (body
      (mut
        (var u (con Unit) (lit-unit))
        (assign u (lit-unit))
        u))))
```

These six fns cover: empty body, single-var, two-var, nested shadow,
Bool, Unit. The four supported scalar types (Int, Float, Bool, Unit)
are each represented. The fixture does NOT cover Str (deferred to a
future milestone per spec §"Out of scope").

- [ ] **Step 2: Run round-trip**

Run: `cargo test --workspace -p ailang-surface round_trip`
Expected: PASS — both `parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`
and `parse_is_deterministic_over_every_ail_fixture` green, including
the newly-added `mut.ail` fixture.

- [ ] **Step 3: Run schema-coverage**

Run: `cargo test --workspace -p ailang-core schema_coverage`
Expected: PASS — `every_ast_variant_is_observed_in_the_fixture_corpus`
green now that `examples/mut.ail` declares the two new variants.

- [ ] **Step 4: Full workspace test run**

Run: `cargo test --workspace 2>&1 | tail -20`
Expected: every test green. If any test fails, inspect the failure
before continuing — the iteration's gate is full-workspace-green.

---

## Self-review checklist

- [ ] **Spec coverage:** every section of `docs/specs/0029-mut-local.md`
  §"Iteration mut.1 — Schema + surface" has a corresponding task here.
  Cross-check: AST extension (Task 1), Form A surface (Task 4),
  canonical-form invariants (Task 1 empty-vars pin + Task 5 drift
  tests), DESIGN.md amendment (Task 5 Step 5), round-trip fixture
  (Task 6). All covered.

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

- [ ] **Type-name consistency:** `Term::Mut`, `Term::Assign`, `MutVar`,
  `CheckError::Internal`, `CodegenError::Internal`, `vars`, `body`,
  `name`, `value`, `init`, `ty`. Each appears with the same spelling
  in every task that references it.

- [ ] **Step granularity:** every step is 2-5 minutes. Tasks 1-3
  Step shapes are all "add an exhaustive-match arm of one of two
  canonical shapes (rebuilder or visitor) at a named line"; each is
  bite-sized. Task 4 steps follow TDD shape with code shown. Task
  5 steps are mechanical drift-test extensions. Task 6 steps are
  fixture + test invocations.

- [ ] **No commit steps:** verified — the plan contains no
  `git commit` instructions. The implementer leaves work in the
  working tree; the Boss commits at iter end.

- [ ] **Boss decisions named:** the three recon open questions are
  resolved in this plan: (1) walker-arm policy is "substantive
  everywhere except `check_term` and `lower_term` dispatch sites
  which stub with Internal" (Tasks 2-3); (2) empty-`vars` canonical
  bytes pinned by the test in Task 1 Step 1; (3) `(mut)` with no
  body rejected at parse via the test in Task 4 Step 1.

---

## Acceptance gate for iteration mut.1

- `cargo build --workspace` green.
- `cargo test --workspace` green, including:
  - `ast::tests::term_mut_empty_vars_serialises_with_explicit_vars_field`
  - `ast::tests::term_assign_round_trips_through_json`
  - `parses_empty_mut_with_int_body`
  - `parses_mut_with_one_var_and_one_assign_and_final`
  - `rejects_mut_with_no_body`
  - `rejects_mut_with_only_vars_no_final_expression`
  - `design_md_anchors_every_term_variant`
  - `data_model_section_is_bounded`
  - `every_ast_variant_is_observed_in_the_fixture_corpus`
  - `parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`
    (including `examples/mut.ail`)
  - `parse_is_deterministic_over_every_ail_fixture`
    (including `examples/mut.ail`)
  - `spec_drift`
- `examples/mut.ail` parses, round-trips, and is observed by the
  fixture-corpus walker.
- `Term::Mut` and `Term::Assign` reaching typecheck produce
  `CheckError::Internal` with the expected message text.
- `Term::Mut` and `Term::Assign` reaching codegen produce
  `CodegenError::Internal` with the expected message text.

The iteration does NOT include typecheck recognition (deferred to
mut.2) or codegen lowering (deferred to mut.3); a `mut.ail`-based
e2e test that *runs* a mut block is therefore out of scope and is
not part of the gate.
