Ownership and Promotion

Ownership and Promotion

Provenance and epochs govern whether an access is safe. A separate analysis governs where a value lives and who frees it. Its residuals are Tier P (transformable) or Tier N (unfixable), never Tier R there is no runtime check for "who owns this."

When Tether determines that a heap pointer needs ownership semantics, it selects a smart pointer type from the pointer's whole-program usage. In debug it performs the promotion and warns; in release it emits a hard error naming the type to annotate.

std::Unique<T> single owner

Exactly one live binding holds the pointer at any point. Ownership may transfer between scopes; the count of live owners is always one.

fn make_config() -> *Config {
    var *cfg = @create Config(8080)
    return cfg
    // debug: promote cfg -> std::Unique<Config>
    // release: error, annotate the return type
}

std::Shared<T> multiple owners

Multiple live bindings refer to the same allocation and single ownership cannot be proven. The allocation is reference-counted; the object is destroyed when the last Shared reference's scope ends.

fn share_config(a: *Server, b: *Server) {
    var *cfg = @create Config(8080)
    a.config = cfg
    b.config = cfg
    // debug: promote cfg -> std::Shared<Config>
}

A short-lived alias that dies before the owner escapes does not force Shared. The trigger is not "multiple pointers exist" but "multiple pointers are live at a point where shared ownership is required for safety."

var *cfg = @create Config(8080)
{
    var tmp: *Config = cfg
    validate(tmp)
}                        // tmp is dead here
return cfg               // single owner at the escape -> Unique

std::Weak<*T> back-reference

A pointer that, promoted to Shared, would close a reference cycle (A -> B -> A). Weak does not contribute to the count and does not keep the target alive; access requires a liveness check.

class Node {
    var child: *Node
    var parent: *Node     // points back at the owner -> Weak
}

No promotion

A pointer whose lifetime is fully contained, or provably bounded by its referent's lifetime, stays a plain *T with no overhead:

fn process() {
    var *data = @create Buffer(1024)
    fill(data)
    consume(data)
    @destroy data    // fully contained -> plain *T
}

Field access and interior pointers are covered in Bounds and Provenance.


Stack Pointers

A stack value has no heap allocation behind it, so it cannot be promoted to a smart pointer. But the (B) model splits stack escape into two cases by whether the value is heap-promotable that is, whether Tether can mechanically rewrite the stack allocation into a @create call.

Heap-promotable escape Tier P. A plain local whose address escapes via return or store, where the value's type can be heap-allocated:

fn make() -> *i32 {
    var x = 42
    return &x
    // debug: rewrite `var x = 42` into `var x = @create i32(); *x = 42`,
    //        then promote the escaping pointer (Unique)
    // release: hard error, heap-allocate explicitly or restructure
}

In debug, Tether lifts x to the heap and the escape becomes a legal ownership transfer. In release this is a hard error, because silently turning a stack allocation into a heap allocation in a release binary is exactly the hidden allocation the language forbids. The diagnostic shows the fix:

error[Tether]: address of stack local 'x' escapes its scope
  --> src/m.k:3:12
   3 |     return &x
     |            ^^ 'x' is stack-allocated and destroyed at end of make()
  note: in debug, Tether would heap-promote: var x = @create i32()
  help: allocate on the heap explicitly, or restructure to avoid the escape

Non-promotable escape Tier N. A borrow with no value to lift (a pointer into a structure Tether cannot heap-allocate, an escape into an opaque sink where Tether cannot even establish the heap-allocation pattern) is a hard error in both modes. There is nothing to transform.

Dangling within a function is likewise a hard error in both modes the value is gone, there is no allocation to lift, and the access is statically known to be invalid:

fn also_bad() {
    var p: *i32
    {
        var x = 42
        p = &x
    }                  // x destroyed here (epoch incremented)
    *p = 10            // hard error: p's provenance epoch is stale
}

See also