# 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):

1. **Value-type params** — an `Int`/`Bool`/`Float`/`Unit` param read
   multiple times trips `use-after-consume`. Value types have no
   refcount and are never consumed; multi-read is always legal.
2. **Function params applied in HOFs** — applying a function param
   (`apply_thrice`, `map_int`, `fold_left`) trips `use-after-consume`
   (own f-param) or `consume-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

1. **`Str` is heap, not a value type, for consume-tracking.** The
   model §1 calls `Str` "an RC-heap value"; codegen confirms it:
   `drop.rs:490–492` lowers only `Int`/`Bool`/`Float`/`Unit` to
   non-`ptr`, and `Str` lowers to `ptr` and is RC-`dec`'d. A `Str`
   binder **can** be consumed (its slab freed) and a multi-consume
   without `clone` is a genuine use-after-free. So the value-type
   exemption covers exactly the **unboxed** set `{Int, Bool, Float,
   Unit}` — narrower than `primitives::is_primitive_name`, which
   includes `Str`. This divergence is deliberate and named: "primitive
   zero-arity type constructor" (`is_primitive_name`, includes `Str`)
   is a different predicate from "unboxed value type, no RC"
   (`is_value_type`, excludes `Str`).

2. **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), which `callee_arg_modes` already
   resolves. The recursion-passing pattern (`map_int f t`) is then
   consistent: `f` is borrowed for the application *and* borrowed for
   the recursive hand-off when `map_int`'s f-param is `borrow`.

### 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), and `lam` typed-params. A
  `let`-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-typed `let`-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-mode` lint's `Str` treatment.** That lint uses
  `is_heap_type` (which routes through `is_primitive_name`, so it
  treats `Str` as non-heap). Whether that is correct for the lint is a
  separate question; this spec does not touch `is_heap_type` or 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_fn` already has `param_tys`; a param whose
  type is a value type starts with `is_value = true`;
- **pattern binders** — `walk_arm` looks the binder's type up in the
  ctor field types (the `ctors` map, now threaded into the `Checker`)
  and sets `is_value` accordingly;
- **`lam` typed-params** — `Term::Lam` carries each param's declared
  type; a value-typed lam param starts with `is_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`):

```ail
(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:**

```ail
(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:**

```ail
(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):

```ail
(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:**

```text
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`:**

```text
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:**

```text
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`):**

```text
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`** — gains `is_value_type`; a unit test
  pins it as the `Str`-excluding subset of `is_primitive_name`.
- **`ailang-check::linearity`** — `BinderState.is_value`; the `use_var`
  consume short-circuit; the `App` callee borrow walk; `is_value`
  seeding at param / pattern / lam introduction; `Checker` gains an
  immutable `ctors` reference for pattern-binder typing.
- **`.ail` fixtures** — the three RED fixtures plus the must-stay-RED
  heap-consume fixture, under `examples/`.

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:

```text
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_map` land under
  `examples/`; 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_consume` asserts the heap
  `use-after-consume` still fires after the fix — the exemption is
  type-gated, not blanket.
- **Unit test on the predicate.** `is_value_type` agrees with
  `is_primitive_name` on every name except `Str`, where
  `is_value_type` is `false` and `is_primitive_name` is `true`.
- **Linearity unit tests.** The in-source `#[cfg(test)] mod tests` in
  `linearity.rs` gains 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 produces
  `use-after-consume`.
- **Full corpus regression.** `cargo test --workspace` stays green,
  and the existing `examples/` 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.py` and
  `bench/compile_check.py` stay green (the analysis is diagnostic-only;
  no compile-baseline shift is expected).

## Acceptance criteria

1. `is_value_type` exists in `ailang-core::primitives`, returns `true`
   for exactly `{Int, Bool, Float, Unit}`, and a unit test pins it as
   the `Str`-excluding subset of `is_primitive_name`.
2. `fp_value`, `fp_hof`, `fp_map` check **clean** (exit 0) after the
   fix; each shipped as an `examples/` fixture asserting its today→post
   transition.
3. `real_consume` still fails with `use-after-consume` after the fix.
4. `apply_thrice`-style own-function-param application and
   `map_int`-style borrow-function-param application+recursion both
   check clean.
5. The value exemption is type-gated: an in-source unit test shows a
   heap param multi-consume still produces `use-after-consume` while a
   value param multi-read produces none.
6. `cargo test --workspace` green; `bench/check.py` and
   `bench/compile_check.py` green.
7. No change to `ParamMode`, `Type::Fn`, the parser/printer, codegen,
   the runtime, `is_heap_type`, or the `over-strict-mode` lint (grep /
   diff clean).
