# Remove `mut` / `var` / `assign` — Design Spec

**Date:** 2026-05-18
**Status:** Draft — awaiting user spec review
**Authors:** Brummel (orchestrator) + Claude

## Goal

Remove the local-mutable-state construct from AILang entirely. At
the AST there are exactly two nodes — `Term::Mut` (the block, which
declares the vars) and `Term::Assign` (the update); `var` is not a
first-class `Term`, only the declaration syntax inside a `mut`
block, and `Term::Assign` is statically illegal outside an enclosing
`Term::Mut` (`mut-assign-out-of-scope`). The three keywords are one
indivisible feature; the cut is atomic.

The feature-acceptance criterion, applied **inverted** (does the
*removed* feature fail the gate?):

- **Clause 1** (an LLM author reaches for it): met — and per
  `docs/DESIGN.md` that counts *against*, not for, since clause 1 is
  satisfied by exactly the imperative constructs the core refuses.
- **Clause 2** (removes redundancy / improves correctness):
  violated. Every one of the shipped `mut`-bearing fixtures is
  straight-line scalar mutation a `let`/`if` expresses; the
  iteration ones delegate to a tail-recursive helper. The construct
  *is* the redundancy. Proven executable below (`mut_counter.ail`
  before→after).
- **Clause 3** (reintroduces no bug class the core exists to
  eliminate): violated. `Term::Mut` + `Term::Assign` is the
  iterated/sequenced mutable-state reasoning `docs/DESIGN.md`
  lines 99–108 name as the canonical clause-3 failure. Scalar
  sealing bounds the blast radius, not the reasoning burden — and
  nothing in-tree needs it.

`loop` / `recur` shipped and closed as a standalone milestone
(2026-05-18) and is the surviving first-class iteration construct;
`let` / `if` cover the straight-line cases. This milestone is
explicitly **decoupled** from the open "Stateful islands" roadmap
item, which is a separate, later, independent decision and is **not**
in scope here.

## Architecture

Atomic hard-removal in one milestone, no deprecation window
(Approach A). Surface, AST, checker, codegen, DESIGN.md, fixtures
and the drift trio are all cut together so the schema is honest at
every commit.

After removal the Form-A keywords `mut` / `var` / `assign` no longer
lex; a JSON-AST carrying `{"t":"mut"}` or `{"t":"assign"}` fails
closed as a generic serde unknown-variant error. There is **no**
dedicated "construct removed" diagnostic — a tombstone diagnostic is
permanent clutter for a language whose sole author is an LLM that
simply stops emitting `mut` once `docs/DESIGN.md` no longer
documents it (no-nostalgia).

One delicate interaction governs the whole milestone: **the codegen
alloca machinery is shared with `loop`.** `loop-recur.3` lowered
loop binders by reusing the mut.3 `pending_entry_allocas` mechanism
and registering them in the existing `mut_var_allocas` map, and
`loop-recur.tidy` widened the shared `Term::Lam` escape guard to
cover both. The machinery therefore **survives** the cut (loop needs
it); only its `mut` half is removed, and the now-misnamed
`mut_var_allocas` is renamed `binder_allocas` for honesty (its
"mut-var" name would otherwise be a lie in the source).

## Concrete code shapes

### The AILang program — inverted clause-1 faithfulness evidence

The headline is the proof that removal loses **no** expressivity:
every shipped `mut` program rewrites to an observably identical
`let`/`if` program. Two real fixtures, before → after:

`examples/mut_counter.ail` (the strongest evidence — the `mut` block
computed *nothing*; the iteration was always the tail-recursive
helper):

```clojure
;; BEFORE — ships today (mut.3 fixture), prints 55
(fn main
  (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 0))
        sum))))

;; AFTER — observably identical, prints 55
(fn main
  (type (fn-type (params) (ret (con Unit)) (effects IO)))
  (params)
  (body
    (app print (app sum_helper 1 10 0))))
;; sum_helper (tail-recursive accumulator) is unchanged.
```

`examples/mut.ail` :: `mut_single_var` (the minimal scalar case):

```clojure
;; BEFORE — value is 1
(body
  (mut
    (var x (con Int) 0)
    (assign x (app + x 1))
    x))

;; AFTER — value is 1, faithful
(body
  (let x 0 (app + x 1)))
```

`mut.ail` :: `mut_nested_shadow` shows the rewrite also *exposes*
the redundancy — the outer `var`+`assign` was dead code `mut`
permitted (only the inner block is the body's value):

```clojure
;; BEFORE — value 101 (outer x=10,x+1 discarded; inner x=100,x+1 returned)
(body
  (mut (var x (con Int) 10) (assign x (app + x 1))
    (mut (var x (con Int) 100) (assign x (app + x 1)) x)))

;; AFTER — value 101, the dead outer state simply gone
(body
  (let x 100 (app + x 1)))
```

The must-fail side (the removal made executable): a module carrying
`{"t":"mut"}` / a `.ail` with the `mut` keyword must now be
rejected (serde unknown-variant / Form-A parse error). This is a new
pin fixture, the clause-3 discriminator (the wrong code fails to
typecheck, not merely advised against).

### Implementation shape (secondary — supporting, not the point)

| Site | Before | After |
|---|---|---|
| `ailang-core/src/ast.rs` (~529-638) | `Term::Mut { vars: Vec<MutVar>, body }`, `Term::Assign { name, value }`, `struct MutVar { name, ty, init }` | all three deleted; every exhaustive no-`_`-wildcard `Term` match across the 6 crates loses its `Mut`/`Assign` arms |
| `ailang-surface` (`lex`/`parse`/`print`/`loader.rs`, `form_a.md`) | `mut`/`var`/`assign` keywords, `parse_mut`/`parse_assign`, print arms | deleted; the keywords no longer lex |
| `ailang-check/src/lib.rs` | `Term::Mut`/`Term::Assign` synth arms; `mut_scope_stack` threaded through every synth caller; 4 variants `MutVarCapturedByLambda`, `UnsupportedMutVarType`[`mut-var-unsupported-type`], `MutAssignOutOfScope`[`mut-assign-out-of-scope`], `AssignTypeMismatch`[`assign-type-mismatch`] + their `code()`/`ctx()` arms; shared `Term::Lam` escape guard gate `!mut_scope_stack.is_empty() \|\| !loop_stack.is_empty()` | synth arms + `mut_scope_stack` + the 4 variants deleted; escape-guard gate simplifies to `!loop_stack.is_empty()`, the `mut_scope_stack` free-var pass removed — **the `loop_stack` half MUST survive byte-equivalent** (loop-binder-captured-by-lambda still rejected) |
| `ailang-codegen` (`lib.rs` ~1779-1855, `escape.rs` ~193-417, `lambda.rs`) | `Term::Mut`/`Term::Assign` `lower_term` arms; `mut_var_allocas`/`pending_entry_allocas` save/restore | the two `lower_term` arms + the `mut`/`assign` escape arms deleted; the alloca machinery **kept** (loop reuses it) and `mut_var_allocas` renamed `binder_allocas`, doc-comments made truthful |
| `docs/DESIGN.md` | `Term::Mut`/`Term::Assign` data-model entries (~2404-2422) + mut.1/mut.3/mut.4 prose (~2448-2458) + the symmetric lambda-capture note | the `mut` data-model + prose hard-deleted; the symmetric-rule note keeps only its `loop`-binder half; present-tense, zero nostalgia |
| drift trio + `carve_out_inventory.rs` + roadmap | mut anchors in `design_schema_drift`/`spec_drift`/`schema_coverage`; mut carve-out fixtures counted in `EXPECTED` (18 post-loop-recur.tidy); roadmap "mut-local is the foundation for Stateful islands" sub-bullet | mut drift anchors removed in lockstep with the schema deletion; `EXPECTED` decremented by the mut carve-out count (exact integer = planner's to pin); the false foundation sub-bullet removed |

## Components

1. **AST** — delete the two variants and `MutVar`; sweep every
   exhaustive `Term` match (the same ~30-site no-wildcard pattern
   `loop-recur.1` swept additively, now in reverse).
2. **Surface** — delete keyword lexing, the two parsers, the print
   arms, the loader handling, the `form_a.md` grammar lines.
3. **Checker** — delete synth arms, `mut_scope_stack` threading,
   the 4 `CheckError` variants + registry/ctx arms; simplify the
   *shared* escape guard to `loop_stack`-only, preserving the
   loop-binder rejection exactly.
4. **Codegen** — delete the two `lower_term` arms + `escape.rs`/
   `lambda.rs` `mut` handling; **keep** the alloca machinery
   (loop-carried), rename `mut_var_allocas` → `binder_allocas`.
5. **Fixtures** — rewrite value-computing fixtures to `let`/`if`
   (`examples/mut.ail` 6 fns, `mut_counter.ail`, `mut_sum_floats.ail`,
   `fieldtest/mut-local_1..4`), repoint the e2e assertions
   (`mut_counter`→55, `mut_sum_floats`→55); **delete** the
   rejection-probe fixtures whose entire purpose is the removed
   mut-specific rejection (`fieldtest/mut-local_5_lambda_capture_probe.ail`,
   `mut-local_6_diag_probe.ail`, `examples/test_mut_var_captured_by_lambda.ail.json`)
   — no `let`/`if` equivalent exists for them.
6. **DESIGN.md** — hard-delete the `mut` data-model + prose;
   present-tense; the file must read as the *current* language, with
   no trace of `mut`/`var`/`assign` and no post-mortem.
7. **Drift trio + roadmap** — remove mut anchors in lockstep with
   the schema deletion (proves removal is matched by pin removal);
   decrement `carve_out_inventory` `EXPECTED`; delete the false
   Stateful-islands "foundation" sub-bullet.

## Data flow

Author writes `.ail` with `let`/`if`/`loop`/`recur` only → surface
no longer lexes `mut`/`var`/`assign` (Form-A parse error on the
keyword) → JSON-AST has no `"t":"mut"`/`"t":"assign"` (serde
unknown-variant rejection, fail-closed) → checker/codegen never see
the nodes. Non-`mut` modules are byte-stable end-to-end: removal is
symmetric to the additive mut.1 introduction and serde is
tag-based, so no canonical-JSON hash of any surviving module moves.
`loop`/`recur` is unaffected — it shares the renamed alloca
machinery, and its lowering is byte-identical (rename is
representation-only).

## Error handling

The 4 `mut` `CheckError` variants are deleted. `{"t":"mut"}` /
`{"t":"assign"}` produce the generic serde unknown-variant error —
no dedicated diagnostic, no tombstone (fail-closed, no-nostalgia).
`loop`/`recur`'s five `Recur*` codes and `LoopBinderCapturedByLambda`
are byte-unchanged. The shared `Term::Lam` escape guard must keep
rejecting a lambda that captures a `loop` binder — this is a
non-regression pin, since the guard is the exact code being
simplified.

## Testing strategy

- **Behaviour-preservation (inverted clause-1, executable):** the
  rewritten fixtures' e2e value assertions stay green —
  `mut_counter`→`55`, `mut_sum_floats`→`55` — proving the `let`/`if`
  rewrite is faithful.
- **loop/recur non-regression:** the full `loop_recur_*` /
  `loop_sum_to*` fixture suite byte-identical (the shared
  alloca-machinery rename must not move loop codegen); the
  `loop-binder-captured-by-lambda` rejection still fires
  point-exactly (shared escape guard, loop half survives);
  `tail-app`/`tail-do` byte-frozen.
- **Schema honesty:** drift trio (`design_schema_drift`,
  `spec_drift`, `schema_coverage`) + `carve_out_inventory` green at
  the new `EXPECTED` — proving the schema-region deletion is
  matched by the drift-anchor deletion.
- **Removal made executable:** a new must-fail pin — a `.ail` with
  the `mut` keyword and a module with `{"t":"mut"}` are now rejected
  (Form-A parse error / serde unknown-variant).
- **Round-trip invariant:** all surviving non-`mut` modules
  `ail parse|render|parse` byte-identical; pre-existing hash pins
  byte-stable.
- `cargo test --workspace` green.

## Acceptance criteria

- `mut`/`var`/`assign` do not lex in Form A; `{"t":"mut"}` /
  `{"t":"assign"}` are rejected as unknown serde variants; no
  `Term::Mut`/`Term::Assign`/`MutVar` symbol remains in any crate.
- The 4 `mut` `CheckError` variants are gone; `loop`/`recur`'s
  codes and the loop-binder escape-guard behaviour are
  byte-unchanged (non-regression pinned).
- Every value-computing fixture is rewritten faithfully and still
  produces its original value (`mut_counter`/`mut_sum_floats`
  print 55); the three rejection-probe fixtures are deleted.
- `docs/DESIGN.md` is present-tense and contains zero
  `mut`/`var`/`assign` mention and zero nostalgia/aspiration; the
  drift trio + `carve_out_inventory` are green at the new
  `EXPECTED`.
- The Roundtrip Invariant holds byte-identically for all surviving
  modules; the `loop`/`recur` e2e (`55` / `500000500000` /
  infinite-compiles) stay green.
- `cargo test --workspace` green; milestone-close `audit` bench
  baseline pristine (pure removal, no hot-path touched — any
  non-pristine result is a defect, not a ratifiable delta).
