# Local mutable state — `mut` / `var` / `assign` — Design Spec **Date:** 2026-05-15 **Status:** Draft — awaiting user spec review **Authors:** Brummel (orchestrator) + Claude ## Goal Introduce a sealed, lexically-scoped mutable-state construct into AILang as the **foundational first step** on the Stateful-islands roadmap path. After this milestone: 1. The AST has three new shapes — `Term::Mut` (the block), the `MutVar` struct inside it (one mutable binding), and `Term::Assign` (an in-block update). Form A surface syntax for all three round-trips canonically. 2. Inside a `mut` block, the LLM author may declare mutable bindings with `var`, update them with `assign`, and read them as ordinary `Term::Var` references. Each mut block evaluates to its final expression's value. 3. mut blocks are **sealed by construction**: a `var` is a lexically-scoped first-order binding whose only observable behaviour outside the block is the final value the block returns. No `var` reference can escape (vars are not first-class values), so the surrounding fn signature stays pure — no `!Mut` effect leaks, no reference-typed return. 4. The supported `var` element types are restricted to the **stack-resident primitives** Int, Float, Bool, Unit. Str (heap-RC-managed) and ADTs are deferred to the next milestone on the Stateful-islands path, alongside ref-types that can escape. The motivation is two-fold. First, **the roadmap path**: the full Stateful-islands milestone (`Stateful a b` + `!Mut` effect + pipe combinator) has ten identified blockers; the cheapest of those — the surface forms and codegen path for local mutation, with sealed-by-construction semantics — can ship as a self-contained milestone *before* the effect-handler infrastructure lands. Decoupling them keeps each milestone small, reviewable, and auditable. Second, **direct LLM-author utility**: even without the escape-permitting layers above it, local mutable state immediately removes friction from one recurring authoring shape — accumulators inside a fn body that today must be expressed by tail-recursive helpers carrying explicit "running total" parameters. The imperative form is what an LLM-author reaches for unprompted; this milestone supplies it under static-safety constraints stronger than myc's (vars are alloca-resident with statically-bounded lifetime, no aliasing possible). The bet is that an `(mut (var sum 0) (assign sum (+ sum x)) sum)` shape will appear in LLM-authored AILang code naturally, and that its presence will measurably reduce the line-count and the helper-fn count compared to the tail-recursive accumulator form that the same author would write today. The fieldtest after this milestone closes is exactly that LLM-utility measurement. ### Why this is the right first milestone on the path The full Stateful-islands roadmap entry identifies effect-handler infrastructure as the heaviest prerequisite. But mut-local is **orthogonal** to effect handlers: it lowers entirely to LLVM allocas with no effect annotation, no handler, no escape. It can ship today against the existing typechecker and codegen with three new AST nodes and one new pass invariant ("Term::Assign is legal only inside Term::Mut"). The follow-on milestone (ref-types + effect handlers + `!Mut`) builds on top of this without invalidating it — the seal-at-boundary semantics of this milestone become the "no escape" leg of the future ref-discharge semantics. If we ship effect handlers first, mut-local still has to be written; if we ship mut-local first, effect handlers benefit from the surface forms it establishes. The dependency goes one way. ### Out of scope (deferred to follow-on milestones) The following are explicitly deferred. Each is named so the boundary is unambiguous to the planner and to anyone reading the spec in retrospect. - **`ref a` as a first-class type.** No reference types in this milestone. Vars are *not* references — they are alloca slots with lexically-bounded lifetime. Reading a var yields a value, not a reference; the var's address is never observed. - **`!Mut` as an effect annotation.** No effect leakage from mut blocks in this milestone. Fns containing mut blocks retain their pure signature. The `!Mut` effect is reserved for the follow-on milestone that introduces escaping refs. - **Effect-handler infrastructure.** No `effect E { ... }` top- level declaration form, no `handle ... with ...` construct. mut is lowered as a syntactic-only construct without going through an effect-handler dispatch. - **`MutArray a` / mutable arrays.** No mutable container types. Future milestone. - **`Stateful a b`** sealed-callable type and `pipe` combinator. Future milestone (the full Stateful-islands target). - **Var element types beyond Int / Float / Bool / Unit.** Str (heap-Str ABI, RC-managed) and ADTs are deferred because their reassign semantics (drop old, retain new, RC bookkeeping) need separate design. This milestone covers the four scalar primitives whose reassign is a single LLVM `store`. - **Loops.** No `while`, no `for`. Iteration in this milestone is still tail recursion (Decision 8). A future milestone may add `while` *inside* mut blocks once the foundation here is solid. - **Multi-arm mut blocks / nested mut.** Nested mut is *allowed* (a mut block whose body contains another mut block); the inner block introduces a fresh scope. This is not "out of scope" — it falls out of the design — but it is called out explicitly so the typecheck pass treats it correctly. - **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` (iter mut.4-tidy). Mut-vars are alloca-resident and lexically scoped; lifting them into a heap-closure env would require ref-types and the `!Mut` effect, both deferred to the follow-on Stateful-islands milestone (the layered effect-handler + ref-type combo). The rejection is conservative — it fires on any free-var hit against the enclosing mut-scope-stack, with `Term::Match` pattern bindings not yet excluded from the free-var set (a future tidy can tighten the over-approximation without unblocking anything). ## Architecture The cut is **three new AST nodes** plus **one new typecheck pass invariant**, with codegen extending the existing `escape.rs` and `lower_term` paths to handle allocas and stores. ### Phase split **Today (no mut concept):** ``` ailang-surface::parse -> Module { defs: [FnDef, ...] } ailang-check::check -> typecheck per FnDef body (pure Term tree, no mutation) ailang-codegen -> SSA-only lowering, no allocas except per-fn arena (escape.rs decides between alloca and GC_malloc for ADT boxes) ``` **After this milestone:** ``` ailang-surface::parse -> recognises (mut ...) / (var ...) / (assign ...) Form-A constructs, builds Term::Mut / Term::Assign / MutVar nodes. ailang-check::check -> existing typecheck extended with mut-scope tracking: a stack of "current mut scope" entered on Term::Mut, exited on Term::Mut completion. Term::Var resolves to either a lexical mut-var (if in scope) or a regular let-bound / top-level identifier. Term::Assign requires its target name to be a mut-var in the current scope (else MutAssignOutOfScope diagnostic). ailang-codegen -> Term::Mut: emit alloca per mut-var at fn entry block (hoisted), emit init stores at the mut block's source location, lower body statements in order, lower final expression and return its SSA value. -> Term::Assign: emit store-to-alloca for the named var. -> Term::Var resolution at codegen: lookup-in-scope returns either an SSA (for let-bound / param) or a load-from-alloca (for a mut-var in scope). ``` ### Why three AST nodes, not one fused construct A single fused `Term::Mut { vars, stmts, final }` would conflate two things that the typechecker and codegen need to distinguish: 1. The block-scope (when does a var enter scope, when does it leave) — a property of `Term::Mut`. 2. The mutation operation (which var is being updated, with what value) — a property of each `Term::Assign`. Keeping them as separate nodes lets `Term::Assign` appear anywhere in the body of a `Term::Mut` (including inside `Term::If` branches, `Term::Match` arms, and so on), without the schema having to model a flat "list of mutation events". The lexical-scope check is "Term::Assign is reachable only when a `Term::Mut` is on the ancestor stack and the assigned name is one of its `vars`" — a single pass with a name-stack. `MutVar` (the var-declaration sub-shape) lives as a struct field of `Term::Mut`, not as a standalone Term variant, because vars cannot appear outside mut blocks. They are not first-class values; making them a Term variant would imply they can. ### Form A surface ``` ;; the mut form: vars listed first, statements in sequence, final ;; expression last. Sequence is the natural list of (assign ...) ;; and other Unit-typed steps; the final element is the block's ;; result. (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))) ``` `(var )` declares a mut-var with the given type and initial value. The type is explicit (no inference inside mut, consistent with Decision 6's "explicit annotations"). `(assign )` updates the var named ``. The var must be in lexical mut-scope. The expression's static type is Unit. The mut block's body is the flat tail of the form, after the trailing `var` declarations: a sequence of zero or more Unit-typed statements followed by exactly one final expression of any supported type. This is desugared by the surface parser into a `Term::Mut` whose `body` field is a single Term obtained by right-folding the sequence through `Term::Seq` (with the final element as the seed). The JSON-AST never sees a "list of statements" — it sees a `Term::Mut` containing exactly one body Term. ### JSON-AST schema ```jsonc { "t": "mut", "vars": [ { "name": "", "type": Type, "init": Term }, ... ], "body": Term } ``` The `vars` array MAY be empty (a `(mut )` form with no var declarations is legal; it is equivalent to a scope-introduction that does nothing — useful uniformity, not a feature in itself). ```jsonc { "t": "assign", "name": "", "value": Term } ``` `Term::Assign`'s static type is Unit. Its only legal context is the body sub-tree of a `Term::Mut` whose `vars` includes a var with the same `name`. The typechecker enforces this; codegen relies on it (an Assign outside a mut scope is a panic). ### Canonical-form invariants - The `vars` array is **not** sorted; ordering follows lexical declaration order (consistent with the surface). Reordering would affect codegen alloca ordering and the surface print. - The `vars` array MAY be empty; the empty-array form is the canonical encoding (the field stays present rather than being omitted, since `Term::Mut`'s shape is identified by its `t` tag and we want the schema to be uniform). - `Term::Assign.name` MUST refer to a mut-var of an ancestor `Term::Mut`. Out-of-scope Assigns are rejected at typecheck. - A `Term::Mut` with `vars: []` and `body` containing no `Assign` is **semantically a no-op** identical to its body alone. The parser does NOT collapse it; canonical form is preserved (round-trip determinism wins over a one-shot optimisation). ### Term::Var resolution Today: `Term::Var.name` resolves to a let-bound / param / top- level def / builtin / class-method. The resolver order is documented at `crates/ailang-check/src/lib.rs` (in the typechecker) and at `crates/ailang-codegen/src/lib.rs` (in the lowering). After this milestone: resolution gains one more layer at the front — **mut-var in current lexical scope**. The resolver checks the mut-scope stack first; if the name matches a mut-var of any enclosing `Term::Mut`, the reference resolves to that var. Otherwise the existing resolution proceeds. A mut-var shadows an outer let-bound / param / top-level name of the same identifier (legitimate user choice; the shadow is documented but not warned). Within nested mut blocks, the innermost mut-var of the same name wins (lexical shadowing). ## Components (iterations) Three iterations, each shippable as a standalone commit: ### Iteration mut.1 — Schema + surface - Add `Term::Mut { vars: Vec, body: Box }` variant to `ailang-core::ast::Term` (file: `crates/ailang-core/src/ast.rs`). - Add `Term::Assign { name: String, value: Box }` variant. - Add `MutVar { name: String, ty: Type, init: Term }` struct. - Wire canonical-JSON serde for both variants (`crates/ailang-core/src/hash.rs`-area). - Add `(mut ...)` / `(var ...)` / `(assign ...)` Form A productions to `ailang-surface::parse` and the form-A printer. - Add `tests/round_trip.rs` coverage with a `mut.ail` fixture. - DESIGN.md §"Data model" — append the two new Term variants and the MutVar sub-shape. DESIGN.md §"Term (expression)" gets the jsonc-blocked schema additions. Out of iteration: no typechecker recognition of the new nodes beyond the validator that round-trips them schema-side; no codegen. A `Term::Mut` reaching typecheck in iteration mut.1 produces a `CheckError::Internal("Term::Mut not yet supported in typecheck")` diagnostic and the test fixture is gated on iteration mut.2. Round-trip is the iteration's gate: surface → JSON-AST → surface must reproduce the source byte-for-byte for the `mut.ail` fixture. ### Iteration mut.2 — Typecheck - Extend `ailang-check::check_term` with the `Term::Mut` arm: - Push a fresh mut-scope frame onto a `mut_scope_stack: Vec>`. - For each MutVar in order: typecheck `init` in the outer scope plus already-declared vars; bind name → type in the current frame; check the type is one of {Int, Float, Bool, Unit} (else `CheckError::UnsupportedMutVarType`). - Typecheck `body` in scope of all vars + the mut-frame stack. - Pop the mut-scope frame. - The `Term::Mut`'s static type is `body`'s static type. - Extend `Term::Assign` arm: - Walk up the mut_scope_stack for a frame containing `name`. - If found: typecheck `value` against the var's type; produce Unit. If types mismatch: `CheckError::AssignTypeMismatch`. - If not found: `CheckError::MutAssignOutOfScope`. - Extend `Term::Var` resolution to consult the mut-scope stack first (innermost-wins shadowing). - Add unit tests under `crates/ailang-check/tests/` covering: legal mut, assign out of scope, type mismatch, unsupported var type (e.g. `var s : Str`), nested mut shadow, mut-var-shadows-let. Out of iteration: no codegen lowering. A `Term::Mut` reaching codegen in mut.2 produces `CodegenError::Internal("Term::Mut not yet supported in codegen")` and the e2e fixture is gated on iteration mut.3. ### Iteration mut.3 — Codegen + e2e - Extend `ailang-codegen::lower_term` with `Term::Mut`: - Hoist a `LLVMBuildAlloca` for each MutVar to the fn's entry block. Track the alloca pointer in a per-fn `mut_var_allocas: HashMap`. - At the current source position, lower each init and emit a `LLVMBuildStore` into the corresponding alloca. - Lower the body Term with `mut_var_allocas` extended; `Term::Var` resolution emits `LLVMBuildLoad` for a mut-var name. - On block exit, remove the introduced mut-vars from the map (lexical scope). - Extend `lower_term` with `Term::Assign`: - Lookup the alloca for `name` in `mut_var_allocas`. - Lower `value` to an SSA, emit `LLVMBuildStore`. - Yield a Unit SSA value. - Extend the escape-analysis pass `crates/ailang-codegen/src/escape.rs` to traverse `Term::Mut` (visit vars' inits + body) and `Term::Assign` (visit value). Mut blocks do not introduce escape edges by themselves; mut-vars are alloca-resident, not heap. - Add `examples/mut_counter.ail`: - A fn that uses `mut` to compute the sum of 1..10 and prints the result. Expected stdout: `55`. - Round-trips through `parse` ↔ render. - Builds and runs end-to-end via the e2e test harness. - Add `examples/mut_sum_floats.ail`: - Mirrors `mut_counter` for Float to exercise the second supported scalar. - Append a §"Local mutable state" entry to DESIGN.md's "What is supported" enumeration. Acceptance gate for the iteration: both example fixtures build + run + match expected stdout under the existing e2e harness; no new build-bench regressions (the bench corpus is pure-fn and is not touched). ## Data flow ``` .ail source | v ailang-surface::parse | - (mut ...) production -> Term::Mut { vars, body } | - (var ) entries -> MutVar | - (assign ) -> Term::Assign | - body sequence -> right-folded into Term::Seq if multi-step v canonical JSON-AST (Term::Mut + Term::Assign + MutVar nodes) | v ailang-check::check | - check_term arm for Term::Mut pushes mut-scope frame | - check_term arm for Term::Assign verifies in-scope + types | - Term::Var consults mut_scope_stack first v typed Term tree (mut-scope information no longer needed — the typecheck pass discards the stack on exit; codegen re-derives lexical scope from the tree) | v ailang-codegen::lower_term | - Term::Mut: hoist allocas to fn entry, emit init stores, | lower body, scope-exit removes vars from map | - Term::Assign: store-to-alloca | - Term::Var: load-from-alloca if mut-var, else existing v LLVM IR with alloca/store/load instructions on a per-fn basis | v clang -O2 | v native binary ``` The `mut_scope_stack` lives only inside the typecheck pass; it is not stored on the AST. Codegen re-derives the same information by walking the tree top-down with its own `mut_var_allocas` map. The correspondence between the two is enforced by the lexical-scope invariant in the AST: an Assign whose name is not lexically bound in an ancestor Mut would have failed typecheck. ## Error handling Three new diagnostic categories, one new compiler-internal invariant: ### CheckError::MutAssignOutOfScope Raised when `Term::Assign.name` does not match any mut-var in the current lexical mut-scope stack. ``` error[mut-assign-out-of-scope]: cannot assign to `foo` here -> the name `foo` is not declared as a mut-var in the enclosing (mut ...) block. Either declare it with (var foo ) at the top of the block, or move the assign inside the block that declares it. ``` The diagnostic includes the surface span of the Assign and (if any mut block is on the ancestor stack) the spans of the enclosing blocks' vars-lists, with the available mut-var names listed. ### CheckError::AssignTypeMismatch Raised when `Term::Assign.value`'s inferred type does not match the mut-var's declared type. ``` error[assign-type-mismatch]: cannot assign Float to mut-var `count : Int` -> `count` was declared as Int at ; the assign at has a Float value. The two types must match. ``` ### CheckError::UnsupportedMutVarType Raised when a `MutVar.ty` is not one of {Int, Float, Bool, Unit}. ``` error[mut-var-unsupported-type]: mut-var `s : Str` is not supported in this milestone -> only stack-resident primitive types (Int, Float, Bool, Unit) may be declared as mut-vars. Heap-RC-managed types (Str, ADTs, fn-values) are deferred to a follow-on milestone. Move the binding to a `(let ... )` outside the mut block, or restructure the code to use a primitive accumulator. ``` This is a *temporary* restriction; the diagnostic text says so explicitly. Future milestones lift it. ### Compiler invariant (codegen) A `Term::Assign` reaching `lower_term` with `mut_var_allocas` empty (i.e. outside any Mut frame) is a `CodegenError::Internal`. The typecheck pass is the gate; codegen does not re-validate, but asserts via the internal error if the invariant is broken. ## Testing strategy Three test tiers, all of which already exist in the project and get extended: ### Round-trip (gate for iteration mut.1) - `examples/mut.ail` — a minimal but representative fixture exercising: - empty `(mut)` (vars empty, body is a single literal expression); - single-var mut with one assign + final expr; - two-var mut with multiple assigns; - nested mut (outer var of one name, inner var shadowing it); - mut returning each of the supported var types (Int, Float, Bool, Unit). - `crates/ailang-surface/tests/round_trip.rs` picks up the fixture automatically (the test globs `examples/*.ail`). ### Typecheck negative tests (gate for iteration mut.2) Five negative fixtures under `crates/ailang-check/tests/` covering each diagnostic: - `test_mut_assign_out_of_scope.ail.json` — assign to a name that has not been declared as a mut-var. - `test_mut_assign_type_mismatch.ail.json` — assign Float to a mut-var of type Int. - `test_mut_var_unsupported_type.ail.json` — `var s : Str` (lifted by a future milestone). - `test_mut_assign_outside_mut.ail.json` — `Term::Assign` directly in a fn body with no `Term::Mut` ancestor. - `test_mut_nested_shadow_legal.ail.json` — positive test asserting the nested-shadow case is legal (sanity). Each fixture is a `.ail.json` (canonical-form-rejection style) so the test harness can assert the exact diagnostic code emitted. ### End-to-end (gate for iteration mut.3) - `examples/mut_counter.ail` — sum of 1..10 via mut, prints 55. - `examples/mut_sum_floats.ail` — sum of 1.0..10.0 via mut, prints the Float total. - Both are exercised by the e2e harness (`crates/ailang-codegen/tests/e2e.rs` or wherever the existing end-to-end runner sits), which builds the example, runs the resulting binary, captures stdout, and asserts the expected output. ### Bench corpus This milestone does NOT add streaming-specific benches; that is deferred to the future Stateful-islands milestone. The existing bench corpus is unaffected by this milestone (mut blocks are not introduced into bench fixtures); a regression check on the existing baseline runs as part of audit close. ## Acceptance criteria The milestone closes when **all** of the following are true: 1. Iteration mut.1 has shipped: `Term::Mut` / `Term::Assign` / `MutVar` are in the schema, the form-A surface parses + prints them, round-trip is green for `examples/mut.ail`. 2. Iteration mut.2 has shipped: the typecheck pass recognises all three nodes with the documented diagnostics; all five negative fixtures fail with the expected error code; nested-shadow is legal. 3. Iteration mut.3 has shipped: `examples/mut_counter.ail` and `examples/mut_sum_floats.ail` build + run + print the expected stdout under the e2e harness. 4. DESIGN.md is updated: "Data model" §Term lists the two new variants and their schemas; "What is (yet) supported" gains a "Local mutable state" bullet naming the milestone close date. 5. `ailang-architect` audit at milestone close: no drift; the three new AST nodes are documented in DESIGN.md and behave as spec'd. 6. Bench regression: existing `bench/run.sh` numbers within their tolerance bands (this milestone should not move them; the regression check confirms nothing inadvertently broke). 7. Fieldtest (post-audit, Boss-dispatched): the LLM-utility test — an LLM author given DESIGN.md plus public examples produces AILang code that reaches for `mut` unprompted when an accumulator is the natural shape. Friction or absence of reach-for is logged for the follow-on milestone. ## Load-bearing assumptions about current behaviour Statements this spec relies on as currently true. The grounding-check agent (Step 7.5) verifies each against a green test in the workspace. 1. **`ailang-core::ast::Term` is a Rust enum and adding a new variant is mechanical.** Recent precedent: `Term::ReuseAs` shipped post-Decision-10 (DESIGN.md §"Term"). The schema-drift test at `crates/ailang-core/tests/design_schema_drift.rs` already enumerates Term variants and is the gating mechanism for additions. 2. **The form-A parser/printer in `ailang-surface` is the sole text projection** (per the post-form-a-default-authoring state) and round-trip is gated by `crates/ailang-surface/tests/round_trip.rs` over every shipped `.ail` fixture (DESIGN.md §"Roundtrip Invariant"). 3. **Typecheck operates by structural walk over Term with a fresh environment per fn body** (DESIGN.md §"What is supported": "HM inference inside bodies … lambdas check monomorphically against their declared type"). Adding a per-scope side-stack for mut-vars is consistent with this shape. 4. **Codegen lowers per fn with `LLVMBuildAlloca` already used for the per-fn arena** (DESIGN.md §"Per-fn arena via stack `alloca`"). The alloca path is exercised end-to-end by `examples/escape_local_demo.ail`. Hoisting mut-var allocas to the fn entry block follows the same LLVM convention. 5. **Diagnostics in `ailang-check` follow the bracketed-code format `[diag-code]: message`** with rendered spans (the cli-diag-human iter, 2026-05-14, made this uniform across all `WorkspaceLoadError` paths; per-iter journal `docs/journals/2026-05-14-iter-cli-diag-human.md` is the reference). 6. **Existing `Term::Var` resolution at typecheck consults a stacked scope** (let-bound, param, top-level def, builtin, class-method, in that order). Prepending one more layer (mut-var, innermost-wins) is additive. 7. **`Term::Seq` already exists and types correctly** when its lhs is Unit-typed (DESIGN.md §"Term": `seq` semantically is `let _ = lhs in rhs`). `Term::Assign` produces Unit, so a sequence of Assigns chained via Seq is well-typed by existing machinery. 8. **The four supported scalar primitives (Int, Float, Bool, Unit) are stack-resident with trivial value semantics** — they are not RC-managed and a store/load pair is the full mutation primitive at codegen. Str is heap-RC-managed and adds drop-old / retain-new bookkeeping; restricting this milestone to non-RC types keeps the codegen story to a single LLVM `store`. 9. **The schema-drift test catches additions** of Term variants that are not documented in DESIGN.md (`crates/ailang-core/tests/design_schema_drift.rs:111`-area compares the enum's variants against DESIGN.md anchors). The DESIGN.md amendment in iteration mut.1 is gated by this test. 10. **The escape-analysis pass `crates/ailang-codegen/src/escape.rs` is structured as a Term-tree walk** that decides per allocating-site between `alloca` and `GC_malloc`. Extending it for `Term::Mut` and `Term::Assign` is mechanical (visit children); mut-var allocas are always stack-resident, never heap.