# 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 <name> <type> <init>)` 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 <name> <value>)` updates the var named `<name>`. 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": "<id>", "type": Type, "init": Term }, ... ],
  "body": Term }
```

The `vars` array MAY be empty (a `(mut <expr>)` 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": "<id>",
  "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<MutVar>, body: Box<Term> }` variant
  to `ailang-core::ast::Term` (file: `crates/ailang-core/src/ast.rs`).
- Add `Term::Assign { name: String, value: Box<Term> }` 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<HashMap<String, Type>>`.
  - 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<String, (LLVMValueRef alloca, Type ail_ty)>`.
  - 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 <name> <type> <init>) entries -> MutVar
   |   - (assign <name> <value>) -> 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 <type> <init>)
     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 <span>; the assign at <span>
     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.
