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.
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:
- The AST has three new shapes —
Term::Mut(the block), theMutVarstruct inside it (one mutable binding), andTerm::Assign(an in-block update). Form A surface syntax for all three round-trips canonically. - Inside a
mutblock, the LLM author may declare mutable bindings withvar, update them withassign, and read them as ordinaryTerm::Varreferences. Each mut block evaluates to its final expression's value. - mut blocks are sealed by construction: a
varis a lexically-scoped first-order binding whose only observable behaviour outside the block is the final value the block returns. Novarreference can escape (vars are not first-class values), so the surrounding fn signature stays pure — no!Muteffect leaks, no reference-typed return. - The supported
varelement 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 aas 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.!Mutas an effect annotation. No effect leakage from mut blocks in this milestone. Fns containing mut blocks retain their pure signature. The!Muteffect is reserved for the follow-on milestone that introduces escaping refs.- Effect-handler infrastructure. No
effect E { ... }top- level declaration form, nohandle ... 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 bsealed-callable type andpipecombinator. 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, nofor. Iteration in this milestone is still tail recursion (Decision 8). A future milestone may addwhileinside 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::Mutis rejected at typecheck withCheckError::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!Muteffect, 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, withTerm::Matchpattern 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:
- The block-scope (when does a var enter scope, when does it
leave) — a property of
Term::Mut. - 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
varsarray is not sorted; ordering follows lexical declaration order (consistent with the surface). Reordering would affect codegen alloca ordering and the surface print. - The
varsarray MAY be empty; the empty-array form is the canonical encoding (the field stays present rather than being omitted, sinceTerm::Mut's shape is identified by itsttag and we want the schema to be uniform). Term::Assign.nameMUST refer to a mut-var of an ancestorTerm::Mut. Out-of-scope Assigns are rejected at typecheck.- A
Term::Mutwithvars: []andbodycontaining noAssignis 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 toailang-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 toailang-surface::parseand the form-A printer. - Add
tests/round_trip.rscoverage with amut.ailfixture. - 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_termwith theTerm::Mutarm:- Push a fresh mut-scope frame onto a
mut_scope_stack: Vec<HashMap<String, Type>>. - For each MutVar in order: typecheck
initin the outer scope plus already-declared vars; bind name → type in the current frame; check the type is one of {Int, Float, Bool, Unit} (elseCheckError::UnsupportedMutVarType). - Typecheck
bodyin scope of all vars + the mut-frame stack. - Pop the mut-scope frame.
- The
Term::Mut's static type isbody's static type.
- Push a fresh mut-scope frame onto a
- Extend
Term::Assignarm:- Walk up the mut_scope_stack for a frame containing
name. - If found: typecheck
valueagainst the var's type; produce Unit. If types mismatch:CheckError::AssignTypeMismatch. - If not found:
CheckError::MutAssignOutOfScope.
- Walk up the mut_scope_stack for a frame containing
- Extend
Term::Varresolution 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_termwithTerm::Mut:- Hoist a
LLVMBuildAllocafor each MutVar to the fn's entry block. Track the alloca pointer in a per-fnmut_var_allocas: HashMap<String, (LLVMValueRef alloca, Type ail_ty)>. - At the current source position, lower each init and emit a
LLVMBuildStoreinto the corresponding alloca. - Lower the body Term with
mut_var_allocasextended;Term::Varresolution emitsLLVMBuildLoadfor a mut-var name. - On block exit, remove the introduced mut-vars from the map (lexical scope).
- Hoist a
- Extend
lower_termwithTerm::Assign:- Lookup the alloca for
nameinmut_var_allocas. - Lower
valueto an SSA, emitLLVMBuildStore. - Yield a Unit SSA value.
- Lookup the alloca for
- Extend the escape-analysis pass
crates/ailang-codegen/src/escape.rsto traverseTerm::Mut(visit vars' inits + body) andTerm::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
mutto 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.
- A fn that uses
- Add
examples/mut_sum_floats.ail:- Mirrors
mut_counterfor Float to exercise the second supported scalar.
- Mirrors
- 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).
- empty
crates/ailang-surface/tests/round_trip.rspicks up the fixture automatically (the test globsexamples/*.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::Assigndirectly in a fn body with noTerm::Mutancestor.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.rsor 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:
- Iteration mut.1 has shipped:
Term::Mut/Term::Assign/MutVarare in the schema, the form-A surface parses + prints them, round-trip is green forexamples/mut.ail. - 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.
- Iteration mut.3 has shipped:
examples/mut_counter.ailandexamples/mut_sum_floats.ailbuild + run + print the expected stdout under the e2e harness. - 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.
ailang-architectaudit at milestone close: no drift; the three new AST nodes are documented in DESIGN.md and behave as spec'd.- Bench regression: existing
bench/run.shnumbers within their tolerance bands (this milestone should not move them; the regression check confirms nothing inadvertently broke). - Fieldtest (post-audit, Boss-dispatched): the LLM-utility test —
an LLM author given DESIGN.md plus public examples produces
AILang code that reaches for
mutunprompted 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.
ailang-core::ast::Termis a Rust enum and adding a new variant is mechanical. Recent precedent:Term::ReuseAsshipped post-Decision-10 (DESIGN.md §"Term"). The schema-drift test atcrates/ailang-core/tests/design_schema_drift.rsalready enumerates Term variants and is the gating mechanism for additions.- The form-A parser/printer in
ailang-surfaceis the sole text projection (per the post-form-a-default-authoring state) and round-trip is gated bycrates/ailang-surface/tests/round_trip.rsover every shipped.ailfixture (DESIGN.md §"Roundtrip Invariant"). - 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.
- Codegen lowers per fn with
LLVMBuildAllocaalready used for the per-fn arena (DESIGN.md §"Per-fn arena via stackalloca"). The alloca path is exercised end-to-end byexamples/escape_local_demo.ail. Hoisting mut-var allocas to the fn entry block follows the same LLVM convention. - Diagnostics in
ailang-checkfollow the bracketed-code format[diag-code]: messagewith rendered spans (the cli-diag-human iter, 2026-05-14, made this uniform across allWorkspaceLoadErrorpaths; per-iter journaldocs/journals/2026-05-14-iter-cli-diag-human.mdis the reference). - Existing
Term::Varresolution 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. Term::Seqalready exists and types correctly when its lhs is Unit-typed (DESIGN.md §"Term":seqsemantically islet _ = lhs in rhs).Term::Assignproduces Unit, so a sequence of Assigns chained via Seq is well-typed by existing machinery.- 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. - 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. - The escape-analysis pass
crates/ailang-codegen/src/escape.rsis structured as a Term-tree walk that decides per allocating-site betweenallocaandGC_malloc. Extending it forTerm::MutandTerm::Assignis mechanical (visit children); mut-var allocas are always stack-resident, never heap.