Design spec for #55, superseding #54. Deletes ParamMode::Implicit in a single cutover -> binary {Own, Borrow}; no defaulted ownership mode survives on any fn-type slot, authored or compiler-synthesised. The grounding pass corrected the central architecture claim. Both #55 and the first draft assumed a new return-provenance check was needed to reject an own-return aliasing a borrowed binder, "silently accepted today". Running the candidate fixtures through the live `ail check` falsified that: consume-while-borrowed already rejects all three provenance paths -- direct passthrough, let-indirection, and borrow escaping into a returned constructor (regime A). Own-return soundness and regime A are already-green properties. The cutover therefore adds exactly one new check (borrow-return rejection) plus one signature-level reject (borrow-over-value), not two new provenance analyses. Scope decisions ratified: keyword stays verbal (own/borrow), ParamMode keeps its name; totality is strict including value-type slots (every slot moded, (own value) trivial, (borrow value) an error, bare slot a parse error) so an LLM author needs no heap/value knowledge. The leak fix (spec 0061 symptom) falls out of Implicit->Own at the synthesis sites: the old typechecker made Implicit and Own indistinguishable (mode_eq), so setting Own changes no typecheck outcome -- it only flips the codegen dec gate from skip to emit. Out of scope: re-enabling borrow-returns (needs the escape/liveness axis), multi-return, mode polymorphism. grounding-check: PASS (7 assumptions ratified against named green tests; all fenced ail blocks validated against the live tool). refs #55
20 KiB
Eliminate the Implicit ownership default — Design Spec
Date: 2026-06-01 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Goal
Delete ParamMode::Implicit from the language in a single cutover.
ParamMode becomes binary {Own, Borrow}. After this cycle no
fn-type slot — parameter or return, authored or compiler-synthesised
— carries a defaulted ownership mode. Every authored slot reads
(own T) or (borrow T); every compiler-synthesised fn-type carries
a concrete Own.
This closes #55 and supersedes #54. It also fixes the typed-MIR
fieldtest leak (spec 0061): the leak is the observable symptom of the
real defect, which is that Implicit is read two ways — the
typechecker treats Implicit ≡ Own (mode_eq,
crates/ailang-core/src/ast.rs:894), while codegen omits the caller
dec for it (crates/ailang-codegen/src/drop.rs:476), so the same
fn-type means two different things. Removing the variant removes the
fork.
The design rationale is design/models/0008-ownership-totality.md
§§1–3 (the totality principle: a default cannot be half-removed) and
the 2026-06-01 design review (full totality is not atomically
shippable because of borrow-returns; this spec carves them out). The
model is status: Design exploration — its claims are validated
against the live tool by this spec, not assumed.
What the grounding work changed in this spec
Both #55 and the first draft of this spec assumed a new
return-provenance check was needed to reject an own return that
aliases a borrowed binder (the fn f(x: (borrow T)) -> (own T) { x }
passthrough), and that this passthrough is "silently accepted today".
Running the candidate fixtures through the live ail check (traces in
the Testing section) falsified that assumption: the existing
consume-while-borrowed analysis already rejects all three provenance
paths —
- direct passthrough
(body b)→ rejected today; - let-indirection
(body (let y b y))→ rejected today; - borrow escaping into a returned constructor
(body (term-ctor Wrap Wrap b))(regime A, model §5.1) → rejected today.
A borrow parameter holds a live borrow for the whole function body, so
any use of it at a consume position (own-return, ctor arg, own-param
arg) trips consume-while-borrowed. Own-return soundness and regime
A are therefore already-green properties, not new work. The cutover
adds exactly one new check (borrow-return rejection) plus one
signature-level reject (borrow-over-value), not two new provenance
analyses. This is the central correction the grounding pass produced.
Scope decisions ratified here
-
Keyword form stays verbal (
own/borrow, today's surface). No adjectival rename, no keyword churn. The internal type keeps the nameParamMode. (User decision, 2026-06-01.) -
Totality is strict, including value-type slots. Every fn-type slot carries
(own T)or(borrow T), value types included.(own (con Int))is trivial-own (no inc/dec ever emitted);(borrow (con Int))is a check error (§3.2 of the model). Bare type in a fn-type slot is rejected by the parser everywhere. Rationale: this gives an LLM author a heap/value-agnostic rule — always write a mode;ownis always safe; reach forborrowonly when you want a heap view — so the author never needs to know which types are boxed. The alternative (bare = trivial-own for value types only) forces exactly that heap/value classification on every slot and is rejected.
Out of scope
- Re-enabling borrow-returns.
(ret (borrow T))is a check error in this cut, with a diagnostic naming the liveness/escape gap. The escape/liveness axis that would make borrow-returns sound is separate later work (model §5.2, §8). - Multi-return (model §5.3, §7-Q4).
- Mode polymorphism (model §4). A type-variable slot takes a
concrete
own/borrow; the surface already cannot express a mode variable, so no change is needed.
Architecture
The cutover has four schema/representation edits, one new check, one
new signature-level reject, and one corpus migration. They land
together: a deleted Implicit means returns must be own/borrow,
every checked-in .ail source carries Implicit slots today, and the
hash shift is one-time for the whole corpus.
Schema / representation (crates/ailang-core):
ParamModeloses theImplicitvariant and#[derive(Default)]— ownership has no default, so the type has no default.Type::Fn::param_modes/ret_modelose theirskip_serializing_ifelision. Modes are always serialised. This is the one-time canonical-JSON hash shift for the whole corpus.mode_eq(theImplicit ≡ Owninterchange) is deleted; mode equality becomes structural==.Type::fn_implicitis renamedType::fn_ownedand setsOwnon every synthesised slot (justified in Data flow).
New check (crates/ailang-check):
- Borrow-return rejection.
(ret (borrow T))is a check error with a diagnostic pointing at the out-of-scope liveness axis. This is the only genuinely new ownership analysis in the cycle.
New signature-level reject (crates/ailang-check):
- Borrow-over-value.
(borrow value-type)in any slot is a check error, fired on the signature before body analysis so it does not depend on the body trippingconsume-while-borrowed. Specified by model §3.2; this cycle makes it reachable now that every value slot is moded.
Parser (crates/ailang-surface):
- A bare type in a fn-type slot is a parse error. The slot grammar
becomes
fn-type-param ::= "(" ("borrow" | "own") type ")"— the bare-typealternative is removed.
Migration:
- Every
.ailsource (prelude, kernelraw_buf, the ~208 example fixtures) gets explicit modes. Param modes are derived from the existing uniqueness/consume analysis (consumed ⇒own, read-only ⇒borrow; value types alwaysown); return modes areowneverywhere (no borrow-return survives). The hash-pin tests reset their pinned hashes once.
Already-green, pinned not built: own-return provenance and regime A
are enforced by the existing consume-while-borrowed check. The cycle
adds regression pins (Testing) proving the schema deletion does not
weaken them, but builds no new analysis for them.
Concrete code shapes
Every block below was run through the configured ail check; the
exit-code traces are in the Testing section.
User-facing: the post-cutover authoring surface (checks clean today)
The canonical form an LLM author writes after this cycle. Every slot
carries a mode; value-type slots use trivial-own:
(module ownership_total
(data List
(doc "Monomorphic singly-linked Int list — boxed, recursive.")
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn list_length
(doc "Borrow the list, count its elements.")
(type
(fn-type
(params (borrow (con List)))
(ret (own (con Int)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t)
(app + 1 (app list_length t))))))
(fn sum_list
(doc "Consume the list, sum its elements.")
(type
(fn-type
(params (own (con List)))
(ret (own (con Int)))))
(params xs)
(body
(match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t)
(app + h (app sum_list t))))))
(fn main
(doc "Build [1,2,3]; print length (3) then sum (6).")
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body
(let xs
(term-ctor List Cons 1
(term-ctor List Cons 2
(term-ctor List Cons 3
(term-ctor List Nil))))
(seq
(seq (app print (app list_length xs)) (do io/print_str "\n"))
(seq (app print (app sum_list xs)) (do io/print_str "\n")))))))
This is the feature-acceptance evidence: an LLM author writes a mode
on every slot, never a bare type, and never needs to know that Int
is unboxed and List is boxed — own is the always-safe choice,
borrow the deliberate heap-view on list_length's parameter.
New RED fixture: borrow-return rejection
The one new check. Wellformed today (no return check exists), a check error post-cutover; the diagnostic names the liveness gap and the out-of-scope status:
(module borrow_return_reject
(data Box
(doc "Heap cell holding one Int.")
(ctor Box (con Int)))
(fn peek
(doc "MUST FAIL post-cutover: borrow-return is refcount-invisible and can outlive its source; re-enabling needs the escape axis.")
(type
(fn-type
(params (own (con Box)))
(ret (borrow (con Box)))))
(params b)
(body b)))
New RED fixture: borrow-over-value
Wellformed today, a signature-level check error post-cutover. The body ignores the param so the rejection is unambiguously about the signature, not a body-level consume:
(module borrow_value_reject
(fn ignore
(doc "MUST FAIL post-cutover: borrow over a value type is meaningless.")
(type
(fn-type
(params (borrow (con Int)))
(ret (own (con Int)))))
(params n)
(body 0)))
New parser reject: bare type in a fn-type slot
Wellformed today (bare = Implicit), a parse error post-cutover:
(module bare_slot_reject
(fn id
(doc "MUST FAIL post-cutover: bare `(con Int)` slot carries no mode.")
(type
(fn-type
(params (con Int))
(ret (con Int))))
(params x)
(body x)))
Already-green regression pin: own-return provenance + regime A
These are rejected today by consume-while-borrowed. They are
pinned as regressions to prove the schema deletion does not weaken the
existing guard — not as new checks:
(module own_return_provenance_reject
(data Box
(doc "Heap cell holding one Int.")
(ctor Box (con Int)))
(fn passthrough
(doc "Already rejected today (consume-while-borrowed): own-return aliases a borrowed binder.")
(type
(fn-type
(params (borrow (con Box)))
(ret (own (con Box)))))
(params b)
(body b)))
North-star: the fixed leak
examples/rc_let_implicit_returning_app.ail asserts live=1 today as
an intentional Implicit back-compat lane. Post-cutover its return is
own, the caller dec is emitted, and the property flips to live=0.
The fixture header and its property-3 assertion are rewritten from
"Implicit leaks by design" to "own-return frees correctly".
Secondary: implementation shapes (before → after)
Supporting detail, not the headline.
crates/ailang-core/src/ast.rs — ParamMode:
before:
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParamMode { #[default] Implicit, Own, Borrow }
after:
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParamMode {
Own, // (own T) — caller transfers ownership; callee consumes
Borrow, // (borrow T) — caller retains ownership; callee may not consume
}
// no #[derive(Default)]: ownership has no default.
crates/ailang-core/src/ast.rs — Type::Fn serde:
before:
#[serde(default, skip_serializing_if = "all_implicit")] param_modes: Vec<ParamMode>,
#[serde(default, skip_serializing_if = "ParamMode::is_implicit")] ret_mode: ParamMode,
after:
param_modes: Vec<ParamMode>, // always present, len == params.len()
ret_mode: ParamMode, // always present
// all_implicit / is_implicit helpers deleted.
crates/ailang-core/src/ast.rs — mode_eq deleted:
before: fn mode_eq(a, b) { Implicit and Own interchangeable; Borrow distinct }
after: removed; modes compared with derived PartialEq (a == b)
crates/ailang-core/src/ast.rs — fn_implicit → fn_owned:
before: pub fn fn_implicit(params, ret, effects) -> Type // sets Implicit
after: pub fn fn_owned(params, ret, effects) -> Type // sets Own
// 16 call sites updated; Own is correct by the Data-flow argument.
crates/ailang-codegen/src/drop.rs — App ret-mode gate (:467–478):
before: matches!(m, ParamMode::Own) with Implicit/Borrow → no dec (the leak)
after: matches!(m, ParamMode::Own) unchanged in form; the Implicit case no
longer exists, so every former-Implicit return now matches Own and is
dec'd. Comment updated to drop the Implicit back-compat language.
crates/ailang-surface/src/parse.rs — slot grammar (:32, parse_param_with_mode):
before: fn-type-param ::= type | "(" "borrow" type ")" | "(" "own" type ")"
after: fn-type-param ::= "(" "borrow" type ")" | "(" "own" type ")"
// bare type → ParseError (no mode on slot)
Components
ailang-core::ast— the enum, theType::Fnfields, the constructor, equality. Blast-radius origin.ailang-surface::parse/print— slot grammar gains a reject arm; printer drops theImplicitarm (print.rs:360).ailang-check::uniqueness/linearity— host the borrow-return reject and the borrow-over-value reject (both signature inspections). The migration mode-derivation reusesconsume_count. No new provenance analysis —consume-while-borrowed(linearity.rs:1272) already covers own-return provenance and regime A.ailang-codegen::drop/lambda/match_lower/lib— everyParamMode::Implicitmatch arm deleted; gate logic unchanged in form..ailcorpus — prelude,raw_buf, examples; mechanically re-moded then reviewed.
Data flow
Why Own is correct for every synthesised slot (fn_owned).
Implicit had two roles: the authoring default (bare type) and the
internal marker for fn-types the compiler builds (lambda types, builtin
signatures, inferred application results). The authoring role is
deleted. The synthesis role resolves to Own with no behaviour
change: the old typechecker made Implicit and Own indistinguishable
(mode_eq true for every Implicit/Own pairing), so every
synthesised fn-type was already semantically Own under typechecking —
no check ever depended on a slot being Implicit rather than Own.
Setting Own at synthesis changes no typecheck outcome. It changes
exactly one thing: the codegen gate (drop.rs:476) flips from "skip
the caller dec" to "emit it" for those former-Implicit returns —
precisely the leak fix. The synthesis sites do not need the consume
analysis (that is for authored params during migration); Own is
correct for them by construction. The corpus typecheck/codegen suite is
the regression net for this claim (Testing).
Migration mode-derivation. For each authored param: run the
uniqueness pass, read consume_count — > 0 ⇒ (own T), == 0 ⇒ (borrow T) for heap types, (own T) for value types. For each return:
(own T) (no borrow-return survives; any borrow-passthrough body is
now rejected and must be restructured — and is already rejected today,
so the corpus contains none). Derived sources are reviewed by the
orchestrator before commit — the author owns the annotations; the
derivation is a drafting aid, not a surviving default.
Check flow for the new rejects. Both the borrow-return reject and
the borrow-over-value reject are signature inspections on the fn-type,
fired before body dataflow — no new tracking. Own-return provenance and
regime A need no flow here: consume-while-borrowed already fires
because a borrow parameter's borrow is live across the whole body, so
any consume of it (return-as-own, ctor arg) is caught. Ctor args are
Position::Consume (linearity.rs:527), which is why regime A holds —
a borrowed value cannot escape into a returned constructor.
Error handling
- Bare slot (parse error).
parse_param_with_modereturns aParseErrorwhen a slot is a bare type, naming the slot and requiring(own …)or(borrow …). Rendered in AILang syntax. - Borrow-return (check error, new). New
CheckErrorvariant. States(ret (borrow …))is not permitted in this language version and points at the liveness/escape axis — an honest "not yet". - Borrow-over-value (check error, new). New
CheckErrorvariant. Signature-level: borrow is meaningless over an unboxed value type. - Own-return provenance. No new diagnostic —
consume-while-borrowedalready covers it; its message is unchanged.
Each new CheckError variant gets a stable code via the existing
CheckError::code() registry (the diagnostic registry — not a separate
doc list).
Testing strategy
Live ail check traces of the spec's fixtures (run 2026-06-01,
target/debug/ail):
ownership_total.ail : exit 0 ok (surface checks + runs)
bare_slot_reject.ail : exit 0 (legal today; parser reject post-cutover)
borrow_return_reject.ail : exit 0 (legal today; new check post-cutover)
borrow_value_reject.ail : exit 0 (legal today; new sig reject post-cutover)
own_return_provenance_reject.ail : exit 1 error: [consume-while-borrowed] passthrough: `b` ...
let-indirection (let y b y) : exit 1 error: [consume-while-borrowed] f: `b` ...
ctor-escape (term-ctor Wrap b) : exit 1 error: [consume-while-borrowed] f: `b` ... (regime A)
- New RED fixtures (
borrow_return_reject,borrow_value_reject,bare_slot_reject) land underexamples/, each asserting the specific diagnostic fires post-cutover. These are the RED side of the one new check, the one new signature reject, and the parser reject. - Already-green regression pins (
own_return_provenance_rejectand the let-indirection / ctor-escape variants) assertconsume-while-borrowedstill fires after the schema deletion. They protect the own-return-soundness and regime-A properties the cycle relies on but does not build. - North-star leak flip.
rc_let_implicit_returning_app.ailand its pin family (crates/ail/tests/print_no_leak_pin.rs) flip fromlive=1tolive=0underAILANG_RC_STATS. - Hash-pin reset.
crates/ailang-core/tests/hash_pin.rsandcrates/ailang-surface/tests/prelude_module_hash_pin.rsget their pinned hashes regenerated once and re-asserted (memory: hash-pin blast-radius audit — both sites). - Round-trip invariant.
ailang-surfaceround-trip tests stay green with modes always present; add a case proving a bare-slot input now fails to parse. - Synthesis-site behaviour. The corpus typecheck/codegen suite is
the regression net for
fn_implicit → fn_owned: if any synthesised slot was not semanticallyOwn, a test breaks. Green corpus ratifies the Data-flow argument. - Full workspace suite, not just e2e — typed-MIR re-synth
strictness (memory) means mode changes surface in
lower_to_mirre-unification; runcargo test --workspace.
Acceptance criteria
ParamModehas exactly two variants{Own, Borrow}, noDefault, noImplicitanywhere incrates/(grep clean).- The parser rejects a bare type in any fn-type slot; round-trip tests prove it.
borrow_return_rejectandborrow_value_rejectfail their checks with the specified diagnostics; the post-cutover authoring-surface module checks and runs clean.- The own-return-provenance / regime-A pins stay red
(
consume-while-borrowedfires) after the deletion. rc_let_implicit_returning_app.ailreportslive=0underAILANG_RC_STATS(leak fixed).cargo test --workspacegreen, with hash-pins regenerated once and re-asserted.design/contracts/0008-memory-model.mdsections describingImplicit(:180-182,:288-298) are updated to the binary model as part of this cycle (a contract change rides with the feature that forces it).