diff --git a/docs/specs/2026-05-15-mut-local.md b/docs/specs/2026-05-15-mut-local.md new file mode 100644 index 0000000..6752463 --- /dev/null +++ b/docs/specs/2026-05-15-mut-local.md @@ -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 )` 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.