Precondition for #55 (eliminate ParamMode::Implicit, spec 0062). The strict linearity check (use-after-consume / consume-while-borrowed) runs today only on the ~45 of 258 all-explicit-mode fns; deleting Implicit turns it on universally and surfaces ~21% false positives in two classes — value-type params (Int/Bool/Float/Unit, never consumed) and applied function params in HOFs (application is a borrow). Two design questions settled against the live tool, not the model: - Str is heap, NOT a value type for consume-tracking. drop.rs:490-492 lowers only Int/Bool/Float/Unit to non-ptr; Str is a ptr, RC-dec'd, so a multi-consume of Str without clone is a real use-after-free. The value-type set is the unboxed {Int,Bool,Float,Unit}, narrower than is_primitive_name (which includes Str). - Applying a function value is a borrow; passing it as an arg follows callee param_modes (unchanged). The recursion-passing HOF pattern is then consistent under a borrow f-param. All three false-positive classes reproduced today against explicit-mode fns (testable without #55) and recorded as RED fixtures; a heap double-consume fixture stays RED to prove the exemption is type-gated. grounding-check PASS. refs #56
17 KiB
Harden the ownership analysis for universal activation — Design Spec
Date: 2026-06-01 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Goal
Make the strict linearity analysis (use-after-consume /
consume-while-borrowed, crates/ailang-check/src/linearity.rs)
correct when it runs on every fn, not just the ~45 of 258
fn-bearing modules that today carry an all-explicit signature. The
analysis is the core ownership-safety feature, yet its activation gate
(linearity.rs:339) skips any fn with a bare/Implicit param — so it
almost never runs. Deleting ParamMode::Implicit (#55, spec 0062)
turns it on universally, and two blind spots then surface as ~21%
false positives (38 of ~182 functional fixtures, measured 2026-06-01):
- Value-type params — an
Int/Bool/Float/Unitparam read multiple times tripsuse-after-consume. Value types have no refcount and are never consumed; multi-read is always legal. - Function params applied in HOFs — applying a function param
(
apply_thrice,map_int,fold_left) tripsuse-after-consume(own f-param) orconsume-while-borrowed(borrow f-param). Applying a function value reads it; it is a borrow, not a consume.
This is the precondition for #55: spec 0062 §8 derives each migrated parameter's mode from the consume analysis and asserts "the corpus contains none [of the rejected shapes]". That assertion only holds once these two blind spots are closed — otherwise the cutover turns ~21% of the corpus red. Closing them is also independently valuable: it makes the core ownership analysis sharp across the whole codebase for the first time.
The rationale anchors are design/models/0008-ownership-totality.md
§3.2 (value types are trivial-own; a value type "has nothing to
lend") and §4 (HOF mode slots — each canonical HOF applies its
function param, which is a read), plus the RC/uniqueness contract
design/contracts/0008-memory-model.md. The model is status: Design exploration; its claims are validated against the live tool below
(Testing strategy), not assumed.
Scope decisions ratified here
-
Stris heap, not a value type, for consume-tracking. The model §1 callsStr"an RC-heap value"; codegen confirms it:drop.rs:490–492lowers onlyInt/Bool/Float/Unitto non-ptr, andStrlowers toptrand is RC-dec'd. AStrbinder can be consumed (its slab freed) and a multi-consume withoutcloneis a genuine use-after-free. So the value-type exemption covers exactly the unboxed set{Int, Bool, Float, Unit}— narrower thanprimitives::is_primitive_name, which includesStr. This divergence is deliberate and named: "primitive zero-arity type constructor" (is_primitive_name, includesStr) is a different predicate from "unboxed value type, no RC" (is_value_type, excludesStr). -
Applying a function value is a borrow. A function value (closure or fn-ref) applied at a call site is read, not moved; it stays live for further applications and is dropped at scope close like any other binder. Passing a function value as an argument is unchanged — it follows the callee's
param_modes(borrow-param → borrow, own-param → consume), whichcallee_arg_modesalready resolves. The recursion-passing pattern (map_int f t) is then consistent:fis borrowed for the application and borrowed for the recursive hand-off whenmap_int's f-param isborrow.
Out of scope
- #55 itself — deleting
ParamMode::Implicit, the schema/hash reset, the parser/return changes. This spec only hardens the analysis; it changes no schema, resets no hash, and is testable today against explicit-mode fns (Testing strategy). - Value-typed
let-binders. The exemption is driven from the binder's type where it is locally available: parameter signatures, ctor field types (pattern binders), andlamtyped-params. Alet-binder's type is inferred, not annotated, and the linearity walk is a post-typecheck pass that does not re-run inference, so a value-typedlet-binder stays conservatively tracked as heap. This is soundness-safe (it can only keep the analysis over-strict — a potential false positive — never miss a real consume) and is not a measured corpus shape. Lifting it would mean threading a full binder→type table out of the type-checker; deferred until a corpus shape demands it. - The
over-strict-modelint'sStrtreatment. That lint usesis_heap_type(which routes throughis_primitive_name, so it treatsStras non-heap). Whether that is correct for the lint is a separate question; this spec does not touchis_heap_typeor the lint.
Architecture
Two orthogonal, additive changes inside linearity.rs, plus one new
shared predicate. No schema change, no new Type variant, no hash
shift, no codegen change. The analysis stays a pure diagnostic pass.
New shared predicate (crates/ailang-core/src/primitives.rs):
is_value_type(name) -> bool returns true for exactly Int /
Bool / Float / Unit. It is the "unboxed, no-RC" predicate, as
distinct from is_primitive_name (which also returns true for
Str). Both live in the same module so the divergence is visible in
one place, and a unit test pins that is_value_type ⊊
is_primitive_name, the sole difference being Str.
Fix 1 — value-type exemption (linearity.rs):
A binder of a value type is never consumed. BinderState gains an
is_value: bool flag; use_var skips all consume bookkeeping (no
consumed = true, no consume-while-borrowed) when it is set. The
flag is set from the binder's locally-available type at three
introduction sites:
- parameters —
check_fnalready hasparam_tys; a param whose type is a value type starts withis_value = true; - pattern binders —
walk_armlooks the binder's type up in the ctor field types (thectorsmap, now threaded into theChecker) and setsis_valueaccordingly; lamtyped-params —Term::Lamcarries each param's declared type; a value-typed lam param starts withis_value = true.
Because the flag rides on BinderState, it follows the existing
save/restore scoping (with_binder) automatically and survives the
branch/match merge_states (a binder's value-ness is invariant across
branches, so the merge keeps it).
Fix 2 — application is a borrow (linearity.rs):
Term::App walks its callee in Position::Borrow instead of
Position::Consume (linearity.rs:433). For a global fn-ref callee
this is a no-op (globals are not tracked binders, so use_var returns
early either way); for a tracked function-typed binder (a HOF param,
or a let/lam-bound function value) it stops the application from
consuming the binder. The callee's consumed flag is still checked,
so applying an already-consumed function value still fires
use-after-consume.
Concrete code shapes
RED fixtures (check-error today, clean post-fix)
These are the feature-acceptance evidence: the post-#55 authoring
surface forces every value-type and function-type slot to carry a mode
((own …) / (borrow …)), and an LLM author writing the natural form
below trips a false positive today — exactly the friction this
cycle removes. All three are testable now against explicit-mode fns,
without #55, because an all-explicit signature already activates the
analysis. Live ail check traces (target/debug/ail, run
2026-06-01) are in the Testing section.
Class 1 — value-type param read multiple times ((borrow (con Int)) is an error per model §3.2, so a multi-read Int param must
be own):
(module fp_value
(fn sum_explicit
(doc "value-type param read multiple times")
(type (fn-type (params (own (con Int))) (ret (own (con Int)))))
(params n)
(body (if (app eq n 0) 0 (app + n (app sum_explicit (app - n 1))))))
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body (app print (app sum_explicit 10)))))
Class 2a — own function param applied multiple times:
(module fp_hof
(fn apply_thrice
(doc "apply a function param three times")
(type (fn-type
(params (own (fn-type (params (own (con Int))) (ret (own (con Int))))) (own (con Int)))
(ret (own (con Int)))))
(params f x)
(body (app f (app f (app f x)))))
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body (app print (app apply_thrice (lam (params (typed y (con Int))) (ret (con Int)) (body (app + y 1))) 0)))))
Class 2b — recursive HOF, borrow function param applied + passed:
(module fp_map
(data IntList
(doc "boxed list")
(ctor Nil)
(ctor Cons (con Int) (con IntList)))
(fn map_int
(doc "recursive HOF: f applied AND passed to the recursive call")
(type (fn-type
(params (borrow (fn-type (params (own (con Int))) (ret (own (con Int))))) (own (con IntList)))
(ret (own (con IntList)))))
(params f xs)
(body (match xs
(case (pat-ctor Nil) (term-ctor IntList Nil))
(case (pat-ctor Cons h t) (term-ctor IntList Cons (app f h) (app map_int f t)))))))
Must-stay-RED fixture (a genuine consume, not exempted)
The exemption must not silence a real heap multi-consume. This
boxed-ADT param is consumed twice with no clone; it is a real
use-after-consume and stays an error after the fix (proves the
exemption is type-gated to value types, not blanket):
(module real_consume
(data Box
(doc "heap cell")
(ctor Box (con Int)))
(data Pair
(doc "two boxes")
(ctor Pair (con Box) (con Box)))
(fn dup
(doc "MUST STAY an error: a heap param consumed twice without clone")
(type (fn-type (params (own (con Box))) (ret (own (con Pair)))))
(params b)
(body (term-ctor Pair Pair b b))))
Secondary: implementation shapes (before → after)
Supporting detail, not the headline.
crates/ailang-core/src/primitives.rs — new predicate:
after (added alongside is_primitive_name):
/// Unboxed value type — no RC, no heap slab. Narrower than
/// `is_primitive_name`: `Str` is a primitive zero-arity ctor but is
/// heap-allocated (`ptr`, RC'd), so it is NOT a value type.
pub fn is_value_type(name: &str) -> bool {
matches!(name, "Int" | "Bool" | "Float" | "Unit")
}
crates/ailang-check/src/linearity.rs — BinderState:
before: struct BinderState { consumed: bool, borrow_count: u32 }
after: struct BinderState { consumed: bool, borrow_count: u32, is_value: bool }
// is_value: a value-typed binder is never consumed.
linearity.rs — use_var consume arm:
before (Position::Consume):
if state.borrow_count > 0 { ...consume-while-borrowed...; return; }
state.consumed = true;
after:
if state.is_value { return; } // value types are never consumed
if state.borrow_count > 0 { ...consume-while-borrowed...; return; }
state.consumed = true;
linearity.rs — Term::App callee walk (:433):
before: self.walk(callee, Position::Consume); // "the function value is consumed"
after: self.walk(callee, Position::Borrow); // applying a function value reads it
// global fn-refs are untracked → no-op; a tracked function-typed
// binder (HOF param) is no longer consumed by application.
linearity.rs — param/pattern/lam is_value seeding: check_fn
sets is_value from param_tys[i] (a Type::Con whose name
satisfies is_value_type); walk_arm threads the ctors map to type
each pattern binder; Term::Lam reads its typed-param types. All three
default is_value = false (heap, conservative) when the type is not a
value Type::Con.
Components
ailang-core::primitives— gainsis_value_type; a unit test pins it as theStr-excluding subset ofis_primitive_name.ailang-check::linearity—BinderState.is_value; theuse_varconsume short-circuit; theAppcallee borrow walk;is_valueseeding at param / pattern / lam introduction;Checkergains an immutablectorsreference for pattern-binder typing..ailfixtures — the three RED fixtures plus the must-stay-RED heap-consume fixture, underexamples/.
No change to: the schema (ParamMode, Type::Fn), the parser, the
printer, codegen, the runtime, the over-strict-mode lint, or
is_heap_type.
Data flow
Value exemption. The linearity walk does not infer types; it reads
them where the source already pins them. A parameter's type is on the
signature (param_tys); a pattern binder's type is the corresponding
ctor field type (ctors[ctor].fields[i]); a lam param's type is its
(typed x T) annotation. Each of these is a Type::Con { name } for a
value type, and is_value_type(name) decides the flag. The flag is
immutable per binder and rides BinderState, so lexical scoping
(with_binder) and branch merging (merge_states) carry it without
special handling. A binder whose type is not a locally-pinned value
Type::Con (a heap ADT, Str, a type variable, a let-binder)
defaults to is_value = false and is tracked exactly as today.
Application borrow. Term::App already distinguishes callee from
args: args go through callee_arg_modes to pick up per-position
modes, while the callee was walked as Consume. Switching the callee
to Borrow aligns it with the semantics — a function value is read by
application — and leaves the arg path untouched, so passing a function
value to an own param still consumes it and to a borrow param
still borrows it. The consumed-check in use_var still runs in
Borrow position, so applying an already-consumed function value is
still caught.
Error handling
No new diagnostic codes. The two existing codes
(use-after-consume, consume-while-borrowed) fire on a strictly
smaller, more correct set: they no longer fire on value-type
multi-reads or on function-param applications, and they continue to
fire on genuine heap multi-consume (the must-stay-RED fixture) and on
applying an already-consumed function value. The change is a precision
improvement to existing diagnostics, not a new check surface.
Testing strategy
Live ail check traces (run 2026-06-01, target/debug/ail) — the
state today, before the fix:
fp_value.ail : exit 1 error: [use-after-consume] sum_explicit: `n` ...
fp_hof.ail : exit 1 error: [use-after-consume] apply_thrice: `f` ... (x2)
fp_map.ail : exit 1 error: [consume-while-borrowed] map_int: `f` ...
real_consume.ail : exit 1 error: [use-after-consume] dup: `b` ...
- RED→GREEN fixtures.
fp_value,fp_hof,fp_mapland underexamples/; each asserts the named diagnostic fires today and is clean after the fix (exit 0). These are the RED side of the two blind-spot fixes. - Stays-RED fixture.
real_consumeasserts the heapuse-after-consumestill fires after the fix — the exemption is type-gated, not blanket. - Unit test on the predicate.
is_value_typeagrees withis_primitive_nameon every name exceptStr, whereis_value_typeisfalseandis_primitive_nameistrue. - Linearity unit tests. The in-source
#[cfg(test)] mod testsinlinearity.rsgains cases: a value-typed param multi-read produces no diagnostic; an own/borrow function-param application produces no diagnostic; a heap-typed param multi-consume still producesuse-after-consume. - Full corpus regression.
cargo test --workspacestays green, and the existingexamples/typecheck/codegen suite is the net for "the exemption silenced nothing it should not have". Per the typed-MIR re-synth strictness memory, run the whole workspace suite, not just e2e. - Regression scripts.
bench/check.pyandbench/compile_check.pystay green (the analysis is diagnostic-only; no compile-baseline shift is expected).
Acceptance criteria
is_value_typeexists inailang-core::primitives, returnstruefor exactly{Int, Bool, Float, Unit}, and a unit test pins it as theStr-excluding subset ofis_primitive_name.fp_value,fp_hof,fp_mapcheck clean (exit 0) after the fix; each shipped as anexamples/fixture asserting its today→post transition.real_consumestill fails withuse-after-consumeafter the fix.apply_thrice-style own-function-param application andmap_int-style borrow-function-param application+recursion both check clean.- The value exemption is type-gated: an in-source unit test shows a
heap param multi-consume still produces
use-after-consumewhile a value param multi-read produces none. cargo test --workspacegreen;bench/check.pyandbench/compile_check.pygreen.- No change to
ParamMode,Type::Fn, the parser/printer, codegen, the runtime,is_heap_type, or theover-strict-modelint (grep / diff clean).