Files
AILang/docs/specs/2026-05-15-mut-local.md
T
Brummel 20add51112 iter mut.4-tidy + audit close — mut-local milestone closed
Tidy iter addressing the audit-mut-local drift. Plus paired baseline
update on bench/baseline_compile.json (audit-skill discipline).

Architect [high] items closed:

1. CheckError::MutVarCapturedByLambda rejects lambdas whose body
   free vars include a mut-var of the enclosing mut_scope_stack.
   Uses the existing ailang_core::desugar::free_vars_in_term walker
   (which honours Term::Match pattern bindings). The scan runs only
   when mut_scope_stack is non-empty.

2. crates/ailang-codegen/src/lambda.rs: the capture-not-in-locals
   path that previously raised CodegenError::Internal blaming the
   typechecker now uses unreachable!. The companion comment block
   was rewritten to state the current reality.

Architect [medium] items closed (stale mut.1-stub history comments):

3. docs/DESIGN.md §'Term (expression)' mut/assign block: the
   trailing paragraph describing the iter mut.1 stub state was
   replaced with one describing the current mut.3-end-state. The
   inline jsonc comment on {'t': 'assign'} was updated to drop the
   'deferred to mut.2/mut.3' language.

4. crates/ailang-codegen/src/lib.rs: the stale comment block above
   the real Term::Mut arm describing the mut.1 stub was removed
   entirely.

Other:

- Short-circuit on empty mut_scope_stack in synth's Term::Var arm:
  the iter mut.2 prepend now skips the iter-and-find walk when
  the stack is empty, eliminating any per-Var-resolution overhead
  for the common case (programs with no mut blocks). The
  short-circuit did NOT close the check_ms regression — see ratify
  below.

- Spec docs/specs/2026-05-15-mut-local.md §'Out of scope' amended
  with the lambda-capture rejection bullet.

- Negative fixture examples/test_mut_var_captured_by_lambda.ail.json
  + driver test extension in crates/ailang-check/tests/
  mut_typecheck_pin.rs (6th test) +
  crates/ailang-core/tests/carve_out_inventory.rs EXPECTED bumped
  12 → 13.

Bench-regression ratify:

bench/compile_check.py check_ms showed a uniform ~30-50% relative
shift across the entire 11-fixture suite (~0.5ms absolute on a
1.4-1.5ms baseline). The uniformity across fixtures of very
different Var counts argues for a fixed-cost-per-invocation tax,
not a Var-proportional hot path. The Term::Var short-circuit
falsified the hot-path hypothesis. The plausible remaining causes
(synth-parameter-passing through ~19 recursive sites, and binary-
size startup tax from mut-* adding ~1400 LOC to typecheck/codegen)
are both feature-cost, not pathological. Ratified by paired
journal entry; bench/baseline_compile.json updated to the post-
mut-local numbers via 'bench/compile_check.py --update-baseline'.

bench/check.py continues to show the established tail-latency-noise
envelope from audit-pd (2026-05-14) — no separate ratify needed.

bench/cross_lang.py clean.

Tests: 594 → 598 green (4 new lib.rs mod tests + 1 driver test +
1 fixture-corpus uptake).

Journal: docs/journals/2026-05-15-iter-mut.4-tidy.md.

mut-local milestone end-to-end status:
  - mut.1 (7b92719): AST + Form A surface.
  - mut.2 (b24718a): typecheck.
  - mut.3 (03fb633): codegen + e2e.
  - mut.4-tidy (this commit): audit-drift close + bench ratify.
mut-local milestone CLOSED.

Refs: docs/specs/2026-05-15-mut-local.md,
docs/plans/2026-05-15-iter-mut.4-tidy.md.
2026-05-15 09:21:33 +02:00

26 KiB

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

{ "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).

{ "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.jsonvar s : Str (lifted by a future milestone).
  • test_mut_assign_outside_mut.ail.jsonTerm::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.