Ownership
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.
Transfer Semantics
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.
COPY types
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.len() // 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.
MOVE types
A type with a @move transfer constructor can only be moved. 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.
NON_TRANSFER types
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
Structs
Structs are always trivially copyable via memcpy. They have no transfer constructors, no
destructors, and no lifecycle categories. Assignment always copies:
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
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.len() // ok: b is still live, inspect got a copy
Last-use move optimization
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).
Pass-by-pointer optimization
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.
Pointer Aliasing
Kairo allows multiple pointers to the same value. There is no Rust-style exclusivity rule (one
mutable xor many immutable). 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
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.
What AMT enforces
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:
var p: *i32
{
var x = 42
p = &x
}
*p = 10 // hard error: x is destroyed, p is dangling
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. Where the buffer
is mutated through a path AMT cannot trace (an opaque container, a raw FFI call), AMT conservatively
errors rather than allowing an unprovable access. See
AMT Analysis scope.
Stack escape. A pointer to a stack-allocated value cannot outlive the value. Returning &x
where x is a local is always a hard error. There is no heap allocation behind a stack value, so
no smart pointer can rescue it this is why a stack escape is an error rather than a promotion
candidate. See AMT Stack Pointers.
Data-race detection across threads is part of the intended model but depends on Kairo’s
concurrency design (spawn, the memory model, happens-before semantics), which is not yet
finalized. See Concurrency. Once that model is pinned, AMT will
treat concurrent write+read and write+write to the same allocation without synchronization as a
hard error. The semantics here are a target, not a current guarantee.
What AMT does not enforce
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 once concurrency is finalized races) and lets the rest through.
noalias optimization
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.
Smart Pointer Promotion and Aliasing
When AMT promotes a heap pointer to a smart pointer (in debug mode), the aliasing pattern determines which smart pointer type is chosen:
// Single owner, no aliasing -> Unique
var cfg = std::create<Config>(8080)
return cfg
// AMT: cfg has one owner -> Unique
// Aliased, both pointers escape -> Shared
var cfg = std::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 = std::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.
Closure Captures
Closures capture variables from their enclosing scope. The capture mode determines the ownership relationship between the closure and the captured variable.
Capture by transfer (|=|)
|=| 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.len() // ok: buf was copied, original is still live
file.handle // compile error: file was moved into the closure
Capture by address (|&|)
|&| 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, it is a hard error stack pointers cannot be promoted:
fn make_closure() -> fn() -> i32 {
var x = 42
return fn ()|&| -> i32 { return x }
// hard error: x is stack-allocated, closure would outlive it
// AMT will not promote stack escapes are never promotable
}
Capture by transfer avoids this the closure owns its own copy:
fn make_closure() -> fn() -> i32 {
var x = 42
return fn ()|=| -> i32 { return x }
// ok: x is copied into the closure, no lifetime dependency
}
Per-variable capture
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.
Destruction Order
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. Destruction is tied to lexical scope, so side effects in destructors (file close, lock release, flush) are predictable from reading the source.
Moved-From State
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. There is no runtime check the compiler statically tracks which bindings are live and which have been moved.
Summary
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)
// 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: 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