Wado

WEP: Reference Representation and Mutation Write-Back

Context

Wado has no raw pointers and no borrow checker. A reference (&T / &mut T) is always a GC-managed handle (see Value Semantics and Reference Retention). How that handle is represented on Wasm GC has never been written down, even though the choice differs by referent type and is what makes mutation through a &mut observable at the original place.

This WEP specifies the intended representation and &mut semantics, and catalogs where the current implementation diverges. The divergences are bugs to fix so the implementation conforms — not behavior to preserve. The motivating symptom is a silent miscompile (issue #1333): a write through &mut xs[i] or &mut s.f of certain element/field types is discarded with no diagnostic.

Design (normative)

The dividing line: in-place mutation vs replace-on-assign

A &mut T must let a write be observed at the original place. The representation follows from how the value is mutated through the reference:

The axis is mutation mode, not "scalar vs heap". A variant is a heap GC struct yet is replace-on-assign (see below). Mutability (& vs &mut) is a type-level distinction, not a representational one: a reference to a replace-on-assign type is Box<T> whether & or &mut.

Since both collapse onto one wrapper, the resolved type no longer says which is which. The rewrite records the shared-&T origin separately, and anything asking whether a boxed handle can be written through MUST consult that record, not the type. A Box<T> parameter is the caller's storage, not a copy of it: reading it as a by-value struct snapshots an aliased reference and loses the write.

Classification of types

Category Types &T / &mut T Mutation through the reference
In-place (reference types) struct; List<T> (raw GC Array<T>); String; i128 / u128 shared GC handle field / element write on the shared object
Replace-on-assign (value/boxed) primitive (except i128 / u128); enum; flags; variant; fn / fn mut Box<T> *r = v replaces the box's content
Handle resource (opaque i32; conceptually i32 / externref) see divergence D6 replace-on-assign conceptually

Notes on the non-obvious entries:

Connecting a &mut to a place: the box must be the place's storage

For a replace-on-assign type, a write through &mut is observable at the original place only when that place's storage is the box cell. The compiler establishes this for locals via the address-taken-locals boxing pass:

let mut c = Color::Red;
let r = &mut c;   // c's slot promoted to Box<Color>; r shares that box
*r = Color::Blue; // writes the box
code(c);          // reads c (auto-unboxed) -> Blue

A non-local place — a List / array element xs[i] or a struct field s.f — has no local to promote.

The boxed-as-value set is a single predicate

The set of types boxed by reference (the replace-on-assign category) MUST be defined by one predicate (e.g. is_boxed_reference_target(T)), and the boxing pass, the forbid rule, and the carve-out below MUST all consume that predicate. No component re-lists the member types by hand; that is how fn and flags already drifted out of sync (see D2– D4).

&mut to a non-local replace-on-assign place

In-place places — &mut <local> of any type, and &mut of a struct / List / String reference mutated in place — are always allowed and unaffected.

What the two rules above rest on:

Decision

Each carve-out narrows the forbid for the case it handles; the escaping case is never carved out.

Known implementation divergences

These are gaps between the design above and the current tree. Each is a bug to fix to conform; none should be preserved.

Where the callee does replace or store, all but the &mut self receiver are refused rather than dropped: that code was already losing the write.

Two sinks are refused wider than the rule asks, neither gateable on a whole-value write the way a let is. Rebinding (r = &mut b.item) is refused even where nothing replaces through r, since the detached-local set is filled in visit order and an assignment need not dominate its reads; gating it takes that set as a fixpoint before the walk. A break carrying one is refused because a labeled block's value is read from its tail alone.

The write-back stores the temp back only when the callee replaced it — the temp aliases the place, so an unconditional store would also undo a write the callee made through another route to the same place (self, a sibling &mut argument), which lands on its own.

A store back is still late for a route the callee takes after replacing. Until the store back runs, the new value is in the temp alone, so another route reads the old value and has its own write undone. So a call whose callee replaces the temp is refused where another argument reaches the same storage. That argument may be a borrow on the same root, the root itself where the root is a reference, or any reference once the root is borrowed anywhere in the body. Two reference parameters the caller passed the same storage through are not seen: f(a, &mut b.opt) inside g(a: &mut S, b: &mut S), reached as g(&mut s, &mut s), still stores back over a's write.

Two costs come with running the refusal after monomorphization, where the types are concrete enough to see a variant behind a type parameter:

Consequences

Positive

Negative

References