spec: mut-local — local mutable state (mut/var/assign) — foundation milestone for stateful islands

First shippable milestone on the Stateful-islands roadmap path:
sealed-by-construction local mutable bindings in fn bodies, with
no ref-types, no effect handlers, no escape. Lowers entirely to
LLVM allocas with statically-bounded lifetime; fn signatures stay
pure (no !Mut effect leakage).

Three iterations: mut.1 (schema + surface), mut.2 (typecheck +
diagnostics), mut.3 (codegen + e2e). Var element types restricted
to Int/Float/Bool/Unit (non-RC-managed scalars) in this milestone;
Str/ADT/fn-value vars deferred.

Grounding-check PASS on iteration mut.1 assumptions: Term enum
extension is gated by design_schema_drift, round-trip by
ailang-surface round_trip auto-discovery, DESIGN.md amendment by
the same drift test, schema_coverage forces a corpus fixture.
This commit is contained in:
2026-05-15 00:33:24 +02:00
parent ae96c903a1
commit ce5567d601
+604
View File
@@ -0,0 +1,604 @@
# 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.
## 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.