-
Notifications
You must be signed in to change notification settings - Fork 3
Ownership
Warning
The ownership model is enforced by AMT, which is not yet implemented. AMT development begins at Stage 2 of the compiler roadmap. This page describes the intended design. See AMT for the implementation timeline.
Kairo's ownership model governs how values are created, transferred, borrowed, and destroyed. It works in conjunction with AMT to provide memory safety without lifetime annotations and without a separate reference type.
The model has two parts: transfer semantics (how values move between bindings) and pointer aliasing (how multiple pointers to the same value interact). Transfer semantics are determined by the type's lifecycle category. Pointer aliasing is tracked by AMT at compile time.
Note
This page covers who owns a value and when it dies. It does not cover the safety of individual pointer accesses (bounds, use-after-free, null, data races) those are dereference obligations discharged by AMT's proof engine, described in AMT The Dereference Obligation. The distinction matters: ownership and escape violations are resolved by transforming the program (debug) or a hard error (release), never by a runtime check. Access violations may fall back to a runtime check. When this page says a violation is "a hard error," it means specifically that no ownership transformation can rescue it see AMT The Residual Table for the full classification.
Every type in Kairo has a lifecycle category that determines what happens when a value is assigned to a new binding, passed to a function, or returned. The category is set by which transfer constructor the type defines. See Classes Lifecycle Categories for the declaration syntax.
A type with a @copy transfer constructor (explicit or implicit) is copyable. Assignment produces
an independent copy both the source and destination are live after the assignment:
class Buffer {
var data: [i32]
@copy
fn Buffer(self, other: Self) = default
}
var a = Buffer()
var b = a // copy: a and b are independent
b.data.push(42)
a.data.length() // 0 a is unaffected
AMT may elide a copy into a move when it proves the source is not used after the transfer and the elision is unobservable. This is a pure optimization with no observable semantic difference. AMT does not elide when the destructor has timing-sensitive side effects see AMT Copy Elision for the exact rule.
The programmer does not opt into or control copy elision. AMT applies it when safe.
A type with a @move transfer constructor can only be moved. MOVE types represent unique ownership of a resource. Moving transfers ownership without duplicating the underlying resource, preventing double destruction and expensive deep copies.
Assignment transfers ownership the source is invalidated and cannot be used:
class UniqueFile {
var handle: i32
@move
fn UniqueFile(self, other: Self) = default
}
var a = UniqueFile()
var b = a // move: ownership transfers to b
// a is invalidated any use after this line is a compile error
a.handle // compile error: a has been moved
b.handle // ok: b owns the value
A type cannot define both @copy and @move pick one. A type with neither is implicitly COPY
with compiler-generated members.
A type with both @copy and @move explicitly deleted cannot be assigned, copied, or moved.
The auto-derived op = is also = deleted as a consequence there is no transfer constructor for
it to be generated from. These are scope-bound values: they live and die in the scope where they
are created:
class ScopeLock {
@copy fn ScopeLock(self, other: Self) = delete
@move fn ScopeLock(self, other: Self) = delete
}
var lock = ScopeLock()
// var copy = lock // compile error: copy deleted
// var moved = lock // compile error: move deleted
// lock = ScopeLock() // compile error: op = is deleted
// lock lives until end of scope, then destructor runs
Note
NON_TRANSFER types have a specific restriction under threading: they cannot be transfer-captured
into a spawned task (there is nothing to transfer), and may only be shared read-only by address.
See AMT Threading.
Structs have value semantics. Assignment copies their object representation byte-for-byte via memcpy. Because structs cannot define constructors, destructors, or transfer constructors, this copy is always valid.
struct Point { var x: f64; var y: f64 }
var a = Point { x: 1.0, y: 2.0 }
var b = a // memcpy always, unconditionally
b.x = 9.0
a.x // 1.0 independent copy
See Structures.
Function parameters follow the same transfer rules as assignment. Passing a COPY type copies it. Passing a MOVE type moves it the caller cannot use the value after the call:
fn consume(file: UniqueFile) {
// file is owned by consume
}
var f = UniqueFile()
consume(f)
// f is invalidated moved into consume
f.handle // compile error: f has been moved
fn inspect(buf: Buffer) {
// buf is an independent copy
}
var b = Buffer()
inspect(b)
b.data.length() // ok: b is still live, inspect got a copy
For MOVE types, AMT detects when a value is passed to a function and never used again. In this case, the value is moved rather than requiring explicit annotation:
var m = Moveable()
foo(m) // m is moved AMT sees m is not referenced after this line
// m is invalidated from here
This only applies when the value is genuinely unused after the call. If any subsequent code references the value, it is a compile error (MOVE types cannot be copied).
When a function takes a parameter by value and the type is larger than a pointer (8 bytes on 64-bit), the compiler may silently pass a pointer instead of copying. This is a codegen optimization only the source-level semantics are always by-value. The optimization applies only when AMT can prove the by-value semantics are preserved for the duration of the call:
- The parameter is not aliased and mutated through any other live pointer while the call is in progress i.e. AMT proves no write reaches the same allocation during the call.
- The parameter is not modified inside the callee (or the function takes it as
const). - The parameter is not stored, returned, or captured.
- The function is not
async.
The first condition is stronger than "no other pointer exists." Kairo permits arbitrary mutable aliasing in single-threaded code (see Pointer Aliasing), so AMT cannot rely on the absence of aliases it must prove that no write through an alias is observable during the call. If it cannot prove this, the parameter is copied as written. The programmer does not control this optimization and cannot observe it.
Kairo allows multiple pointers to the same value. There is no Rust-style exclusivity rule (one
mutable xor many immutable) in single-threaded code. Multiple *T to the same location is
legal, and writing through one pointer is visible through all others:
var x = 42
var p: *i32 = &x
var q: *i32 = &x
*p = 100
std::println(*q) // 100 defined behavior
Important
This freedom is single-threaded only. The moment an allocation is shared across a thread boundary
(captured into a spawn/thread), AMT switches to a stricter rule: one writer, or many readers,
never both. Arbitrary mutable aliasing is permitted within one thread because a single-threaded
race is impossible; across threads it is undefined behavior that no runtime check can rule out, so
the rule tightens exactly at the boundary. See AMT Threading.
This applies uniformly regardless of const:
var x = 42
var p: *i32 = &x
var q: *const i32 = &x
*p = 100
std::println(*q) // 100 *const prevents mutation through q, not through p
const is a semantic check on the binding, not an aliasing constraint. *const T prevents
the holder from mutating through that pointer. It does not prevent other pointers from mutating
the same value. AMT does not change behavior based on const qualifiers it tracks provenance
and lifetime independently of mutability.
AMT does not restrict aliasing patterns in single-threaded code. What it does enforce:
Provenance validity. A pointer must refer to memory that is still live. Using a pointer after its target has been destroyed is a hard error. This is clause C1 of the dereference obligation, discharged by epoch tracking AMT proves the allocation has not been freed or relocated since the pointer was derived:
var p: *i32
{
var x = 42
p = &x
}
*p = 10 // hard error: x is destroyed (epoch incremented), p is dangling
When provenance is intact but the epoch cannot be proven equal statically, this access falls back to a runtime epoch check rather than a hard error see AMT Provenance and Epochs. It is a hard error only when provenance is lost entirely.
Iterator invalidation. A pointer into a container's buffer is invalidated by operations that may reallocate the buffer:
var v: [i32] = [1, 2, 3]
var p: *i32 = &v[0]
v.push(4) // may reallocate v's internal buffer
std::println(*p) // hard error: p's provenance is invalidated by push
AMT detects this through .amt summaries: push's summary records that it may reallocate the
backing buffer, so any live pointer into that buffer is flagged at the call site. Invalidation is
a hard error even when AMT can trace the reallocation it is not a runtime-check fallback. The
realloc is provable statically, re-validating every live pointer across every potentially-reallocating
call would be expensive, and a use-after-realloc is almost always a real bug, so AMT errors rather
than checks. Where the buffer is mutated through a path AMT cannot trace (an opaque container, a raw
FFI call), AMT also errors rather than allowing an unprovable access. See
AMT Analysis scope.
Stack escape. A pointer to a stack-allocated value cannot outlive the value. What happens when it tries depends on whether the value is heap-promotable:
fn make() -> *i32 {
var x = 42
return &x
// debug: AMT rewrites `var x = 42` to a heap allocation and promotes the
// escaping pointer (Unique)
// release: hard error a heap allocation in a release binary must be visible
// in the source, so AMT shows the fix instead of inserting it silently
}
A stack escape of a heap-promotable value is debug-transformable / release-error AMT can lift
x to the heap, which turns the escape into a legal ownership transfer. A stack escape with no
value to lift (a borrow with no owner, an escape into an opaque sink where AMT cannot establish the
heap-allocation pattern) is a hard error in both modes there is nothing to transform. This is the
split the AMT Stack Pointers section details in full. Either
way, the fix in release is to allocate explicitly or restructure.
Note
Data-race detection across threads is enforced by AMT's strict threading mode. When an
allocation is shared across a spawn/thread boundary, AMT requires a single writer (or
read-only sharing) and treats a second writer, or any unsynchronized write+read, as a hard error.
The synchronization primitives that discharge these obligations depend on Kairo's concurrency
runtime, which is not yet finalized the analysis shape is fixed, the runtime seam is not. See
AMT Threading and Concurrency.
AMT does not prevent multiple mutable pointers to the same value in single-threaded code. This is intentional many valid patterns require mutable aliasing (parent/child pointers, graph structures, cache-and-source patterns). The tradeoff: Kairo allows more programs than Rust at the cost of not statically preventing all aliasing bugs. AMT catches the ones that are provably wrong (dangling, invalidation, and across a thread boundary races) and lets the rest through.
When AMT proves that two pointers do not alias (point to different allocations or non-overlapping
regions), it attaches noalias metadata to the LLVM IR. This enables the backend optimizer to
perform more aggressive transformations (load/store reordering, vectorization) without the
programmer writing anything. This is invisible the source code does not change, and the
behavior is identical with or without the tag.
When AMT promotes a heap pointer to a smart pointer (in debug mode), the aliasing pattern determines which smart pointer type is chosen. In release, the same analysis produces a hard error naming the type to annotate AMT does not silently change pointer types in a release binary. See AMT Ownership and Promotion.
// Single owner, no aliasing -> Unique
var cfg = @create Config(8080)
return cfg
// AMT: cfg has one owner -> Unique
// Aliased, both pointers escape -> Shared
var cfg = @create Config(8080)
server_a.config = cfg
server_b.config = cfg
// AMT: cfg is aliased across two live bindings -> Shared
// Aliased, but the alias dies before escape -> Unique
var cfg = @create Config(8080)
{
var tmp: *Config = cfg
validate(tmp)
}
// tmp is dead cfg has single ownership at this point
return cfg
// AMT: alias was short-lived, cfg is sole owner -> Unique
The promotion trigger is not "multiple pointers exist" but "multiple pointers exist AND the
aliasing pattern requires shared ownership for safety." A short-lived alias that dies before the
owner escapes does not force Shared.
Because AMT is whole-program, the cases where promotion is actually needed are narrow. Most pointers have fully contained lifetimes and require no promotion at all.
See AMT Promotion Decision for the full decision tree.
Closures capture variables from their enclosing scope. The capture mode determines the ownership relationship between the closure and the captured variable.
|=| captures all referenced variables by their type's transfer semantics COPY types are
copied, MOVE types are moved. Captures happen at closure creation time, not at invocation:
var buf = Buffer() // COPY type
var file = UniqueFile() // MOVE type
var closure = fn ()|=| {
buf.data.push(1) // operates on the closure's copy
file.close() // operates on the moved-in file
}
buf.data.length() // ok: buf was copied, original is still live
file.handle // compile error: file was moved into the closure
|&| captures all referenced variables by address. The closure holds *T to each captured
variable & here is the address-of operator, the same & used everywhere else in the
language. Mutations through the pointer affect the original:
var count = 0
var inc = fn ()|&| {
count += 1 // modifies the original count through a pointer
}
inc()
inc()
count // 2
AMT tracks address captures the same way it tracks any other pointer. If the closure escapes and the captured variable is stack-allocated, the heap-promotable / non-promotable split applies exactly as it does for any stack escape (see Stack escape above): a promotable value is heap-lifted in debug and a hard error in release; a non-promotable one is a hard error in both modes:
fn make_closure() -> fn() -> i32 {
var x = 42
return fn ()|&| -> i32 { return x }
// debug: x is heap-lifted and the capture promoted
// release: hard error capture by transfer or heap-allocate explicitly
}
Capture by transfer sidesteps the escape entirely the closure owns its own copy with no lifetime dependency on the enclosing frame:
fn make_closure() -> fn() -> i32 {
var x = 42
return fn ()|=| -> i32 { return x }
// ok: x is copied into the closure, no lifetime dependency
}
Note
When a closure is captured into a spawned task, address captures fall under AMT's threading rules:
a const & (read-only) capture shares the allocation read-only across the boundary; a mutating
& capture makes the allocation a shared mutable allocation and triggers the single-writer
analysis. See AMT Threading.
Mix capture modes per variable. Unqualified names use transfer semantics, &-prefixed names
capture by address:
var a = Buffer() // COPY
var b = 0
var closure = fn ()|a, &b| {
a.data.push(1) // closure's own copy of a
b += 1 // modifies the original b through a pointer
}
See Closures for the full capture syntax.
Values are destroyed at the end of their enclosing scope in reverse declaration order. This applies to stack-allocated, heap-allocated, and smart-pointer-promoted values alike:
fn example() {
var a = Resource("first")
var b = Resource("second")
var c = Resource("third")
}
// destruction order: c, b, a
For smart pointers promoted by AMT:
-
std::Unique<T>: the object destructor runs at the end of the owning binding's lexical scope, in reverse declaration order. -
std::Shared<T>: each binding decrements the reference count at its own scope boundary in reverse declaration order; the object destructor and deallocation run once, when the lastSharedreference's scope ends. The decrement order is lexical and deterministic; the object destructor fires at the final owner, which is not necessarily the last-declared binding in any single scope. See AMT Destruction Timing. -
std::Weak<*T>: invalidated at scope exit. Does not affect the reference count.
There is no drop-at-last-use optimization for observable destructors. Destruction of a value whose destructor has side effects (file close, lock release, flush) is tied to lexical scope, so the effect is predictable from reading the source. A value whose destructor is trivial may be reclaimed at last use, since that is unobservable see AMT Destruction Timing for the trivial / non-trivial split.
After a value is moved, the source binding is invalidated. Any use of a moved-from binding is a compile error there is no "valid but unspecified" state like C++:
var a = UniqueFile()
var b = a // move
a.handle // compile error: a has been moved
a = UniqueFile() // ok: a can be reassigned to a new value
a.handle // ok: a is live again
A moved-from binding can be reassigned. After reassignment, it is live again with the new value. But between the move and the reassignment, any access is a hard error.
This is enforced by AMT in both debug and release builds, and it is a static determination, not a runtime check there is no runtime fallback for a moved-from access the way there is for an unprovable bounds access. A use-after-move is a logic error the compiler proves at compile time, not a safety property discharged at runtime; the compiler statically tracks which bindings are live and which have been moved.
| Type category | Assignment | Source after | Function param |
|---|---|---|---|
| COPY | Copy | Live | Copy (caller keeps) |
| MOVE | Move | Invalidated | Move (caller loses) |
| NON_TRANSFER | Error | N/A | Error |
| Struct | memcpy | Live | memcpy (caller keeps) |
Escape and ownership violations resolve by transforming the program in debug (heap-lift, smart pointer promotion) and a hard error with the fix in release. They are never runtime checks that is reserved for access obligations like bounds. See AMT The Residual Table.
// COPY: both sides live after transfer
var a = Copyable()
var b = a // copy
a.method() // ok
b.method() // ok
// MOVE: source invalidated after transfer
var x = Moveable()
var y = x // move
// x.method() // compile error
y.method() // ok
// Pointer aliasing (single-threaded): allowed, AMT checks provenance
var val = 42
var p = &val
var q = &val
*p = 100
std::println(*q) // 100
// Closure capture by transfer
var m = Moveable()
var f = fn ()|=| { m.use() }
// m is moved into f
// Closure capture by address
var n = 0
var g = fn ()|&| { n += 1 }
g()
// n is 1
This wiki mirrors the language reference at kairolang.org/docs. To edit a page, edit the source at kairo-web/src/content/docs/language changes sync automatically.
Start here: Primitives
1. Fundamentals
2. Functions & Control Flow
3. Types
4. Modules & Metaprogramming
5. Memory & Safety
6. Interop & Concurrency