Ownership and Memory Safety

<!-- verified-against: seed-llvm; examples: docs-examples-test -->

seed uses ownership, move-only resources, lexical loans/provenance, structural capabilities, deterministic cleanup, linear t, and regions. It has no GC, borrow-checker lifetime syntax, or exception unwinding. The authoritative semantic rules are in seed/compiler/llvm/SEMANTIC_RULES.md; current limits are tracked in Current Status.

Copy, move, borrow, clone

Operation Meaning
copy source and destination remain available; only for copy-capable values
move ownership transfers and the source place becomes unavailable
borrow a zero-copy view is tied to proven backing storage
clone explicit independent value/handle according to structural capability

Scalars and aggregates containing only copy fields copy. string, bytes, shared_bytes, user-destructor owners, resource aggregates, and linear t obey their move/clone/cleanup capabilities.

shared_bytes is not bitwise-copyable: .clone() explicitly retains another handle without copying the payload. Cloning owned string/bytes or aggregates containing them may allocate. Clone allocation effects and capabilities survive source-elided .sdi compilation.

Places and partial ownership

The compiler tracks locals, fields and nested fields, known fixed-array indexes, active ADT payloads, raw-union fields, tuple/array/nominal aggregates, generic instances, option/result, function values, channels/tasks, aliases, blocks, calls, returns, and control-flow joins.

A partial move makes the moved place unavailable and prevents whole-owner use when completeness is required. Independent remaining fields stay usable when a user destructor does not require a complete owner. Dynamic-index moves of resource elements remain rejected because the cleanup set would be runtime dependent.

Cleanup

Cleanup is planned from typed HIR ownership facts, verified separately, and lowered onto normal structured exits. Live owners clean exactly once in reverse successful initialization order. Reassignment cleans the previous live owner before storing the replacement. Loops use dataflow fixed points across entry, back edges, break, continue, and return.

Panic, trap, and abort terminate without unwinding. They do not run lexical cleanup or user destructors.

Destructors

fn TypeName.drop() receives implicit consuming self. It must be unique and return void. Calling value.drop() manually consumes the owner, preventing a second automatic call. Returned owners transfer their cleanup obligation to the caller. Cleanup/resource globals are rejected because global teardown is not an implicit language service.

ADT cleanup dispatches only over the active variant. Raw unions require unsafe active-field discipline; safe tagged alternatives should use ADTs or an audited wrapper.

Closure ownership

A plain |...| closure owns compiler-generated stack capture storage. Capture initialization follows first lexical-use order: copy-capable values copy and other owned values move. Captures are immutable; a moved resource capture makes the closure itself move-only. The owner drops live captures exactly once in reverse initialization order.

Plain closures are reusable and non-escaping. They cannot capture values that cannot remain safely stored after a call, including views, mutable captures, region-bound values, raw/reference values, format_args, or already-moved places. They cannot be returned, retained in an outer scope, sent to a task, or passed through a platform function-pointer boundary.

box |...| allocates capture storage explicitly and returns Result<box fn(...) -> T, alloc_error>. The boxed owner can be returned or stored but remains move-only, non-cloneable, and non-sendable. Allocation failure drops transferred captures once; successful drop runs the capture drop thunk and frees the environment once. A boxed closure allocated under region(...) cannot escape that region.

Linear values

linear t preserves t's representation and adds an exactly-once obligation. It removes copy/clone/shared capability while retaining valid move, borrow, send, sync, and cleanup facts. The implementation propagates the obligation through aliases, blocks, aggregates, option/result, ADTs, generics, channels, function values and indirect calls, task capture, parameters/returns, and schema-17 .sdi.

Leaving a linear value live at scope exit, consuming it twice, cloning it, discarding it, overwriting it, or storing it into pre-existing non-linear storage is rejected.

Text, buffers, and views

There is no lifetime syntax. @borrow receiver methods create shared lexical loans; returned views remain tied to the receiver through .sdi summaries. Free functions may also return static or input-derived str/slice views; an owner-derived view from a local string/bytes remains a V001 escape. @mut receiver methods create an exclusive call-scoped loan and mutate the caller's place directly. Immutable receivers and overlapping live views are rejected; no move or clone is hidden by the call.

Regions

fn work() -> Result<i64, alloc_error> {
    region(4096) {
        // allocating operations use the active checked bump allocator
        Ok(42)
    }
}

Capacity is evaluated once, aligned allocation is overflow checked, and exhaustion is returned as alloc_error by fallible APIs. Nested regions are LIFO. Body resources clean before the backing storage is released. Safe owners, views, and pointers tied to a region cannot escape through result, return, or assignment to an outer binding.

Spawned work captures allocator context. Region-bound tasks remain structured and join before region cleanup.

Task transfer

spawn records copy/move capture mode. Keeping the original and a task value requires an explicit clone. Crossing a task boundary requires send; shared access also requires sync. Mutable global state cannot be captured. Each task group drains before its lexical owner cleans resources.

if let and while let are surface forms over match: both desugar to a two-arm match whose second arm is the complement, so binding, ownership transfer, exhaustiveness, and cleanup are exactly the match rules. if let Some(x) = e names its complement None; another variant pattern keeps a wildcard arm, which is rejected when the scrutinee carries a move-only payload because a wildcard arm does not complete that payload's ownership obligation — write the explicit match arms there. An irrefutable pattern makes the complement arm unreachable and is rejected. A repeat array literal [value; count] expands to count elements of one literal value and takes its element type from the expected array type, so let slots: [4]usize = [0; 4] needs no cast; the value must be a literal, a cast of a literal, or an array of those, and the count is a positive integer literal of at most 65536.

Unsafe boundary

Raw allocation, pointer access, FFI, syscalls, atomics, volatile access, address-space casts, raw union access, and raw assembly require unsafe. Unsafe code does not disable ownership for surrounding safe values; it assumes specific obligations that should be hidden behind narrow audited safe wrappers.

unsafe struct parameters: an unsafe struct with a raw pointer field and @repr("null_niche") is a resource type that cannot be passed by value as a function parameter. Passing it by value moves the underlying owner, making it unavailable for subsequent use. Methods with @mut receiver require a mutable place and cannot be called on a moved value. To use such a type across function boundaries, inline the logic or return the owner alongside the result.

What release changes

--release and --release-small optimize the same safe semantics. Bounds, discriminant, provenance, ownership, and cleanup checks remain unless proven redundant. No unchecked release profile is implemented.

Ownership cookbook

These are the twenty recurring construction patterns used by the Grove and compiler fixtures. The important part of each pattern is the invariant it preserves; when a value cannot satisfy that invariant, keep the operation in a narrow unsafe adapter instead of weakening the owner model.

Pattern Seed construction Invariant preserved
1. owned builder stringbuilder's string_builder_new(capacity) then fallible to_string() The builder owns initialized bytes and publishes only valid UTF-8.
2. borrowed parser view input.view() or str.slice(...) Parsing borrows stable storage and allocates only for owned results.
3. arena plus views region(capacity) { ... } Region-bound values and views cannot escape the region.
4. bounded byte buffer bytes.zeroed(n)? plus a mutable view The committed length never includes uninitialized storage.
5. spare-capacity fill audited spare_capacity() plus commit_initialized() The producer initializes exactly the prefix it commits.
6. fallible conversion view.to_string()? / view.to_bytes()? Allocation failure is a typed result and cleanup remains structured.
7. shared read-only payload bytes.to_shared()? then explicit .clone() Sharing retains a handle; payload mutation is impossible.
8. disjoint mutation split_at_mut(index) The owner remains exclusively borrowed through two non-overlapping views.
9. indexed collection access checked values[index] Bounds are checked; dynamic resource moves remain rejected.
10. static resource projection a known array index or field Cleanup can name the exact moved element.
11. move-only map entry HashMap<K,V> with CollectionKey Key/value ownership is transferred exactly once through removal.
12. channel transfer move an endpoint or payload into spawn Cross-task values satisfy send; shared access also requires sync.
13. typed task result task_result_new, complete, then wait The result handle and payload have one lexical owner.
14. structured cancellation task cancellation plus a join boundary Cancellation does not bypass task join or cleanup.
15. C callback named fn(...) -> ... at an extern "c" boundary The C side receives a thin code pointer, never a Seed environment owner.
16. C opaque handle *mut u8 inside a small unsafe adapter Foreign lifetime and validity obligations stay explicit at the boundary.
17. repr(C) record @repr("c") struct for by-value FFI Field order and ABI layout are part of the declared contract.
18. retained callback `box ...
19. clone before fan-out value.clone() before independent consumers Every consumer receives an explicit ownership decision.
20. result across spawn spawn { fallible_work()? } and wait() Error and success paths cross the task boundary with typed cleanup.

For a worked diagnostic example, start with the consuming span in S001, then apply the smallest pattern above that restores a single owner. The compiler must continue to reject dynamic moves, region escapes, unsafe callbacks, and missing cleanup even when a different construction would be shorter.

Worked S001 example (move across a call and a branch)

A new user moves title into report at the struct construction and then consumes the moved binding inside a later branch:

struct Report {
    title: string
}

fn main() -> Result<i64, alloc_error> {
    let title = "quarterly".to_string()?
    let report = Report { title: title }
    if len(report.title) == 9 as usize {
        let again = title
        Ok(0)
    } else {
        Ok(1)
    }
}

The compiler resolves this from the diagnostic alone: error[S001] names the ownership path (title), a note: ownership path declared here points at the binding, a note: move occurred here points at the consuming struct construction, and the help: line states the repair direction (clone 'title' explicitly before moving). The smallest fix clones the title into the report and moves the original to its final owner before the branch, so every branch exits with the same resource availability:

struct Report {
    title: string
}

fn main() -> Result<i64, alloc_error> {
    let title = "quarterly".to_string()?
    let report = Report { title: title.clone() }
    let again = title
    if len(report.title) == 9 as usize {
        let total = len(again) + len(report.title)
        Ok(if total == 18 as usize { 0 } else { 1 })
    } else {
        let rest = len(again)
        Ok(if rest == 9 as usize { 2 } else { 3 })
    }
}

Every pattern above (and both halves of this worked example) is an executable corpus program verified by make -C seed/compiler/llvm gate47-cookbook-test; the corpus lives in seed/compiler/llvm/tests/cookbook/.