Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Tier Model

Every function in Ubel Stratum declares which memory strategy it uses. The compiler enforces the rules statically, at every tier, regardless of whether the program runs through the interpreter or, eventually, compiles to native code.

TierAnnotationMemoryawaitTypical use
HIGH@tier(high) (default)Garbage collectedAllowedBusiness logic, I/O
MID@tier(mid)Arena allocatedNot allowedParsers, hot paths
LOW@tier(low)Manual, borrow-checkedNot allowedSystems code, FFI, packet handling

A function with no @tier annotation is HIGH. Lower tiers are opted into for performance, never opted out of by default.

The core constraint

A MID-tier function allocates data in an arena. Once that arena is freed, every pointer into it becomes invalid, so HIGH-tier code must never hold a live pointer into a freed arena. The type system enforces this at compile time: a value living in arena A carries a type parameterized by the arena’s lifetime (&'a T), and the compiler rejects any program where an &'a T appears in a type that outlives arena A.

Three patterns cross the MID to HIGH boundary safely. These are illustrative of the pattern the tier checker enforces, not a claim that these exact function names ship in a standard library yet; there is no standard library today.

Pattern 1: callback

MID parses into an arena, calls a HIGH-tier closure with a borrow into the arena, the closure produces a GC-owned result, then the arena frees. The closure’s borrow never outlives the arena.

@tier(mid)
fn parse_json_with<F, R>(input: string, callback: F) R
    where F: fn(&JsonView) R   // R must contain no arena references
{
    with arena(1MB) {
        let view = build_json_view(input)
        return callback(&view)
    }
}

@tier(high)
fn handle_request(req: Request) Response {
    parse_json_with(req.body, fn(json) {
        let user_id = json.get("user_id").as_int()
        return fetch_user(user_id)
    })
}

Pattern 2: iterator

For processing large datasets without materializing the whole result at once. MID drives the iteration; HIGH only ever sees GC-owned values, one at a time.

@tier(mid)
fn transform_each<R>(items: &[Item], f: fn(&TransformedItem) R) List<R> {
    with arena(10MB) {
        let mut results = List.new()
        for item in items {
            let transformed = expensive_transform(item)
            results.push(f(&transformed))
        }
        return results
    }
}

Pattern 3: view

Syntactic sugar over the callback pattern for read-only access, following the same rules.

@tier(high)
fn parse_config(path: string) Config {
    let config_view = read_toml_view(path)
    using let v = config_view {
        let host = v.get("host").to_owned()
        let port = v.get("port").as_int()
        return Config { host, port }
    }
}

Rejected at compile time

// storing an arena reference in a GC-managed struct
@tier(high)
struct BadCache {
    data: &JsonView   // error: contains an arena lifetime
}

// HIGH tier constructing an arena directly
@tier(high)
fn bad() {
    with arena(1MB) { }   // error: 'with arena' is MID-tier only
}

// a cross-tier function returning an arena reference
@tier(mid)
fn bad_leak(input: string) &JsonView {
    with arena(1MB) {
        return &parse(input)   // error: return type carries an arena lifetime
    }
}

The cross-tier call matrix

Not simply “LOW cannot call HIGH”: every direction is a separate rule.

CallerCalleeAllowed
HIGHMIDYes (callback/view patterns encouraged, not required)
HIGHLOWYes
MIDHIGHNo, an arena lifetime could escape
MIDLOWYes
LOWHIGHNo
LOWMIDNo

What is enforced today

The cross-tier call matrix above, arena-escape checking for the patterns shown, and lifetime well-formedness (declared lifetime names must exist, where clauses can only reference declared names, no outlives cycles) are real, running checks in the semantic analysis pass, independent of which backend eventually executes the program.

Two things are still in progress rather than complete:

LOW-tier borrow checking. The syntax and structural-type layer for references (&, ref, &mut, ref mut, *, deref) exists, along with the first piece of the borrow checker itself, a control-flow graph builder. Real loan and liveness enforcement, the part that actually rejects a use-after-move or a conflicting borrow, is not built yet. The target is NLL-style liveness precision rather than a coarser lexical-scope approximation.

The interpreter’s memory model. The tree-walking interpreter runs every tier on the same reference-counted representation. with arena blocks are recognized and validated by the tier checker but do not yet allocate or free real memory in the interpreter; genuine bump-allocation arrives with the LLVM backend. The static rules above are enforced regardless, since tier checking happens independently of execution, but running a MID-tier function today does not yet exercise real arena memory pressure or reclamation timing.