Skip to content

Memory Model

How memory is organized in q64. Regions as the unifying abstraction; the dual linear / managed heap; cross-region transfers; the multi- memory layout; how @send is derived; and how shared state lives in SAB-backed regions.

Status: draft (v0). Ports the design discussion in q64-lang/design’s memory.md into spec form, then pins down three open calls (shared-region declaration via @shared, managed via @managed, cross-region transfer via a generic transfer(…)) that the narrative doc left as block-style sketches.

Design goals

  1. Two heaps, both first-class. Linear memory (allocator-managed, pointer-based) and WasmGC references (engine-collected) coexist. Neither is “the safe one” and neither is “the unsafe one.”
  2. Regions are values with lifetimes. Every allocation belongs to a region; the region owns the bytes; the region’s lifetime bounds the value’s lifetime.
  3. No hidden cross-heap moves. Linear → managed and managed → linear conversions are explicit, named, and always copy.
  4. The platform layout is queryable, not surprising. Region kinds map to distinct Wasm linear memories (mem.stack, mem.arena, …) so failure modes and diagnostics localize to the right backing memory.
  5. AI-agent friendly. Region kind, sharedness, and managed-vs- linear status are all grep-able at declarations.

Vocabulary

WordMeaning
regionAn allocator with a lifetime and a policy. Values “live in” a region.
region kindArena / Pool / Stack / Free-list / Managed — the strategy backing a region.
linear memoryPointer-based Wasm memory (allocator-managed; freed by region exit).
managed memoryWasmGC references (engine-collected; per-Wasm-instance).
@sendDerived predicate: this type’s ownership may cross a thread boundary.
shared regionA region whose backing memory is SAB-shared across threads.
transferThe single generic cross-region/cross-heap copy primitive (see §Transfers).

The platform: Wasm 3.0, dual address space (wasm32 / wasm64)

q64 targets core Wasm 3.0. Two of its features — Memory64 and Table64 — define the address space, and that is a per-build choice, not a default. Every build is compiled for exactly one address space:

Address spacePointersMemory64 / Table64Runs on Apple WebKit (Safari + every iPad/iOS browser)
wasm32i32offyes — the universal baseline (linear memory ≤ 4 GiB)
wasm64i64onno (as of 2026) — unlocks > 4 GiB on capable hosts

There is no default address space. A build must name one explicitly — via the target’s addressSpace, or the --addr flag (see qube.json5.md §Targets and q64-cli.md / qube-cli.md); q64 errors if neither resolves one. The rationale is compatibility: Memory64 is not implemented in Apple’s WebKit, so a silent 64-bit default would emit qubes that cannot run on iPad/iOS — the very floor the ecosystem targets. Forcing the choice makes every qube declare where it can run, rather than discovering it at runtime.

The remaining Wasm 3.0 features q64 builds on are independent of the address-space choice and present in both modes:

  • Multiple memories — a core module declares several linear memory instances; q64 uses this to segregate region kinds.
  • WasmGCstruct.new, array.new, engine-managed references.
  • Threads + atomics — SAB-backed shared linear memory (browser: gated on COOP/COEP cross-origin isolation — an opt-in upgrade, not the floor; see §“Concurrency platform audit”).
  • SIMD — 128-bit vector ops.

Not on the floor: stack-switching. Earlier drafts assumed lightweight coroutine stacks (the typed-continuations proposal). The concurrency platform audit found no engine q64 targets exposes it (wasmtime 45 has no C-API toggle; no browser ships it to stable as of 2026-06). The v0 concurrency floor is a single-threaded cooperative scheduler instead.

These choices compound. The dual-heap model is what the platform gives us, not a compromise. The address-space split is the one place the platform is not uniform — so q64 surfaces it rather than hiding it: it shows up in the target, the build-output path, the synthesized manifest, and (downstream) in how a deploy host such as qubepods stores and serves the matching artifact.

Address-space negotiation

A qube that publishes both a wasm32 and a wasm64 build is served by capability, not by guesswork. The contract:

  1. The glue code probes the engine. The loader — the browser host adapter (runtime/browser/host.js), or the gate/runtime in front of a deployed qube — tests for Memory64 support before fetching a module. The cheap, side-effect-free probe is to validate a tiny module that declares a 64-bit memory, e.g. WebAssembly.validate(<(memory i64 1) module>); a true result means the engine can run wasm64. (Apple WebKit returns false as of 2026.)
  2. The glue code requests the right build. It asks the store for wasm64 only when the probe passed; otherwise it asks for wasm32.
  3. The store/registry falls back to wasm32. If the requested build is missing — or the client sent no capability hint, or the hint is unknown — the serving layer returns the wasm32 artifact. wasm32 is the universal safe fallback: every engine can run it, so no client is ever left without a runnable build. The only unrunnable case is a qube that published wasm64 only and a WebKit client — which is why the toolchain and manifest flag a wasm64-only qube as not-runnable-on-WebKit.

This keeps the negotiation simple: clients never parse modules to discover the address space, the store never has to guess, and wasm32 is always the answer when anything is uncertain. The compiled artifacts themselves are not stored in the Continuum (it holds source archives — see continuum-api.md); the dual-build store + per-request selection live in the deploy host (on qubepods, the artifact store keyed by the QubePod manifest’s component.variants).

Concurrency platform audit (the stack-switching reckoning)

The address-space split above came from probing one feature (Memory64). The same discipline applies to the features the concurrency and stream layers lean on. The headline finding: q64’s concurrency design was written assuming stack-switching (lightweight coroutine stacks / the typed-continuations proposal), and no production engine exposes it on the floor q64 targets. The audit below is the basis for the v0 concurrency floor.

Support matrix available, absent, available with a caveat. The wasmtime column is probed against the vendored engine (vendor/wasmtime, 45.0.0, via the C-API wasmtime_config_wasm_* toggles it exposes); the browser and wasmer columns are from engine release notes / known status as of 2026-06 and should be re-probed with the live WebAssembly.validate script (below) before any host is declared supported.

Feature (proposal)WebKit — Safari/iPadChrome / V8Firefoxwasmtime 45wasmer
Tail calls
Exception handling
WasmGC✓ (Safari 18.2+)
Threads + atomics + SAB⚠ (COOP/COEP)⚠ (COOP/COEP)⚠ (COOP/COEP)
Stack-switching (no C-API toggle)

Notes:

  • Stack-switching is the blocker. wasmtime 45 ships config toggles for tail-calls, exceptions, GC, function-references, and threads, but no wasm_stack_switching toggle — its only stack mechanism is host-side fibers (async / component_model_async_stackful), which is not the guest-visible cont/resume/suspend instruction set. No browser ships the typed-continuations proposal to stable (Chrome’s JSPI is a different, JS-Promise–shaped feature, not general guest stack-switching). A concurrency runtime that needs guest stacks to suspend mid-function therefore cannot run anywhere q64 ships today.
  • Threads need cross-origin isolation in the browser. SharedArrayBuffer (hence wasm threads/atomics) is gated on COOP/COEP headers. qubepods can serve them, but it is a deployment constraint, and iPad Safari is the weakest link — so threads are an opt-in upgrade, never the floor.
  • Everything else q64 needs (tail-calls, EH for the panic lowering, GC for Managed) is broadly available; wasmer’s GC support is the laggard.

Re-runnable probe. Mirror the Memory64 probe — WebAssembly.validate a tiny module per feature, log engine + version + date, in each browser and via each native host. (Script lives with the runtime adapters; this spec records the decision, not the harness.)

The v0 concurrency floor (normative)

Because stack-switching is unavailable on the floor — and WebKit/iPad is the declared compatibility floor — q64 v0 does not use guest stack-switching. The v0 floor is a single-threaded cooperative scheduler:

  • One scheduler, one thread. Tasks (spawn, graph stages, actor message turns) run on a single wasm instance with no shared-memory parallelism. This preserves the one-scheduler invariant of concurrency-model.md on every host.
  • Suspension only at statically-known boundaries. A task may yield only where the compiler already sees a suspension point — an await, a channel send/recv, a host-call that returns a future. The front end knows these from the effect system, so the lowering is a CPS / state-machine transform of the suspendable functions (only functions whose effect signature can suspend are split), not a whole-program asyncify pass.
  • No mid-function preemption. A CPU-bound loop with no suspension point runs to completion before the scheduler regains control — the same contract as a JS event loop or a single-threaded async runtime. Acceptable for v0; cooperative by construction.

Why this floor over the alternatives the audit weighed:

  1. (rejected) “require a capable host.” Would exclude iPad/WebKit — violates the compatibility floor.
  2. (rejected) asyncify the whole program. Binaryen can, but it taxes every function with code-size and call-overhead even where no suspension happens; the effect system lets us transform only the functions that can actually suspend.
  3. (chosen) cooperative scheduler, selective CPS lowering. Runs everywhere with no stack-switching and no SAB/COOP-COEP requirement, and the suspension points are exactly the ones the type/effect system already identifies.

Upgrade path, not a redesign. True parallelism (threads + atomics, where COOP/COEP and host support allow) and guest stack-switching (if/when it ships broadly) are opt-in upgrades behind the same scheduler abstraction — a capable target may lower spawn to a real worker thread. The surface language and the one-scheduler model do not change; only the task-resumption mechanism does. This is the concurrency analogue of the wasm32/wasm64 per-build choice: the floor is universal, capability unlocks more.

Region kinds

KindAllocatorFree strategyConcurrencyTypical use
Arena<N>bump pointerall at region exitsingle-task onlyper-request, per-frame, per-tick
Pool<T, N>fixed slot indexindividual or at exitlock-free with reservationmessage queues, frame pools
StackLIFOimplicit on returnstrictly per-tasklocal temporary buffers
FreeListmalloc-styleindividualsingle-task or lockedgeneral-purpose dynamic memory
Managedengine (WasmGC)engine, asyncper-Wasm-instancelong-lived graphs, cyclic structures

Each kind is a concrete type fitting the auto-prelude face Region. Generic code over “any region” uses the R: Region parameter (per generics.md §“The four parameter kinds”).

The Region face is the compiler-blessed marker for “this type is a region kind.” It carries no user-visible methods in v0; user code never writes fit MyKind : Region, because the six blessed kinds (Arena, Pool, Stack, FreeList, Managed, plus the Interned<R> target marker) are the only inhabitants. The face exists so R: Region generic bounds and the region <name> : <kind> { … } declaration form share a single name in the type system. Adding new region kinds is a language-level change (tracked under “Open items deferred”).

The word “Region” therefore has three roles, easy to confuse:

UseMeaning
Region (face)The auto-prelude face every region kind fits. Used in bounds (R: Region).
region kindOne of Arena<N>, Pool<T, N>, Stack, FreeList, Managed. A concrete type.
region bindingThe named value introduced by region <name> : <kind> { … }. Lives in scope.

The rest of this spec uses “the Region face,” “a region kind,” or “a region binding” — never bare “region” — when the distinction matters.

Frame reclamation (v0): Stack discipline on the implicit arena

The v0 compiler allocates from one implicit scope arena — a bump pointer (sp) in linear memory, starting past the static data. Named region bindings are future surface; until they land, reclamation comes from giving call frames the Stack kind’s semantics (LIFO, implicit free on return) layered on that arena, as a caller-side calling convention:

  1. Watermark. Every call site saves sp into a local before evaluating the call’s arguments. Everything allocated from that point — argument temporaries, the callee’s entire frame, the frames of everything it calls — sits above the watermark.
  2. The result slides down. After the call returns: a scalar result needs no memory, so sp is simply restored to the watermark. An aggregate result (a str’s bytes, a record, a boxed enum) is copied down to the watermark (memory.copy — memmove semantics, so the overlapping downward slide is safe; record results align the watermark up to the value’s alignment first), the result pointer is rebased onto it, and sp becomes watermark + size. The frame dies; exactly its result survives, now inside the caller’s live region.
  3. Host statements reset. A host-call statement (env.out(…), qview.*) consumes its bytes before it completes, so the entry’s statement wrapper restores sp to its own watermark afterwards — a formatted-print loop runs in constant memory.

The convention is correct without escape analysis: results are slid unconditionally (re-copying a constant or a passthrough parameter is wasteful, never wrong — str and record results are immutable values at the seam). Compiler analyses may skip provably-redundant slides; they can never be required for correctness.

Pinned boundary — interior pointers. The flat slide is a complete deep copy only while aggregates contain no pointers, which is true of the v0 floor (scalar fields; enum payload slots are scalars; str values are (ptr, len) pairs held in locals, never stored inside aggregates). The day a pointer-bearing field lands (a str struct field, a nested-struct reference), its type’s returns must either slide recursively (the compiler knows the layout) or allocate into the caller’s frame from the start. That decision rides with the feature that introduces such a field; until then a pointer-bearing aggregate return is a compile error, not a silent shallow copy.

Known leaks at this floor (reclaimed only by an enclosing frame’s exit, or never in main): a binding re-assigned each loop iteration abandons its previous bytes; concat temporaries feeding a let (not a host statement) in main. Per-iteration resets via the existing escape scan, and explicit scope { } resets, are the recorded next rungs.

Region literal syntax

A region declaration introduces a named region whose lifetime is the enclosing block:

region rt: Arena<1.MB> {
let buf = Vec<u8>.new(rt)
process(buf)
} // rt and everything in it freed at the closing brace

Form: region <name> : <RegionKind> { <body> }. The body sees <name> as a value of type <RegionKind>. At the closing brace:

  • Arena / Pool / Stack regions: bytes returned to the parent memory (or the OS, for top-level regions).
  • FreeList regions: any still-live allocations cause REG040 (“region exited with live allocations”).
  • Managed regions: the GC handle is dropped; the engine collects on its own schedule.

Nested regions inherit cancellation and panic-unwind behavior from their enclosing scope (per concurrency.md).

Scope’s implicit arena

Every scope { … } block (per concurrency.md) carries a default Arena-kind region binding named scope. Code that does not declare its own region uses this implicit one:

scope {
let buf = Vec<u8>.new() // allocates in scope's arena
process(buf)
} // arena freed here

Functions that don’t take an explicit R: Region parameter allocate into scope by default.

Terminology. “The scope arena” (used in errors.md, effects.md, concurrency.md, streams.md) is the value of this implicit binding at the nearest enclosing scope { … }. It is always an Arena<N> kind. Panic payloads allocate here (per errors.md §panic and trap); stage bodies allocate here unless they name a different region. The binding shadows lexically: a nested scope { … } introduces a fresh scope binding whose lifetime is the nested block.

The two heaps

Linear memory:

  • Carved up by allocators (Arena / Pool / Stack / FreeList).
  • Pointer-based; pointers are i32 (wasm32) or i64 (wasm64), per the build’s address space (see §“The platform”).
  • Explicit lifetime; the owning region frees the bytes.
  • Lowers to Wasm linear memory.
  • Can be SAB-shared (see §Shared regions).

Managed memory (Managed kind):

  • WasmGC references, opaque to user code.
  • Engine-collected.
  • Lowers to Wasm GC types (struct.new, array.new, ref types).
  • Per-Wasm-instance (per-Worker in the browser). Cannot leave its thread without a transfer.

The heaps are disjoint at the platform level. Wasm 3.0 provides separate instructions and separate memory areas. q64 surfaces the boundary; there is no implicit pointer-to-reference conversion.

Linear struct layout

How a struct value is laid out when it lives in linear memory (any non-Managed region). Managed structs lower to WasmGC struct.new and have no byte layout. The rules are deliberately C-like and stable: layout is part of the platform contract (q64 show layout <type> prints it), never a surprise.

  1. Declaration order is layout order. Fields are placed in source order; the compiler never reorders. (Predictability over packing — a developer who wants a tighter struct reorders the declaration.)

  2. Natural size and alignment per field type:

    Field typeSize (bytes)Alignment
    bool, i8, u811
    i16, u16, f1622
    i32, u32, f3244
    i64, u64, f6488
    ref T, pointer componentsaddress-space width (4 on wasm32, 8 on wasm64)same as size
    nested structits own sizeits own alignment
  3. Each field starts at the next multiple of its alignment after the previous field’s end (padding bytes are unspecified, not zeroed). A nested struct field embeds its bytes inline.

  4. Struct alignment = max field alignment; struct size is rounded up to a multiple of the struct alignment. An empty struct has size 0, alignment 1.

  5. Allocation is aligned. A region allocates a struct at an address that is a multiple of its alignment (the arena bump pointer rounds up before allocating).

Field access compiles to a load/store at base + constant offset. Only pointer-bearing fields make a layout address-space-dependent; a struct of scalars has identical layout on wasm32 and wasm64.

Values vs. addresses. A record binding whose value never escapes (no whole-value use: not passed, returned, nested, or referenced) may be scalarized by the compiler (SROA) and never materialize in memory. The layout above governs every value that does materialize: passed or returned whole, stored in another struct, or referenced via ref.

Status (impl): v0 lowers escaping record values into the scope arena (no reclamation yet), passes and returns them as a single pointer at the build’s address width, and supports i64, f64, f32, the narrow integer widths (u8/i8/u16/i16/u32/i32 — storage-only: width-true loads/stores, in-range literal inits, arithmetic via explicit i64(x) widening), and bool fields; nested structs, str/ref fields, and q64 show layout are the next slices.

Enum representation (v0)

An all-unit enum (every variant payload-free) is immediate: a value is its variant’s declaration-order tag, an integer at the compute floor (i64). No memory is touched.

An enum with any tuple-payload variant is boxed: a value is a linear record in the active region (the scope arena in v0) laid out as

offset 0: tag (i64 — the variant's declaration-order index)
offset 8·(i+1): p_i (the i-th payload slot)
size = 8 · (1 + max payload arity over all variants)
align = 8

Every value of the enum — including its unit variants — shares this one shape, so match always reads the tag at offset 0 and a variant’s payload slots are at fixed offsets. Slots beyond a variant’s arity are unwritten (never read: pattern arity is checked against the variant).

Status (impl): v0 boxes payload enums with up to four i64 payload slots (tuple form); record-form payloads, non-i64 payload types, and a niche/packed representation are later slices.

Marking a struct managed

A @managed annotation marks a struct as living in WasmGC memory:

@managed
struct GameState {
entities: Vec<Entity, Managed>,
quadtree: ManagedBox<Quadtree>,
}

Effects of @managed:

  • The struct may only be allocated via the Managed region’s constructor Managed.box(value) -> ManagedBox<T>, which registers value as a fresh WasmGC root and returns a handle. There is no separate “shared managed” constructor in v0; cross-thread sharing of managed values requires transfer(to: <thread-local Managed>) on the receiving side (§“Cross-region transfers”).
  • Its fields must themselves be managed or be Copy-style value types (numerics, bool). A ref into linear memory is TYP060-equivalent in this spec: REG020 (“linear pointer in managed struct”).
  • The struct is not @send, regardless of its field composition: WasmGC roots are per-Wasm-instance. See effects.md §“Annotation carve-outs”.

This supersedes the managed struct … keyword form sketched in design.md. The annotation pattern matches @derive and other declaration-level annotations from faces.md.

Region parameters in types

Allocating types take a region as a type parameter (per the generics spec). The default is the enclosing scope’s implicit arena (§“Scope’s implicit arena”, above), spelled scope:

Vec<T, R: Region = scope> // growable array
Map<K, V, R: Region = scope> // hash map
Set<T, R: Region = scope> // hash set
String<R: Region = scope> // owned string
Box<T, R: Region = scope> // single-value box
Bytes<R: Region = scope> // owned byte buffer; alias for Vec<u8, R>

The names Vec, Map, Set, Box, Bytes, String, ManagedBox, Atomic, and Shared are in the language auto-prelude (per modules.md §“The auto-prelude”). Their concrete fits live in the stdlib qube q64.collections (compiled together with the language so the prelude can re-export them), but no import is required at the user side.

R is a compile-time identity that names a specific region value, not a region kind. Vec<i64, arena_a> and Vec<i64, arena_b> are different types; you cannot move data between them without a transfer(to: …).

What “default region” means

The default R = scope resolves at each use site to the value named scope in the enclosing block’s lexical scope: the implicit arena introduced by the nearest scope { … } (or the function body’s top-level scope) per §“Scope’s implicit arena”.

fn outer() {
let a: Vec<i64> = Vec.new() // a's R = the outer scope's arena
inner_a(a)
scope {
let b: Vec<i64> = Vec.new() // b's R = the inner scope's arena
inner_b(b)
} // b's arena freed here
}

a and b are not the same type: their R parameters refer to different region values. A function that takes v: Vec<i64> without naming R introduces an anonymous generic <R: Region> per generics.md §“Implicit face parameters” — the caller passes a Vec from any region, the callee is monomorphized per call site.

The Zig-style “thread the allocator through” escape hatch lets libraries name R explicitly when they need to return a value allocated in a caller-supplied region:

pub fn build_index<R: Region>(r: R, terms: [Term]) -> Index<R> {
let idx = Index<R>.new(r)
for t in terms { idx.insert(r, t) }
idx
}

Constructor calls with the default

A constructor like Vec.new() (with no r argument) allocates into the binding-site’s enclosing scope arena. The desugaring is uniform: Vec<T>.new()Vec<T, scope>.new(scope), where scope on the RHS is the value of the implicit-arena binding. A constructor called from a function that has named a region parameter (fn f<R: Region>(r: R)) and supplies it explicitly — Vec<T, R>.new(r) — bypasses the implicit-arena sugar.

Methods that grow a collection follow the same rule. The Collection.push(self: ref Self, x: T) signature takes no r parameter when the collection’s R is fixed at construction — each push allocates into the region the Vec was originally built with. Libraries that want to allocate into a different region per push must use the explicit form Collection.push_into(self: ref Self, r: R2, x: T) and accept the resulting transfer cost.

Cross-region transfers

The generic transfer primitive moves a value from one region to another. Source and target kinds determine the semantics:

// Conceptual signatures, parameterized on the source's static type.
// The compiler synthesizes one specialization per source-region kind.
fn transfer<R2: Region>(self: Vec<u8, R1>, to: R2) -> Vec<u8, R2>
fn transfer<R: Region>(self: str, to: R) -> String<R>
Source kindTarget kindEffective semantics
LinearLinearDeep copy. Source remains valid in its region.
LinearManagedDeep copy + register as GC root (formerly “pin”).
StaticLinearCopy bytes into the target region.
StaticManagedCopy + register as GC root.
ManagedLinearDeep copy out of GC into the linear region.
StaticInternedDeduplicate; register canonical instance.

Interned is a target marker (not a region kind, strictly) that selects deduplicating semantics: the target region keeps a hashtable keyed by content hash, and transfer(to: Interned<R>) returns a handle to the canonical instance — the second transfer of an equal value re-uses the first allocation. It composes with Managed: "hello".transfer(to: Interned<Managed>) deduplicates inside the WasmGC heap. Hashtable lookup after the first allocation is @pure (no further alloc).

The only verb is transfer(to: …). copy_to, pin_to, and intern are not in the language; the compiler rejects them as unknown verbs (REG050).

Examples

region arena1: Arena<1.MB> {
let a: Vec<u8, arena1> = capture_buffer()
region arena2: Arena<2.MB> {
let b: Vec<u8, arena2> = a.transfer(to: arena2) // deep copy
send_to_thread(b.transfer(to: shared_pool)) // to a SAB pool
}
let c: ManagedBox<Vec<u8>> = a.transfer(to: Managed) // pin into GC
}

The cost is visible: every cross-region step is a named call. An @realtime stage cannot perform a transfer with unbounded size (per effects.md) — EFF110.

Lifetime tracking

Linear regions have explicit lifetimes; the compiler checks that values do not outlive their region. Rules:

  • A region’s lifetime is the region <name> : … { … } block (or the enclosing scope’s arena for the implicit case).
  • A value’s lifetime is bounded by its region’s lifetime.
  • A function cannot return a value allocated in one of its local regions. The compiler diagnoses this at the return site as REG010.
  • A function can return a value whose region was supplied by the caller (the standard Zig pattern):
pub fn parse<R: Region>(r: R, input: str) -> Tree<R> {
Tree<R>.new(r, ...) // Tree allocated in caller's region; safe to return
}

Managed types have no manual lifetime; the GC handles it. They are not constrained by scope boundaries but cannot escape their Wasm instance (their thread).

Multi-memory architecture

Status (impl): not yet implemented — aspirational target. Codegen emits a single linear memory today; the region kinds below are byte-offset regions within it (the bump-arena sp global, etc.). Multi-memory segregation is a deferred non-goal for now. Bulk-memory ops are enabled.

Wasm 3.0’s multiple-memory feature lets q64 segregate region kinds into distinct linear memory instances:

MemoryBacking kindsNotes
mem.stackStackTask stacks, one growable region per virtual thread.
mem.arenaArenaPer-scope bump arenas.
mem.heapPool, FreeListPer-task pools and free-list regions.
mem.sharedshared regionsSAB-backed; the only memory that pays SAB cost.
mem.largeany kind, > 1 MB allocsFragmentation isolation.
mem.rodataStatic / interned dataRead-only; engine enforces.

Benefits:

  • Failure isolation. A stack overflow doesn’t corrupt arenas; a free-list bug doesn’t taint shared data.
  • Sharing cost paid where it’s needed. Only mem.shared is SAB-backed; thread-local memories don’t pay SAB overhead.
  • Diagnostics localize. Stack traces, heap dumps, and allocator reports name the originating memory.

User code never names a memory directly. The multi-memory layout shows up in q64 show memories (per q64-cli.md) and in diagnostics.

@send derivation

@send is a type-level predicate. The derivation rules and the q64 show send <type> introspection are normatively specified in effects.md §“The @send story”; this section records only the region-level inputs the derivation consumes:

  • Linear regions are single-owner — a value allocated in Arena / Pool / Stack / FreeList is @send if and only if its T is @send and the region’s handle is itself @send (i.e., the region is not borrowed by a task other than the current one).
  • Shared regions (@shared structs, backed by mem.shared) are sharable — their handle is @send by construction, so Vec<T, R> in a shared region is @send whenever T is.
  • Managed memory (WasmGC) is per-Wasm-instance. Any type whose composition includes a managed reference (a ManagedBox<T>, a Vec<T, Managed>, a @managed struct) is not @send. Crossing a thread boundary with such a value requires transfer(to: <thread-local Managed>) on the receiving side (see §“Cross-region transfers”).

effects.md enumerates the full struct / enum / parameter composition rules and defines the EFF113 / EFF114 diagnostics; this spec does not duplicate them.

Shared regions

A shared region’s backing memory lives in mem.shared and is visible to every Wasm thread. Declared with @shared on a struct:

@shared
struct World {
counter: Atomic<i64>,
grid: Shared<Grid>,
}
scope {
let world = World.new()
spawn { world.counter.add(1) }
spawn { world.counter.add(1) }
}

Effects of @shared:

  • The struct is allocated in mem.shared (SAB-backed in the browser; pthread_mutex-protected linear memory in Wasmtime / Wasmer).
  • Every field must be Atomic<T>, Shared<T, P>, or another @shared struct. A plain field is REG030 (“non-shareable field in @shared struct”).
  • The struct is @send (its handle can cross threads freely).
  • Field access compiles to atomic loads/stores or to Shared<T, P>-policy operations (Mutex / RwLock / LockFree / Disjoint).

This supersedes the shared_region world { … } block form sketched in design.md; the annotation pattern is consistent with @managed above.

Shared<T, P> policies

Shared<T, Mutex> // exclusive access, blocking
Shared<T, RwLock> // many readers or one writer
Shared<T, LockFree> // requires T: Atomic or T: ImmutableSnapshot
Shared<T, Disjoint<F>> // compile-time-verified disjoint field access

Atomic<T> is the primitive for shared scalars. The blessed payload types in v0 are the integer tower (Atomic<i32>, Atomic<i64>, Atomic<u32>, Atomic<u64>) and Atomic<bool>. Backed by Wasm 3.0 atomic ops. Methods: load() -> T, store(v: T), add(v: T) -> T, sub(v: T) -> T, compare_exchange(expected: T, new: T) -> T (returns the prior value), plus the bitwise variants on integer payloads (or, and, xor). Atomic pointer-sized handles (e.g. atomic references into shared regions) are deferred until the shared- reference design lands.

Memory ordering, v0. Every Atomic<T> operation uses sequentially-consistent ordering — the strongest Wasm 3.0 ordering. Conservative-by-default; a future revision may add @relaxed / @acquire / @release annotations on individual operations for hot paths that have measured the need. v0 picks correctness over peak throughput.

Most user code uses Shared<T, Mutex> or Atomic<T>. LockFree and Disjoint are advanced; they enable hot paths the compiler can prove safe without runtime checks.

Allocator parameters as cultural pattern

Zig’s influence, generalized: anything that allocates takes the region as a parameter when the caller wants control:

pub fn intern<R: Region>(r: R, s: str) -> String<R>
pub fn merge_sort<T, R: Region>(r: R, items: [T]) -> Vec<T, R>
pub fn collect<T, R: Region>(r: R, stream: Stream<T>) -> Vec<T, R>

This appears in libraries where allocation strategy matters and in stdlib code that wants to be generic. Application code with no strong preference falls back to the implicit scope arena and omits R.

Effect interactions

Memory operations carry the effects from effects.md:

OperationEffects
Arena / Pool / Stack allocAllocates ⇒ violates @no_alloc.
FreeList allocAllocates ⇒ violates @no_alloc.
Managed allocAllocates + may suspend (GC) ⇒ @no_suspend violation as well.
transfer(to: linear)Allocates in target; unbounded size ⇒ EFF110 if @realtime.
transfer(to: Managed)Allocates + may suspend ⇒ both effects.
transfer(to: Interned<…>)Allocates first time; hashtable lookup is @pure after.
Atomic<T>.load/store/cas@no_alloc + @no_suspend ✓ — safe inside @realtime.

The @realtime audio path may allocate nothing at run time. That means: a transfer cannot appear in a @realtime stage; the audio thread reads from Pool-backed channels and operates on already-positioned buffers.

Diagnostic codes

All region-and-lifetime diagnostics use the REG prefix. Numbers are stable, never reused. REG040REG099 reserved for future expansion.

CodeShort messageWhen
REG010value outlives its regionA function returns a value allocated in one of its local regions.
REG011borrowed value escapes its regionA ref to region-local data is stored outside that region.
REG012cross-region pointer without transferA function takes Vec<u8, R1> but is called with Vec<u8, R2>.
REG013implicit-scope arena allocation in @no_alloc functionAn unannotated allocation appears under @no_alloc.
REG020linear pointer in @managed structA @managed struct has a field pointing into linear memory.
REG021managed reference in non-managed structA plain struct has a managed-reference field; whole struct must be @managed.
REG030non-shareable field in @shared structA field whose type isn’t Atomic<T> / Shared<T, P> / @shared.
REG031@shared struct passed by linear valueA @shared struct can only be referenced through its handle, not copied by value.
REG032shared region escapes its scopeA @shared value crosses into a scope where its backing memory isn’t visible.
REG040region exited with live allocationsA FreeList region exited without explicit frees for some allocations.
REG041nested region outlives parentA region tries to outlive the region that owns its backing memory.
REG042invalid region kind for operationE.g. Stack region passed where a growable region is required.
REG050unknown transfer verbSource uses copy_to / pin_to / intern instead of transfer(to: …).

All codes are emitted using the standard envelope from diagnostics.md.

Examples

Per-frame arena in a renderer

fn render_frame(scene: Scene) -> Frame {
region frame_arena: Arena<8.MB> {
let visible = scene.cull(frame_arena) // Vec<Object, frame_arena>
let cmds = build_command_list(frame_arena, visible)
let pixels = rasterize(frame_arena, cmds)
// Pixels need to escape; transfer to caller's scope arena.
pixels.transfer(to: scope)
}
}

Everything inside the region block is freed at the closing brace except pixels, which the explicit transfer copies out.

Managed graph with cycles

@managed
struct Node {
children: Vec<ManagedBox<Node>>,
parent: Option<ManagedBox<Node>>,
}
fn build_tree() -> ManagedBox<Node> {
let root = Managed.box(Node { children: Vec.new(), parent: None })
let child = Managed.box(Node { children: Vec.new(), parent: Some(root) })
root.children.push(child)
root
}

child points back to root via parent; root points to child via children. That’s a cycle in the object graph — fine under WasmGC, which collects the whole subgraph once no managed reference reaches it. A linear-memory allocator couldn’t free this without a topological owner; the managed heap is the escape hatch for graphs the developer doesn’t want to discipline by hand.

Shared counter across tasks

@shared
struct Hits {
count: Atomic<i64>,
}
scope {
let hits = Hits.new()
for _ in 0..100 {
spawn { hits.count.add(1) }
}
// structured scope joins; at this point hits.count == 100
}

Real-time audio path — pool-backed channel

fn audio_engine {
region pool: Pool<Frame, 64> { // 64 slots, lock-free
let (tx, rx) = channel<Frame>(pool, policy: RingBuffer, capacity: 64)
spawn { capture_loop(tx) } // producer; allocates into the pool
scope @realtime {
loop {
let frame: Frame = rx.recv() // RingBuffer recv takes no ctx
play(frame) // @realtime; no allocation
}
}
}
}

The pool lives outside the @realtime scope; the audio loop only reads from pre-allocated slots. transfer does not appear in the hot path.

Open items deferred

  • Region parameter inference at call sites. Today the caller passes R explicitly (or relies on Arena default). A future pass may infer R from the binding-type context.
  • First-class region polymorphism in faces. A face whose methods take r: R parameter for arbitrary R works today; faces with associated regions (type R: Region) are deferred.
  • Borrow checker for cross-region references. v0 forbids cross-region references statically (REG011 / REG012); a future revision may permit them via explicit lifetime annotations.
  • Specialization of transfer semantics. Optimizing Linear → Managed for small POD types (avoiding the GC root registration) and similar peephole improvements; tracked by the faces.md specialization deferral.
  • Custom region kinds via user types. Today Arena, Pool, Stack, FreeList, Managed, Interned are blessed. Letting user libraries declare new R: Region kinds requires more thought about layout, @send, and the transfer table.
  • Drop semantics on region exit. A FreeList region with live allocations could call user-defined destructors instead of raising REG040; deferred until the broader destructor design lands.
  • effects.md@send derivation, allocation effects, transfer effects in @realtime paths.
  • generics.md — the R: Region parameter kind, default region parameters on collections, region face bounds.
  • concurrency.md — scope’s default arena, structured concurrency interaction with regions, @shared regions and channels, atomics.
  • faces.mdRegion is an auto-prelude face; Arena, Pool, Stack, FreeList, Managed, Interned are blessed types fitting it.
  • modules.mdRegion and the region kinds are in the auto-prelude; no import required.
  • q64-cli.mdq64 show regions <fn>, q64 show alloc <fn>, q64 show layout <type>, q64 show memories introspection.
  • diagnostics.md — envelope format for the REG* codes.