Wado

WEP: Resource Ownership — Move-Only Resources, Authoritative Cleanup, and Value-Copy Elision

Context

A Wado resource is an opaque handle to a Component Model (CM) resource. CM already enforces an ownership discipline — no double-drop, no use-after-transfer, no leaked borrow — but dynamically, as a trap, and only at the component boundary. This WEP makes resources move-only and builds the static analysis that turns that discipline into compile-time guarantees plus deterministic cleanup.

The analysis answers one question — is this the binding's last use, or is the value still live afterward? — which also decides, for a copyable value type, whether a materialization needs a deep copy or can transfer storage in place. So value-copy elision is a second client; this WEP records its implementation too.

The move and unique keywords once reserved for this work are unnecessary: the analysis needs move-only semantics, not new syntax (No move or unique keyword).

CM handle types

Handle Meaning
own<R> Unique owning handle; dropping the last own runs the dtor.
borrow<R> Non-owning handle lent from an own; must be dropped before the call returns.

A handle is an opaque i32 index into a per-instance table (or an externref in CM-GC mode). lift_own transfers ownership and traps if the handle is lent out; lift_borrow leaves the source in place; a task returning with a live borrow traps.

Wado today

Decision

Three resource kinds

Kind Backed by Ownership Cleanup
Affine resource a CM own/borrow or waitable handle, or a guest one Move-only + a check resource.drop / dtor
Host-object resource a Wasm GC reference to a host object (no dtor) Value semantics Wasm GC
Non-owning token an index naming something owned elsewhere (no dtor) Value semantics none of its own
Unreclaimed handle an index into a host table nobody frees (no dtor) Value semantics none — it leaks

The dtor decides the kind, not the representation. A handle that owns one must be move-only, because copying it aliases a destructor; without one there is nothing to free, so the handle is an ordinary value. i32-vs-externref is orthogonal to all four rows.

The three dtor-less rows are one linearity, which Resource Inheritance lets a declaration write out: #[cm(..., linearity = "unrestricted")]. The other spelling, "affine", is the default and so goes unwritten. Only the web bindings' resources declare it today; the non-owning tokens still take their value semantics from the absence of a dtor alone.

Non-owning tokens

A non-owning token names something whose lifetime another party already guarantees. Two backings qualify:

A CM borrow<R> cannot stand in for either: it must be dropped before the call returns, where a token is compared long after, is copied across comparisons, and travels inside a struct by value (WaitEvent.handle).

A referent with neither backing — allocated per call from unbounded runtime input — is affine instead: a copyable index would leak it.

Unreclaimed handles

The web bindings' browser handles break the rule above. They are indices into a host table, allocated per call from unbounded runtime input, with no affine owner and no immortal bound, so the rule makes them affine. They cannot be: an upcast in a resource extends hierarchy copies the handle (Resource Inheritance), and an affine handle may not be copied.

They get value semantics instead, and pay for it. Nothing frees them, and each one costs a table slot for the life of the instance. That is a known gap of that WEP, not a fourth way to be safe.

The rest of this WEP concerns affine resources.

Move-only resources

An affine resource is move-only, with no keyword:

Transfer is implicit at the consumption site — there is no move operator, no lifetimes, no exclusivity analysis.

The receiver has exactly three spellings and never a type annotation:

Spelling Meaning CM handle
self by-value move — resource-only own<R>
&self shared borrow borrow<R>
&mut self mutable borrow borrow<R>

self: T and self: &T are rejected. A copyable type has no use for a value receiver (&self plus a *self deref-copy covers it), and a single borrow spelling removes a needless choice. Bare self is legal on a resource or an aggregate that carries one (its consuming method hands that resource off); on a value type that owns no resource it is a diagnostic (value types are not move-only; use &self) — this is how the value-semantics friction is mitigated, not with a keyword. The rule licenses fn drop(self) and the consuming AsyncCall<T> methods.

Because the annotated forms are gone, the receiver kind is carried entirely by SelfKind (Value / Ref / MutRef); SelfKind::None means a genuine non-method, and a call consumes its receiver iff the dispatch resolves to SelfKind::Value.

The move check

resource_move_check.rs gives resources their move-only semantics in one diagnostic pass.

Layer — it runs on the Semantics layer (AST + type facts), a sibling of effect_check::check_semantics, so it has source spans, surfaces in both wado compile/check and the editor, and sees every function. Post-mono TIR was rejected: it has neither diagnostic spans nor a call-site origin.

Analysis — a forward walker over each body (free / impl / trait / test / function-local). Each resource binding is live or moved-at-span; a by-value consumption records a move and a later use is reported. Branch joins union the moves and drop a diverging path's; a loop body is walked twice to catch a cross-iteration move; a let / for-of re-bind and a re-assignment clear state.

It is independent of the value-copy client's last-use liveness (lower/plan/value_copy/last_use.rs, post-mono over TIR); unifying the two is future work, not a present fact.

Done: use-after-move of a bare resource local, by-value self consumption (a bare self receiver moves it, via a consumes_self dispatch fact — the semantic-layer twin of resource_cleanup's owned_self), and whole-move of a resource-carrying struct / tuple / Result, and no-move-out-of-borrow (a &self return rooted in the borrow that carries a resource) — see below.

Extraction is a by-value receiver (Rust-aligned)

Pulling a resource out of a Result / Option returns an owned handle, so the source must relinquish ownership. unwrap(&self) cannot — a borrow keeps the receiver owned, so the returned handle would alias the one still inside the container: two owners of one move-only resource, hence a double-free. The fix mirrors Rust: the consuming accessors (unwrap / expect / unwrap_err / expect_err) take self by value, so res.unwrap() consumes the whole Result and moves the interior out with a single owner. For value (copyable) types this is the usual value-semantics copy — elided to a move at last use — so .unwrap() stays ergonomic and no rejection is needed. Inspectors (is_ok, …) keep &self.

By-value self on a value type is otherwise a diagnostic (SelfByValueOnNonResource); the generic Result<T, E> / Option<T> impls are permitted because a generic self-type resolves to a GenericInstance, not a concrete value type.

No move out of a borrow

A hand-written fn f(&self) -> Resource { return self.f; } (or match *self { … => interior }, or a let-bound projection) moves a resource out of a borrowed place: the borrow keeps the source owned, so the returned handle aliases it — a double-free. resource_move_check rejects it. For each function it seeds the borrowed parameters (&self / &mut self / &T), tracks bindings projected from them (through let, field access, deref, and match-arm bindings whose scrutinee is borrowed), and flags a return whose value is rooted in a borrow and whose type carries a resource. A &self method returning a freshly produced resource (dir.open_at()) stays legal (its return roots in a fresh allocation, not the borrow).

The gate is the concrete return type carrying a resource, so a generic body (T abstract) is not flagged at its definition; the stdlib avoids the issue by taking self by value, and a user-written generic &self extractor instantiated with a resource is the remaining call-site case (deferred).

Authoritative cleanup

resource_cleanup.rs drops every owned, untransferred resource on each fall-through path, structurally for aggregates via a synthesized match. A value is transferred when passed by value, returned, placed in an aggregate, or used as the receiver of a by-value (self) method — so extraction (unwrap) is just a by-value receiver transfer, with no aggregate-shape guessing. Two fixes landed:

The is_resource_aggregate heuristic is retired: cleanup now drops "owned and not transferred at scope exit" directly.

Deterministic drop

An owned, un-moved resource is dropped at scope exit on every path; a move suppresses that drop. An imported handle drops via resource.drop; a guest-defined one runs its destructor (WEP 2026-01-12). An explicit fn drop(self) consumes the receiver, so exactly one drop fires; a &self drop would be unsound (the binding outlives its handle and double-frees). An aggregate containing a resource is move-only and gets a synthesized destructor in declaration order. Panic does not unwind, so a drop is not guaranteed on panic.

CM boundary

Wado position CM handle
by-value R parameter / return own<R>
&R parameter, &self receiver borrow<R>
by-value self (consuming method) own<R> — transfers the receiver

Once the move check proves a by-value resource uniquely owned with no live borrow, CM's lift_own preconditions hold statically.

Value-copy elision (the second client)

A copyable type uses the same last-use question to move (last use → no copy), copy (source still live → deep copy), or share (neither side mutates again → alias), replacing the old "copy-everything-then-elide" scheme. As implemented it is caller-side and single-phase (lower::translate emits $value_copy$T only where it cannot prove move / share / fresh; no elision pass):

A recursive type's $value_copy$T is a true deep copy (a mutually-recursive helper), replacing an identity fallback that silently shared storage. optimize::escape and optimize::value_copy_elide are deleted.

A function that calls itself is assumed to return owned while that is being proved: the fixpoint only adds, so its own call would otherwise read back the verdict being computed and pin it at borrowed. The returns that do not go through the recursive call are still checked on their own, and those base cases are what a recursive result is built from.

What a root owns

Every rule above asks who owns the storage a place names, and answers from the place's root local. A & / &mut local is not that owner: its immutability says nothing about whether the storage is written, its death frees nothing, and a write the owner makes never mentions it. A root reached through a reference resolves to the place it borrows (let r = &a.b answers at a.b); one this body cannot resolve — a parameter, or a reference a call returned — names storage the body does not own and answers nothing, leaving it to sharing.

Sharing

A read-only binding may alias its source's storage when nothing writes that storage while the binding is live. Both halves are readings of one backward walk: what is live at each point, and what each point writes. A write conflicts when the binding is live there and the two places may alias; the same write elsewhere in the body does not. Without liveness a deserializer deep-copies a field it read before any &mut self call runs.

Live means the storage is still readable, not that the local still is. A match arm binding is its scrutinee's storage under a second name, and a let out of a projection is its root's, so the live set at a write is closed over what each live local took its value out of. Reading the local alone calls the storage dead while a binding is still looking at it, which shares across the write.

A break resumes at the loop's exit and a continue at its head, so the two read different live sets. Answering both with the exit set drops every loop-carried reader, and a write reached through a continue then looks unobserved.

Two kinds of write reach different storage. p.f = x points p.f elsewhere, so a reference already taken out of p.f keeps what it has, and only a write inside that storage disturbs it. A repoint is therefore never a conflict for a binding that read the place: the binding keeps its share across it.

*p = v repoints nothing when the referent is an aggregate. The write is expanded field by field into the storage the caller holds, so everything read out of *p sees it. Only a boxed borrow replaces a slot, and one predicate decides which of the two an assignment is.

A move also hands storage on, so a binding the fold will move out of cannot share it. Two reads are such a move: a value read of the binding, and a place-level move of a field out of it. A binding with neither is handed nowhere, so refusing it a share on move-eligibility alone costs a copy that defends nothing.

A share licenses the binding and nothing built out of it. let mut b = a and Wrapper { inner: a } mint a second owner, which outlives the binding and may be written through, so each owes its copy though a shares. Only a move exempts them, and a move is proved of the binding itself, never inherited from what the binding was read out of.

A match over a writable place is hoisted into a temp, and the fold decides that temp's copy for every binding under it. One wrap site answers for the whole arm: the bindings read the temp, so each is as defended as the temp is. A place nothing can write is matched where it lies, its bindings costing nothing.

That temp dies at the match. Reading it alone therefore calls the place it was hoisted out of dead while that place is still live, which moves an arm binding out from under its holder. A source root stands on its own chain, and the whole chain answers instead. The share side always closed its live set that way, and the move side now reads the same closure. Both sides close over places rather than root locals, so two components of one aggregate are not readers of each other.

What a call writes is read off the callee rather than assumed: modref.rs collects each function's writes as fields of the type carrying them and closes them over the call graph, so a read of one field survives a call that writes another. The receiver is one such handle and every other &mut the call is given is another, and the callee's own answer covers them alike. A callee with no body reaches only the &mut arguments it is handed; anything this cannot name writes everything.

Some handles the walk loses: one stored into an aggregate, and one given to a callee with no body. Each writes the whole of what it was handed. That answer is its own, never a field index standing for every field, because a caller comparing paths cannot tell such an index from one naming a real field. It reads the two as disjoint and ignores the very write it was told about. One function mints the key both sides compare (place::field_owner), so what a callee records and what a caller looks up cannot drift.

A &mut that outlives the expression taking it (stored, captured, or kept by the callee's return) writes its root wherever its holder runs, which no point in the walk names. So no binding shares storage read out of such a root. The optimizer's alias query answers the same way: a write through a dereferenced field, payload or element reaches whatever the reference was taken from.

An Index or a Variant step names no type of its own, so a Field past one answers for the element rather than for the container the caller holds. A path reached through either writes the whole of what its root was lent. Keying it by the element's field alone leaves a caller holding the container nothing to match, and it shares storage the call goes on to write.

A path through a reference-typed field places nothing: it ends at storage its root only borrows, so the root names the holder rather than the storage. A read taken through one copies: the write that reaches its storage is rooted elsewhere, where the root-keyed scan never looks. Two reference fields of one root may name one storage as well, so splitting a read and a write at them proves nothing. A write made through one is placed no better, and conflicts with every binding live at it.

A reference bound out of such a field names no referent this walk can follow: the field holds the reference, and the reference points elsewhere again. The immutable-root rule abstains over one rather than answering about the holder.

Handing the source root to a new owner costs the binding its share on the same terms. That owner may write what the binding aliases, so the read matters where the binding's storage is still read afterwards, and nowhere else.

A List or a String copy right-sizes its backing storage to the current length, which a share skips. Capacity is not part of the value, but capacity() reads it, so a binding whose own capacity is read keeps its copy. One that never asks observes no difference and shares.

Every analysis here asks one resolver what an expression names (place.rs). Its answer separates a value of its own from a place the walk cannot follow, so a shape no arm covers costs an elision rather than becoming a write nobody records.

Which helpers exist

$value_copy$T is additive synthesis, so the helpers are created in plan — before the fold that decides where to call them, and before pattern lowering mints the temps some of those calls land on (WEP 2026-05-11). The seed must therefore be complete without predicting what a later pass writes: it reads the types a program declares, since no expression rewrite introduces a type the program did not already name. Over-synthesis costs nothing — dce removes an unused helper — while a miss leaves the fold no helper to call.

No move or unique keyword

Move-only semantics need no syntax: transfer is implicit, a use-after-move diagnostic replaces a move operator's local visibility, and the consuming receiver is spelled bare self (scoped to resources, so it never contradicts value semantics — see Move-only resources). unique as a struct modifier only matters for a move-only type that carries no resource — a resource-bearing aggregate is already move-only by composition, and no use case has appeared. Current state: neither move nor unique is a keyword. If revived, unique struct reuses the same move-check machinery, resources being its first client.

Amendments to earlier WEPs

Consequences

Roadmap

Verified against the tree.

Move check

Cleanup

Receiver grammar (self / &self / &mut self)

Consuming drop

Value-copy client

Known gaps

The release that lets a binding leave the function

let v = self.held; self.held = null; gives v what the place held. In principle v may then be handed to a new owner with no copy, which is the take / drain / snapshot idiom. The compiler copies instead.

A repoint of the place is not the proof this needs, for two reasons.

It says nothing about when it runs, so a repoint under an if that never executes would license the elision. value_copy_release_is_not_a_proof is that program.

It says nothing about a second binding read out of the same place, where a write through one is observed through the other. value_copy_new_owner_needs_a_proof is that one, and this half is answered: a hand-over is refused when another chain reaches the same place (value_copy_two_readers_of_one_place). That states the sibling case as a rule instead of counting bindings.

The first half stands. The backward walk records a live set per write and no position, so it cannot say today which repoints dominate a site.

A returned projection behind a let

projection_param matches a syntactic deref / field / index / payload / cast chain rooted at a parameter. Returned as written, such a chain is handed out as a borrow, inside a variant construction too: return Option::Some(h.item) copies nothing. Bound by a let first, it is not recognized. let item = h.item; return item; returns owned, and the fold deep-copies h.item into item.

SliceValueIter<T>::next has that shape:

let item = builtin::array_get_value(self.repr, self.index);
return Option::Some(item);

So every by-value for x of list over a List of aggregates pays a copy of each element, even when the loop body only reads it. The element is also reached through the borrowed field repr, which the Roadmap item "Follow a borrowed field back to what it borrows" covers. Nothing about the copy is required: the same walk written over &list, or as an index loop in one body, reads the element in place under the read-only share.

Neither the inliner nor any NIR pass can stand in: the copy is chosen before NIR exists, and #[inline(always)] on next leaves the expanded clone in the caller's loop untouched even with the cloned array provably unread.

A field moved out of a live aggregate

A place-level move out of an aggregate happens at a struct or tuple literal, for a value type that owns no resource. Moving a field out of an aggregate that is still live, or through a deref or an index, copies.

A declared retention the walk does not confirm

Only a body-less declaration carries retention now (Value Semantics and Reference Retention), and it has no body for the walk to confirm it against. A #[retain(p)] is taken at its word on both channels, so a caller stays conservative whether or not the declaration was right; what nothing can catch is a declaration that understates what the callee keeps.

carries drops at a projection whose type only holds a reference — w.h where Held { r: &Rep } — since the arm keys on the projection being a reference itself. A parameter that only holds one is seeded, through RefCarrying: the memoized "can a value of this type hold a reference" predicate, which is what keeps List::push(Sink { r: &it }) retaining it now that no frontend obligation makes the caller declare it. The projection arm is still the narrower reading: the walk loses a reference at the first projection rather than following it through an adapter chain.

A generic resource a user module declares

The move check reads the type a binding resolves to, and a generic resource a user module declares resolves to no resource type at all. Stream<u8> and Future<T> resolve to GenericResource and are checked; a program's own resource Handle<T> does not, so consume(h); consume(h); on a Handle<i32> compiles with no diagnostic while the same code on a non-generic Handle is rejected.

Nothing can be built through the gap today: a #[cm] resource a user module declares has no import binding, so no program can obtain such a handle.

A Stream or Future handle nothing drops

"Deterministic drop" says an owned, un-moved resource is dropped at scope exit on every path. Cleanup does not do that for GenericResource, meaning Stream<T> and Future<T>: it excludes them, so every path out of a function holding one has to call drop itself, and nothing diagnoses a path that does not. The handle leaks with no trace.

A ? or an early return inside the region holding the handle is the shape that admits it. core:fs drains the stream and drops before it decides what to return. core:kiln lends the handle to a helper, so the body that owns it cannot return early at all.

What cleanup does not reach

Cleanup walks function bodies only. A resource a closure body binds, or a temporary it borrows, is never dropped.

Moving one resource field out of a temporary aggregate (make().a) drops nothing else the temporary owns, so a second resource field leaks.

A panic drops nothing the program owns.

A destructured field's path is assumed to borrow

A pattern-destructured field's path is marked as borrowing, because the pattern carries no type saying whether that field does, and the mark refuses those paths a share outright. Every WIR golden is byte-identical with the mark removed, so nothing measured pays for it.

Freshness does not read the fold's own wraps

A copy hands its target storage nothing else reaches. Ownedness is computed from a local's source before any wrap site is chosen, so a wrap the fold has just decided does not count as making its target fresh.

No program reaches the imprecision. For a second read to pay a copy its move must be refused, and a copied local aliases nothing, so only a read that is not the last one refuses — and there the second copy is a second live object, which is needed.

Some resource holders are not move-checked or dropped

Every value holding an affine resource is move-only and dropped, but only a struct, a tuple and a Result are checked. An Option, a user variant or a List holding one can be moved twice with no diagnostic, and is not dropped at scope exit.

A resource field moves out of its holder

let c = h.c; over a struct h holding a resource compiles, and h stays usable. Moving h afterwards leaves two owners of one handle.

An integer casts to an affine resource

5 as Counter compiles for an affine Counter in any module. The result is a handle nothing minted, which the program then owns and drops.

References