# iter mut.1 — schema + surface for local mutable state

**Date:** 2026-05-15
**Started from:** 60e4559e31d7f09de77e2679aab2d524de257a61
**Status:** DONE
**Tasks completed:** 6 of 6

## Summary

Lands the foundational AST + surface forms for the mut-local
milestone (first milestone on the Stateful-islands roadmap path).
Two new `Term` variants (`Mut` and `Assign`) plus the nested
`MutVar` struct enter `ailang-core::ast`; canonical-JSON serde
round-trips them by virtue of the existing `#[serde(tag = "t")]`
machinery. Form A gains `(mut …)` / `(var …)` / `(assign …)`
productions in the parser and the corresponding write arms in the
printer; the parser right-folds the trailing body sequence into
`Term::Seq` so the canonical JSON-AST always sees a single
`body: Term`. Every `Term` exhaustive match in the workspace (~25
walker sites) gains substantive arms, except the two dispatch
entry points (`synth` in `ailang-check`, `lower_term` in
`ailang-codegen`) which stub with `CheckError::Internal` /
`CodegenError::Internal` per the spec's "out of iteration"
boundary — typecheck recognition lands in mut.2, codegen lowering
in mut.3. The three drift tests (`design_schema_drift`,
`spec_drift`, `schema_coverage`) all flip green after the
exemplar / variant-tag / visitor extensions, and `examples/mut.ail`
ships as the round-trip fixture covering empty / single-var /
two-var / nested-shadow / Bool / Unit cases. Full
`cargo test --workspace` 579/579 green (was 564 + 2 new AST pins +
4 new parser pins + 9 from the extended drift exemplars and the
mut.ail-corpus contributions netting to +15).

## Per-task notes

- iter mut.1.1 — AST extension: added `Term::Mut { vars: Vec<MutVar>,
  body: Box<Term> }`, `Term::Assign { name: String, value: Box<Term> }`,
  and the `MutVar { name, ty, init }` struct to `crates/ailang-core/src/ast.rs`.
  Two new unit tests pin the canonical-bytes of the empty-vars mut
  serialisation and the Assign round-trip. `MutVar` carries Arm's
  derives (`Debug, Clone, Serialize, Deserialize`) — the plan's
  literal pseudo-code asked for `PartialEq + Eq` but its own
  justification ("derives match Arm's for cross-tree consistency")
  points at Arm, which does not derive those. Concern recorded;
  no functional impact (the Task 1 tests do not depend on
  `PartialEq`, and `Term` itself uses a custom `PartialEq` impl on
  `Type` only).

- iter mut.1.2 — substantive walker arms: 9 sites in `desugar.rs`,
  2 in `workspace.rs`, 3 in `ailang-check/src/lib.rs` (1 in
  `substitute_rigids_in_term`, 1 in `verify_tail_positions`, 1
  variant-name string emitter inside `synth`'s `Term::ReuseAs` sub-
  match), 2 in `lift.rs`, 3 in `linearity.rs` (one being a
  variant-name string emitter), 2 in `mono.rs`, 1 each in
  `pre_desugar_validation.rs`, `reuse_shape.rs`, `uniqueness.rs`,
  3 in `escape.rs`, 1 in `lambda.rs`, 1 in `codegen/src/lib.rs`
  (the `synth_with_extras` type-synthesis helper), 3 in
  `ailang-prose/src/lib.rs`, 2 in `ail/src/main.rs`. Plus one
  unenumerated site in `crates/ail/tests/codegen_import_map_fallback_pin.rs`
  (an exhaustive walker inside a test) that the build required.
  Each arm follows the appropriate shape per existing convention
  at its site: rebuilder shapes for `substitute_*` / `subst_*` /
  `desugar_term` / `lift_in_term` / `substitute_rigids_in_term` /
  `rewrite_term`, visitor shapes for `collect_used_in_term` /
  `free_vars_in_term` / `find_non_callee_use` / `walk_term*` /
  `verify_tail_positions` / `term_has_letrec` / the linearity
  walkers / `walk` in `reuse_shape.rs` / `walk` in `uniqueness.rs`
  / `walk` in `escape.rs` / `collect_captures` / `count_free_var` /
  `subst_var_with_term` (rebuilder-shape in prose). Shadowing
  semantics across mut-var bindings replicates the
  `Term::Let`/`Term::Lam`/`Term::LetRec`-shaped convention at each
  site: var-init terms see the outer scope plus already-declared
  vars; later var-inits and the body see all earlier var bindings.

- iter mut.1.3 — dispatch stubs: typecheck dispatch at `synth` in
  `ailang-check/src/lib.rs` (Plan line 2572 was wrong — that line
  is `verify_tail_positions`, which is substantive walker
  territory; the actual dispatch entry is `synth` at line 3403's
  former `Term::ReuseAs` arm, where the two new arms now sit)
  returns `CheckError::Internal("Term::Mut/Assign not yet
  supported in typecheck (deferred to iter mut.2)")`. Codegen
  dispatch at `lower_term` in `ailang-codegen/src/lib.rs:1649`
  returns `CodegenError::Internal("Term::Mut/Assign not yet
  supported in codegen (deferred to iter mut.3)")`. Both errors
  use the single-field tuple-variant shape (`Internal(String)`).

- iter mut.1.4 — Form A parser + printer: dispatcher arms added
  for `"mut" => parse_mut` and `"assign" => parse_assign` in
  `parse_term`'s head-keyword match; the two helpers parse
  positionally (mut: pull leading `(var ...)` entries, then ≥ 1
  body terms right-folded into `Term::Seq`; assign: positional
  ident-then-value pair). Plan pseudo-code used helper names
  (`peek_is_open_paren_with_head`, `expect_head`, etc.) that don't
  exist; substituted the actual API (`expect_lparen`,
  `expect_keyword`, `expect_ident`, `parse_type`, `parse_term`,
  `expect_rparen`, `peek_head_ident`, `matches!(self.peek(),
  Tok::RParen)`). Four RED-first parser tests added covering empty
  mut + 1-var-1-assign-1-final + missing-body rejection (both
  shapes). Print arms in `print.rs` walk the right-spine of
  `Term::Seq` and emit each lhs as a top-level statement; the
  terminal expression closes the `(mut …)` form. EBNF prologue in
  `parse.rs` extended with `mut-term`, `var-decl`, `assign-term`
  productions. The print arms had to land during the Task 1+2
  build-unblock phase rather than waiting for Task 4 (they are
  exhaustive-match neighbours of the synth/lower_term dispatchers);
  this is a benign sequencing shift and the plan's "Task 2 build-
  green verification gate" still holds.

- iter mut.1.5 — drift + coverage extensions: two new exemplars
  added to `design_schema_drift.rs` and two new match arms; two
  new `VariantTag` entries (`TermMut`, `TermAssign`),
  `EXPECTED_VARIANTS` extended, two new `visit_term` arms in
  `schema_coverage.rs`; two new exemplars + two new match arms in
  `spec_drift.rs`. `form_a.md` gains three new lines in the
  Parenthesised-forms block plus a prose paragraph naming the
  empty-vars and post-vars-body invariants and the diagnostic
  codes. `DESIGN.md` §"Term (expression)" gains the two jsonc
  schema blocks immediately after the `reuse-as` block plus a
  closing prose paragraph naming the mut.1 dispatch-stub semantics
  and the deferred mut.2 / mut.3 iterations. Post-edit:
  `design_schema_drift` and `spec_drift` both green;
  `schema_coverage` is RED on the missing `examples/mut.ail`
  fixture, per plan Step 6 expectation.

- iter mut.1.6 — fixture + round-trip: `examples/mut.ail` shipped
  with the six fns from plan Step 1 verbatim (empty / single-var
  with assign / two-var with combine / nested-shadow / Bool /
  Unit). The corpus-globbing round-trip test
  (`parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`)
  and the parse-determinism test pick the fixture up automatically;
  both green. `schema_coverage` also green now that both new
  variants are observed in the corpus. Full `cargo test
  --workspace` 579/579 green.

## Concerns

- `MutVar` derives diverge from the plan's literal pseudo-code
  (`Debug, Clone, PartialEq, Eq, Serialize, Deserialize`); the
  shipped form is `Debug, Clone, Serialize, Deserialize` to match
  Arm's pattern. The plan's own justification ("match Arm's
  derives for cross-tree consistency") points at Arm which does
  not derive PartialEq/Eq. Functional impact: none in mut.1
  (the Task 1 tests do not depend on PartialEq, and `Term` uses
  a custom PartialEq impl scoped to `Type` only). A future iter
  that needs to compare `MutVar` (e.g. for in-place rewriting in
  mut.3 codegen) can add the derives at that time without breaking
  any mut.1 test.

- Plan recon for the typecheck dispatch entry was misindexed.
  The plan listed `crates/ailang-check/src/lib.rs:2572` as the
  "check_term dispatch — STUBBED" site; that line is actually in
  `verify_tail_positions`, a substantive walker. The real
  typecheck dispatch entry is `synth` (defined at lib.rs:2613,
  with its `Term::ReuseAs` arm at line 3403 before the new mut
  arms). The stub was placed at the actual dispatch entry; line
  2572 received a substantive arm. The variant-name string
  emitter sub-match referenced as line 3430 is inside `synth`'s
  former `Term::ReuseAs` arm and was extended as Task 2 Step 13
  asks. Net: the spec intent (stub at typecheck dispatch) is
  preserved; only the line-number routing differs.

- One test-side walker site (`crates/ail/tests/codegen_import_map_fallback_pin.rs:62`)
  carries an exhaustive match on `Term` and was not enumerated
  by the plan's Task 2 file list. The site is the body-walker of
  a regression-pin test from iter 24.tidy; it required two new
  arms to build green. Added with appropriate visitor-shape
  recursion. The plan-recon miss is recorded here as a
  documentary item; no behavioural impact.

## Known debt

- Prose-side `(mut ...)` rendering in `ailang-prose/src/lib.rs` is
  a minimal-correctness shape (`mut { var <name> = <init>; ...; <body> }`).
  The prose surface for mut blocks is not yet fully designed; a
  future prose-iter once the LLM-author signal arrives will refine
  it. This is a deliberate placeholder, not drift — recording for
  visibility.

- `subst_var` in `desugar.rs` does NOT rename the `Term::Assign.name`
  field. The desugar pass's `subst_var` rewrites let-rec captures
  (KnownType / LetBound / MatchArm), none of which can be mut-vars
  (mut-vars are first-order scalars, not captureable). Leaving
  the assign-name untouched is semantically correct; documented in
  the arm's doc-comment. Mentioned here for visibility.

## Files touched

- `crates/ailang-core/src/ast.rs` — `Term::Mut`, `Term::Assign`, `MutVar`, two new unit tests
- `crates/ailang-core/src/desugar.rs` — 9 walker arms
- `crates/ailang-core/src/workspace.rs` — 2 walker arms
- `crates/ailang-check/src/lib.rs` — 3 walker arms (substantive + variant-name) + 2 dispatch stubs in `synth`
- `crates/ailang-check/src/lift.rs` — 2 walker arms
- `crates/ailang-check/src/linearity.rs` — 3 walker arms (2 substantive + 1 variant-name)
- `crates/ailang-check/src/mono.rs` — 2 walker arms (1 mut-ref rebuilder, 1 visitor)
- `crates/ailang-check/src/pre_desugar_validation.rs` — 1 walker arm
- `crates/ailang-check/src/reuse_shape.rs` — 1 walker arm
- `crates/ailang-check/src/uniqueness.rs` — 1 walker arm
- `crates/ailang-codegen/src/escape.rs` — 3 walker arms
- `crates/ailang-codegen/src/lambda.rs` — 1 walker arm
- `crates/ailang-codegen/src/lib.rs` — 1 walker arm (`synth_with_extras`) + 2 dispatch stubs in `lower_term`
- `crates/ailang-prose/src/lib.rs` — 3 walker arms (1 substantive, 1 `count_free_var`, 1 `subst_var_with_term` rebuilder)
- `crates/ail/src/main.rs` — 2 walker arms (dep-walker + `rewrite_term`)
- `crates/ail/tests/codegen_import_map_fallback_pin.rs` — 1 walker arm (test-side, unenumerated by plan)
- `crates/ailang-surface/src/parse.rs` — dispatcher arms, `parse_mut` / `parse_assign` helpers, EBNF prologue extension, 4 new parser tests
- `crates/ailang-surface/src/print.rs` — 2 `write_term` arms
- `crates/ailang-core/tests/design_schema_drift.rs` — 2 exemplars + 2 match arms
- `crates/ailang-core/tests/schema_coverage.rs` — 2 VariantTag + EXPECTED_VARIANTS + 2 visit_term arms
- `crates/ailang-core/tests/spec_drift.rs` — 2 exemplars + 2 match arms
- `crates/ailang-core/specs/form_a.md` — 3 new productions + prose paragraph
- `docs/DESIGN.md` — 2 jsonc schema blocks + prose paragraph
- `examples/mut.ail` — round-trip fixture (6 fns)

## Stats

bench/orchestrator-stats/2026-05-15-iter-mut.1.json
