feat(lang): eliminate the Implicit ownership default — totality + the drop-soundness it demasks (#55)

Deletes `ParamMode::Implicit`. `ParamMode` is now `{Own, Borrow}`:
every fn-type slot on every signature carries an explicit `own` or
`borrow`, no defaulted position survives anywhere (model 0008 §2,
spec 0062). The parser rejects a bare fn-type slot; `borrow-return`
and `borrow-over-value` reject at the signature; the corpus is
migrated to minimal-ownership modes (consumed ⇒ own, read-only-heap
⇒ borrow, value ⇒ trivial-own). The documented `Implicit`-ret-mode
leak is fixed: an owned heap return now drops exactly once (live=0,
acceptance criterion 5).

This was the easy half. Removing the default ACTIVATED a family of
drop paths that `Implicit` had silently skipped — the pre-cutover
language was leaking (and in places mis-dropping) here rather than
crashing, because an Implicit scrutinee turned the drop off. Making
the modes explicit (Own) turned those paths on and exposed two
latent-bug clusters, all fixed RED-first as part of this cutover:

Drop-soundness family (four legs):
  A. lit-sub-pattern double-free — the desugar re-matched the same
     owned scrutinee in the lit fall-through; fixed by grouping
     consecutive same-ctor arms into one match (bind fields once),
     in ailang-core desugar.
  B. Cons-husk leak on non-tail arm bodies — the lit-sub-pattern
     desugar rebound the owned scrutinee via `Let $mp = xs`, which
     bumped consume_count and suppressed the existing fn-return
     partial_drop. Fixed by not rebinding a bare-Var scrutinee
     (one husk-freeing mechanism, not two).
  C. polymorphic `drop_<T>` rc_dec'd monomorphised value fields —
     the per-ADT drop fn was emitted once from the polymorphic
     TypeDef, defaulting type-var fields to ptr and rc_dec'ing
     inline Ints (segfault). Fixed with per-monomorph drop
     functions (new ailang-codegen::dropmono): the drop set is
     collected from the lowered MIR, value-type fields are skipped,
     heap fields still freed once; monomorphic-concrete ADTs keep
     their byte-identical un-suffixed drop symbol.
  D. static Str literal passed to an `(own Str)` param — the
     literal lowers to a header-less rodata constant; the callee's
     now-active rc_dec read its length field as a refcount and
     freed a static address (segfault). Fixed with the missing
     fourth StrRep::Static→Heap promotion in lower_to_mir's App arm,
     gated on Own mode (borrow args stay static, no regression).

over-strict-mode lint over-fired: it suggested `(borrow V)` for
value-typed params (which `borrow-over-value` rejects — own is the
only legal mode there) and fired on `(intrinsic)` bodies (whose
consumption the linearity walk cannot observe). Tightened to skip
both; contract 0008 updated to the narrowed firing scope.

Irreversible step — canonical-form hash reset (model 0008 §6,
acceptance criterion 6). Every signature now carries explicit modes,
so the hashable canonical JSON changed for every module. RATIFY:
the corpus-wide hash-pin reset (hash_pin, prelude_module_hash_pin,
mono_hash_stability, eq_ord_e2e, embed_export_hash_stable, the
ct4/iter*/loop_recur schema-extension pins) and the list ir_snapshot
golden were regenerated once, deliberately, as the intended one-time
consequence of removing the mode elision from the canonical form —
not a regression. Each regenerated hash verified deterministic across
two runs.

Also fixes a pre-existing latent failure surfaced by the verification
gate, unrelated to this cutover: the `every_contract_names_a_resolvable_
ratifying_test` resolver (design_index_pin) could not resolve the
" + " dual-link ratifying-test form (`uniqueness.rs + linearity.rs`)
that the #57 audit-close (dfdc65f) introduced — it shipped red on that
commit. Resolver taught the dual-link form, mirroring its sibling.

Verification: cargo test --workspace = 731 passed, 0 failed (twice,
stable); e2e 102 passed, no binary exits non-zero (corpus crash-free);
grep-clean for Implicit/fn_implicit/mode_eq across crates; every drop
fix confirmed via emitted IR + AILANG_RC_STATS balance on the head==K,
head!=K, and Nil paths. Three BLOCKEDs en route (the unsound first
husk-dec attempt, the over-strict derivation premise, the leg-B fix
direction) were each treated as a real design/spec gap and rediagnosed,
not patched over.

Supersedes #54 (return-position-only leak patch). Precondition #57
(linearity hardening) was already met. Spec docs/specs/0062, plan
docs/plans/0121.

closes #55
This commit is contained in:
2026-06-02 00:03:46 +02:00
parent 05c3c018de
commit 76b21c00eb
342 changed files with 3196 additions and 1503 deletions
+72 -50
View File
@@ -40,6 +40,7 @@ use ailang_mir::{Callee, MArg, MTerm, MirDef, MirWorkspace, Mode, StrRep};
use std::collections::{BTreeMap, BTreeSet};
mod drop;
mod dropmono;
mod escape;
mod intercepts;
mod lambda;
@@ -396,6 +397,14 @@ fn lower_workspace_inner(mir: &MirWorkspace, alloc: AllocStrategy, target: Targe
module_consts.insert(mname.clone(), consts);
}
// Leg C: workspace-global per-monomorph drop metadata. Built once
// here (after the symbol-table pass, before lowering) and shared by
// reference with every `Emitter`. Computes which polymorphic ADTs
// take a per-instantiation drop fn and the concrete arg-tuples each
// is used at, so the emission loop and the call-site manglers agree
// on every `drop_<m>_<T>__<suffix>` symbol.
let drop_monos = dropmono::collect_drop_monos(mir);
// Pass 2: lower per module. Globals/strings are accumulated per module,
// because they are mangled per module.
for (mname, mir_module) in &mir.modules {
@@ -432,6 +441,7 @@ fn lower_workspace_inner(mir: &MirWorkspace, alloc: AllocStrategy, target: Targe
&module_ctor_index,
&module_consts,
import_map,
&drop_monos,
alloc,
);
emitter
@@ -770,6 +780,13 @@ struct Emitter<'a> {
module_user_fns: &'a BTreeMap<String, BTreeMap<String, FnSig>>,
/// Import map of the current module (alias/module name → actual module name).
import_map: BTreeMap<String, String>,
/// workspace-global per-monomorph drop metadata (leg C). Tells the
/// drop-fn emission loop which polymorphic ADTs to emit one drop fn
/// per instantiation for, and tells the call-site manglers whether
/// a `Type::Con` takes a per-monomorph `__<suffix>` on its
/// `drop_`/`partial_drop_` symbol. Shared by reference with every
/// `Emitter`.
drop_monos: &'a dropmono::DropAdtMeta,
/// ADT table: type_name -> list of ctors in definition order.
/// Tag of a ctor = index in this list.
/// Kept around for future tools (pretty-printer for ADT values,
@@ -874,15 +891,15 @@ struct Emitter<'a> {
/// entry) from the fn type's `param_modes`. Consulted by
/// `lower_match`'s arm-close pattern-binder dec emission (Iter A) to
/// decide whether the scrutinee was statically owned: if the
/// scrutinee resolves to a fn-param whose mode is `Borrow` or
/// `Implicit`, the pattern-binder dec must NOT fire — the caller
/// still holds a reference and dec'ing the pattern-binder would
/// fragment the caller's structure.
/// scrutinee resolves to a fn-param whose mode is `Borrow`, the
/// pattern-binder dec must NOT fire — the caller still holds a
/// reference and dec'ing the pattern-binder would fragment the
/// caller's structure.
///
/// Symmetric with the Iter B gate at fn return (`emit_fn`'s Own-
/// param dec): both sites must check the param-mode signal before
/// dec'ing, because Implicit and Borrow do not carry the "caller
/// handed off ownership" signal that makes the dec safe.
/// param dec): both sites check the param-mode signal before
/// dec'ing, because `Borrow` does not carry the "caller handed off
/// ownership" signal that makes the dec safe.
current_param_modes: BTreeMap<String, ParamMode>,
/// Per-fn map of name → (alloca SSA name, AIL element type) for
/// alloca-resident loop binders. Populated on entry to a
@@ -973,6 +990,7 @@ impl<'a> Emitter<'a> {
module_ctor_index: &'a BTreeMap<String, BTreeMap<String, CtorRef>>,
module_consts: &'a BTreeMap<String, BTreeMap<String, ConstDef>>,
import_map: BTreeMap<String, String>,
drop_monos: &'a dropmono::DropAdtMeta,
alloc: AllocStrategy,
) -> Self {
let mut types: BTreeMap<String, Vec<CtorInfo>> = BTreeMap::new();
@@ -1030,6 +1048,7 @@ impl<'a> Emitter<'a> {
str_counter: 0,
module_user_fns,
import_map,
drop_monos,
types,
module_ctor_index,
module_consts,
@@ -1293,10 +1312,9 @@ impl<'a> Emitter<'a> {
fn emit_fn(&mut self, f: &FnDef, body: Option<&MTerm>) -> Result<()> {
// also lift `param_modes` out of the fn type. The
// fn-return Own-param dec emission below consults it to decide
// which params get a drop call before `ret`. `Implicit`
// entries (legacy / unannotated) and `Borrow` entries are
// skipped — only `Own` carries the static "caller handed off
// ownership" signal.
// which params get a drop call before `ret`. `Borrow` entries
// are skipped — only `Own` carries the static "caller handed
// off ownership" signal.
let (param_tys, ret_ty, param_modes) = match &f.ty {
Type::Fn {
params,
@@ -1339,7 +1357,10 @@ impl<'a> Emitter<'a> {
self.pending_entry_allocas.clear();
self.entry_block_end_marker = None;
for (i, pname) in f.params.iter().enumerate() {
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Implicit);
// Codegen-synthesised fn-defs (lambda thunks, local-rec
// lifts) may carry an empty `param_modes`; fall back to
// `Own` (the synthesis default) rather than index-panic.
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Own);
self.current_param_modes.insert(pname.clone(), mode);
}
// run escape analysis over the fn body. The result
@@ -1469,11 +1490,10 @@ impl<'a> Emitter<'a> {
// the caller's frame; caller dec's, not us),
// - the current block is still open.
//
// `Implicit`-mode params do NOT get this dec: they have
// no static "caller handed off ownership" signal —
// emitting a dec here might double-dec a value the caller
// also dec's. `Borrow`-mode params definitely don't get
// dec'd (the caller still owns them).
// `Borrow`-mode params do NOT get dec'd: the caller still
// owns them, so there is no caller-handed-off-ownership
// signal and a dec here would fragment the caller's
// structure.
//
// Closes the 18c.3/18c.4 carve-out: "fn parameters still
// don't get dec'd at fn return — the caller-handed-off-
@@ -1490,7 +1510,7 @@ impl<'a> Emitter<'a> {
if plty != "ptr" {
continue;
}
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Implicit);
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Own);
if !matches!(mode, ParamMode::Own) {
continue;
}
@@ -1812,8 +1832,8 @@ impl<'a> Emitter<'a> {
// If `value` is a `Term::Var` referencing a name in
// `current_param_modes`, the let-binder inherits that
// mode for the duration of the body. Without this,
// `(let a t (match a ...))` where `t` is an Implicit
// / Borrow-mode param defeats the
// `(let a t (match a ...))` where `t` is a
// `Borrow`-mode param defeats the
// `scrutinee_is_owned` gate in `lower_match` (the
// gate looks up `a` in `current_param_modes`, misses,
// and defaults to "owned" — Iter A then dec's
@@ -2690,8 +2710,7 @@ impl<'a> Emitter<'a> {
// alias whose owner is some other binder and is dropped
// there, never here;
// - the matching callee param mode is `Borrow` — `Own` slots
// consume the arg (the callee dec's it), `Implicit` is the
// back-compat lane that carries no transfer signal;
// consume the arg (the callee dec's it);
// - the dropped SSA is never the call result `dst` (an input
// argument SSA is always distinct from the freshly-minted
// result SSA), so this can never dec a value that flows out
@@ -2702,7 +2721,7 @@ impl<'a> Emitter<'a> {
// `param_modes`), so the borrow-slot test reads it directly
// — no re-lookup of the callee's signature from a codegen
// sig table. `Borrow` slots borrow the arg, so an Own-ret
// heap temp landing in one is dropped here; `Own`/`Implicit`
// heap temp landing in one is dropped here; `Own`
// slots consume the arg (the callee dec's it).
for (arg, (arg_ssa, arg_ty)) in args.iter().zip(compiled_args.iter()) {
let is_borrow_slot = matches!(arg.mode, Mode::Borrow);
@@ -3168,7 +3187,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["a".into(), "b".into()],
body: Term::App {
@@ -3192,7 +3211,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
@@ -3238,7 +3257,7 @@ mod tests {
ret: Box::new(Type::int()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Lit {
@@ -3313,7 +3332,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
@@ -3386,7 +3405,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
@@ -3418,7 +3437,7 @@ mod tests {
ret: Box::new(ret_ty),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body,
@@ -3491,7 +3510,7 @@ mod tests {
name: name.into(),
ty: Type::Fn {
params: vec![], ret: Box::new(ret_ty), effects: vec![],
param_modes: vec![], ret_mode: ParamMode::Implicit,
param_modes: vec![], ret_mode: ParamMode::Own,
},
params: vec![], body, suppress: vec![], doc: None,
export: None,
@@ -3543,7 +3562,7 @@ mod tests {
name: name.into(),
ty: Type::Fn {
params: vec![], ret: Box::new(Type::float()), effects: vec![],
param_modes: vec![], ret_mode: ParamMode::Implicit,
param_modes: vec![], ret_mode: ParamMode::Own,
},
params: vec![], body, suppress: vec![], doc: None,
export: None,
@@ -3553,7 +3572,7 @@ mod tests {
name: "main".into(),
ty: Type::Fn {
params: vec![], ret: Box::new(Type::unit()), effects: vec![],
param_modes: vec![], ret_mode: ParamMode::Implicit,
param_modes: vec![], ret_mode: ParamMode::Own,
},
params: vec![], body: Term::Lit { lit: Literal::Unit },
suppress: vec![], doc: None,
@@ -3598,7 +3617,7 @@ mod tests {
ret: Box::new(Type::bool_()),
effects: vec![],
param_modes: vec![ParamMode::Borrow, ParamMode::Borrow],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["x".into(), "y".into()],
// Body is a placeholder — the intercept must
@@ -3616,7 +3635,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
@@ -3661,7 +3680,7 @@ mod tests {
ret: Box::new(Type::bool_()),
effects: vec![],
param_modes: vec![ParamMode::Borrow, ParamMode::Borrow],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["x".into(), "y".into()],
body: Term::Lit { lit: Literal::Bool { value: false } },
@@ -3676,7 +3695,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
@@ -3725,7 +3744,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
@@ -3824,8 +3843,11 @@ mod tests {
params: vec![param_ail_ty.clone(), param_ail_ty.clone()],
ret: Box::new(Type::Con { name: "Ordering".into(), args: vec![] }),
effects: vec![],
param_modes: vec![ParamMode::Borrow, ParamMode::Borrow],
ret_mode: ParamMode::Implicit,
// value-typed params (Int/Bool) cannot be `(borrow V)` —
// the cutover's `borrow-over-value` reject forbids it; a
// value type is copied, so `own` is the only legal mode.
param_modes: vec![ParamMode::Own, ParamMode::Own],
ret_mode: ParamMode::Own,
},
params: vec!["x".into(), "y".into()],
// placeholder body; the `compare__<T>` intercept overrides
@@ -3849,7 +3871,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
@@ -3894,7 +3916,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
@@ -3930,7 +3952,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec!["IO".into()],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Do {
@@ -3972,7 +3994,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec!["IO".into()],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Do {
@@ -4033,7 +4055,7 @@ mod tests {
ret: Box::new(Type::bool_()),
effects: vec![],
param_modes: vec![ParamMode::Borrow, ParamMode::Borrow],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec!["x".into(), "y".into()],
body: Term::Lit { lit: Literal::Bool { value: false } },
@@ -4048,7 +4070,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
@@ -4129,7 +4151,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec!["IO".into()],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Do {
@@ -4170,7 +4192,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec!["IO".into()],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Do {
@@ -4212,7 +4234,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec!["IO".into()],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Do {
@@ -4254,7 +4276,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec!["IO".into()],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Do {
@@ -4296,7 +4318,7 @@ mod tests {
ret: Box::new(Type::unit()),
effects: vec!["IO".into()],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
ret_mode: ParamMode::Own,
},
params: vec![],
body: Term::Do {