docs / specification / lang
Cajeta Memory Model — Specification v1
Goals
- Single-owner heap. Every heap-allocated value has exactly one owning reference at any time.
- Implicit borrow, explicit passthrough.
=is always a borrow — the title stays with the right-hand side.#hands along whatever title the source holds — a transfer when the source owns, a borrow when it doesn’t —dst #= vat a store,#vat a call argument, return, or slot extraction. - Static safety, no user-visible annotations. All borrow/lifetime errors are compile-time. Scope-based inference; no lifetime syntax for users to write.
- Zero runtime cost in release. Heap blocks are plain memory; drops happen at owner scope-end. A debug build flag adds runtime verification for testing the static checker.
AMENDED (2026-07-03): a THIRD ownership state —
shared(governed byslice-spec.md, approved 2026-07-02). Cajeta’s model is an implicit smart-pointer family — the kind is always inferred, never annotated:
State Discipline Analog owned single responsible dropper; #transfers the stakeBox/unique_ptrborrow non-owning; must not outlive its source (static) &Tshared co-owned immutable leaf buffers; runtime count; freed at the last stake Rc/shared_ptr
sharedexists for exactly one thing v1: a slice (String.substring, laterSlice<T>) that outlives the value it was sliced from — the case borrow must reject and copying is too expensive to force. Promotion is one-way (owned → shared); moves are rc-neutral; the count lives in a side table keyed by buffer base with a stolen count-word sign bit (no layout change); buffers never sliced-and-stored pay one predicted bit-test at drop and nothing else. Scoped to immutable leaf buffers ⇒ the shared graph is acyclic ⇒ no cycles, noweak, no leaks. An escaping borrow of an eligible source resolves (copy small / share large / copy arena-backed) instead of erroring; identity/mutable objects keep the error-and-#discipline (slice-spec §4, §9 — the transparent-only boundary).Object.clone()= shallow copy + stake-share per String field (slice-spec §6.4; LIVE). The “single-owner heap” goal above is thereby refined: single owner or counted co-owners for immutable slice backings.
Non-goals (v1)
- Multi-threading safety (deferred — needs Send/Sync-style protocol).
- FFI safety beyond signature-trust.
unsafeescape hatch.- Reflection / dynamic-dispatch borrow analysis (deferred until virtuals land).
Operator: #
Single token, and — with one opt-in exception — it appears only where a transfer actually happens: at a use site, never in a signature.
| Position | Meaning |
|---|---|
dst #= v (store) | Store v into dst and move v’s title onto it. The spelling for assignments: fields, element slots, and local declarations. |
#expr (value position) | Transfer the title from expr — at a call argument, a return, or a slot extraction. After this, expr is moved; a later read is a static error. |
#T (parameter type) | Opt-in must-own. The method refuses a lend: the caller has to surrender. Rarely needed — see below. |
#T (return type) | The method always transfers. Also rarely needed; a plain return already carries whatever title it holds. |
Stores use #=; every other transfer position uses #expr. The two are not
alternatives — they partition by position. An assignment is the one place the
title lands in something, so it gets a fused token that cannot be half-written;
call arguments, returns, and extraction reads are not assignments and keep #v.
this.held #= c; // store: `held` takes c's title
sink.keep(#c); // argument: not an assignment — `#v` stays
return #c; // return: same
T x #= this.data[i]; // store from a slot: the `#=` is the whole transfer
A #= never takes a second # on its right. The store already carries the
transfer, whatever the source is — identifier, field, element, or call result —
so x #= #y is CAJETA_ERROR_DOUBLE_TRANSFER. The rule has no exceptions to
remember.
Deprecated:
dst = #vwas the original store spelling. It still compiles and still transfers, but warns (CAJETA_WARN_DEPRECATED_TRANSFER_ASSIGN) and becomes an error in a later release. Writedst #= v.
Transfer is the caller’s decision, not the signature’s. A plain class-typed
parameter accepts both a lend and a transfer — which one happened is decided at
each call site by whether the argument was spelled #x:
void keep(Cell c) { this.held #= c; } // no ownership spelling in the signature
sink.keep(cell); // lends — `cell` still owns it, and drops it at scope exit
sink.keep(#cell); // surrenders — the title moves; `cell` is moved-from
keep is written once and is correct both ways: #= moves whatever title c
holds. When the caller lent, the field records a borrow and the field’s drop
stays quiet; when the caller surrendered, the field records ownership and drops
it. The callee does not branch, and does not need to know which happened.
This is deliberate. An earlier design put the ownership mode in the signature and
required the author to predict, at declaration time, how every future caller would
use the method. That prediction is not available — most acutely for containers,
where the same put is legitimately used both ways — and the honest endpoint was
to mark everything “either”, which is what the language now does by default.
Rationale and the rejected alternatives are recorded in the title-tracking spec §4.6.
#T on a parameter survives only as an opt-in must-own edge, for a method
that cannot function with a borrow (it stores the value somewhere that outlives
the call, and has no way to cope with the caller keeping the title). A plain
argument at such an edge is CAJETA_ERROR_TRANSFER_REQUIRED.
Borrow / transfer rules
| Operation | Plain | Passthrough (#) |
|---|---|---|
T a = b / T a #= b | a borrows b | a takes whatever title b holds (an owner’s title moves; a formal’s arrived mode is forwarded) |
this.f = x / this.f #= x | the field borrows x — the title stays with x and x still drops it | the field takes whatever title x holds |
this.data[i] = x / this.data[i] #= x | the slot borrows x | the slot takes whatever title x holds, and records it in the slot’s own ownership bit |
f(x) / f(#x) | f borrows x for the call | f takes whatever title x holds; an owned x is moved |
return x / return #x | hands back whatever title x held (a lent value stays lent; an owned one transfers) | hands back the title |
Stores (the first three rows) pass through with #=. Arguments and returns (the
last two) pass through with #x — they are not assignments.
# is a passthrough, not an unconditional move. It hands along the title
the source actually holds. From an owner, that is a transfer. From a plain
formal — whose mode is fixed at the call site (f(x) lends, f(#x)
transfers) and carried at run time by the transfer word — #p, or
this.f #= p, forwards whichever mode arrived, and #= records the forwarded
mode per slot. This is what makes mode-forwarding wrappers expressible; the
static checker deliberately excludes plain formals from the move-of-borrow
check (test/expression/TransferOfBorrowTests.cpp, 2.1.2). Only a value the
compiler can prove is purely a borrow — a local borrowing another local, or a
borrow returned by a plain (non-#) method — refuses # with
CAJETA_ERROR_MOVE_OF_BORROW: that surrender would be a lie.
Note the second row: a plain field store lends. It does not quietly take ownership. If a method stores a borrowed value into a field that outlives the call, the field is left pointing at something the caller will free — which is what the dangling-lend check below catches.
Auto-promotion for fresh heap allocations. An anonymous heap T(...) expression in transfer position promotes implicitly. p.field = heap T() and return heap T() (in a #T function) work without explicit #. The temporary is an unnamed owner with no prior identity, so promotion has no use-after-move risk.
Static analysis rules
Lifetime inference (intra-function)
The compiler tracks each owner’s declaration site and scope-end. For each borrow, it tracks the source’s lifetime. A borrow that may outlive its source is a compile error at the use site.
Path-based borrow tracking
Borrows track their path from the named root, not just the variable. String n = person.address.city records n’s root as person with path address.city. Reassigning any link along the path (person.address #= x) drops everything derived from that link; subsequent use of n is a static error.
Function signatures: the transfer ABI
Because the caller decides, the callee has to be told what it got. Every class-typed parameter and return therefore carries a hidden per-call flag.
-
Arguments. A method with at least one pass-by-pointer class parameter takes a hidden trailing word; bit i is set iff user-argument i was surrendered.
@Kernel,@Device,@Nativemethods andstatic mainkeep the plain C ABI — the compiler does not own both sides of those boundaries. The word is the ONLY argument-side carrier (the oldmoveMaskthread-local is retired, title-tracking 7.2.2), and constructors ride the same trailing word.The word is ABI, not API. Nothing in user code reads it positionally:
Cajeta.moveMask()is retired (CAJETA_ERROR_MOVEMASK_RETIRED) because bit-index-coupled-to-formal-order was fragile and leaked the calling convention into user source. Its two successors cover what it was used for: stores spell#=and let the field/slot bit record what the caller did, and code that genuinely branches on ownership callsCajeta.owned(formal)(below). -
Returns. A method returning a class pointer stores a paired flag beside the return value. (This one is a thread-local: nothing runs between the callee’s
retand the caller reading it, so there is no window to corrupt.) -
Closures (title-tracking 7.2.5). A class-pointer-returning function value (
(T) -> #R) rides the same return flag: the synthesized lambda sets it by return shape — a fresh construction or#xhands out a title, a returned parameter/identifier is a borrow, and a tail call lets the inner call’s own flag ride through. Closure-call results arm the receiving local exactly like method-call results, andT x #= srcforwards a runtime owner’s flag ontox’s entry (a lent source stays lent through the hop).
Formals and call results are runtime owners. A class-typed parameter is not statically a borrow and not statically an owner — it is whichever the caller made it, so its drop entry is armed from its flag bit on entry. The same is true of a local initialized from a call: it arms from the return flag.
The consequences follow from that one rule:
- A surrendered argument the callee never consumes drops in the callee, on
whatever exit it takes — including a
throw, where the runtime unwinder walks the drop chain. - A lent argument leaves the entry disarmed, so the callee’s scope exit does not touch it. The caller still owns it.
#vinside the callee (a store, a forward, a return) consumes the formal: it reads the flag, deactivates the entry, and passes that same flag on. A forwarding chain therefore threads the caller’s decision all the way down — a value lent intoouterand forwarded with#toinnerarrives atinnerstill lent, and nobody frees it.
Ownership is never inferred from the body. The compiler could often guess (a lend at a local’s last use is usually a transfer the author forgot to spell), but guessing is wrong in exactly the cases that matter — a value handed to a spawned task outlives the frame that appears to be done with it. So the compiler advises instead of acting: see the last-use advisory below.
Dangling lends
A plain store or a plain argument lends. If the thing that received the lend then escapes the method, it escapes holding a pointer to a local that is about to drop:
Holder build() {
Holder h = heap Holder();
Cell s = heap Cell(5);
h.c = s; // lend: the title stays with `s`
return #h; // ERROR — CAJETA_ERROR_DANGLING_LEND
} // `s` drops here; the caller's Holder points at freed memory
The fix is to say what was meant: h.c #= s gives the holder the title.
The check is intra-procedural and deliberately conservative. It fires only when
the receiving callee actually retains the argument (stores it into a field);
a method that merely reads its argument — sb.append(s), list.contains(x) —
cannot strand anything and does not poison its receiver.
Last-use advisory (warning)
Lending a local at its final use is suspicious: nothing in the scope reads it
again, so the lend usually should have been a transfer. The compiler says so and
moves on — CAJETA_WARN_LAST_USE_TRANSFER, with a # fixit. It is a warning, not
an error: the build stays green, because (per above) the compiler cannot know
intent. A later read of the local suppresses it, as does a use inside a loop
(where the “last” textual use runs again next iteration), as does spelling #x.
Mode-only overloads are rejected
Transfer mode is not part of a signature — dispatch erases # — so two
declarations that differ only in mode collide:
void f(Cell c) { }
void f(#Cell c) { } // ERROR — CAJETA_ERROR_TRANSFER_MODE_OVERLOAD
Keep one. A plain formal already accepts both a lend and a transfer.
Anonymous-owner error
A chained access whose root is an unnamed temporary, where any intermediate produces a borrow, is a static error:
String name = factory.makeUser().getName();
// └ anon User (drops at end of expression)
// └ borrow into it — would outlive owner
// STATIC ERROR
Fix: bind the intermediate.
User u = factory.makeUser();
String name = u.getName();
Alias-mutation
A live borrow into a thing blocks mutation of (or through) that thing’s path. Iteration is recognized as a borrow construct:
for (String s : list) {
list.add(#thing); // STATIC ERROR — list has live iterator borrow
}
Drop order
LIFO within a scope; inner scopes drop before outer. A borrow declared before its source is a static error (source’s scope ends first; borrow would dangle).
Fields
- Fields may be owners or borrows. A field’s ownership status is resolved at drop time, not at declaration. The global live-set is the registry: every
heapallocation is recorded in it. At parent drop, the synthesized auto-drop wrapper calls each owned-shape field’s drop dispatcher directly; the dispatcher does an atomic claim (remove-if-present) on the field’s address. The first caller to claim an address frees it (and runs~Class()); a later caller for the same address — the owning local’s own chain pop, or another field aliasing it — finds it already gone and no-ops. Seedocs/specification/lang/FieldOwnership.md. - Field assignment.
p.field #= xandp.field = heap T(...)make the field an owner — the freshheapallocation is the live-set registration.p.field = ywhereyis a borrow stores the borrow; the field aliasesy’s source, and the live-set claim at drop ensures whichever path reaches the shared address first frees it while the rest no-op. - A plain store of a parameter into a field or element is rejected.
this.f = v, wherevis a plain (non-#) formal of a title-bearing class type, isCAJETA_ERROR_CAPTURED_BORROW_PARAM(spec §4.2) and does not compile: the field would borrow, while the armed drop entry freesvat callee exit — leaving the field dangling on exactly the calls that surrendered. The same holds forthis.data[i] = v. Spellthis.f #= vto record whatever title the caller handed over (the sink contract of §2.3 — howArrayListand the other collections opt out), orthis.f = v.clone()to keep a copy. Stores the check cannot reach — a nested path such asthis.head.prev = v, or a source that is a runtime-conditional owner rather than a formal — still warn (CAJETA_WARN_PLAIN_RETAIN_STORE). This was the most recurring use-after-free family in the stdlib before the diagnostic existed. - Field reads borrow.
String n = p.fieldmakesna borrow rooted atp. - Use-after-free of an aliased field whose source has already dropped is the programmer’s responsibility at v1. A lifetime tracker (Phase 6+) will catch this statically.
Destructors
A class can declare a destructor with ~ClassName(). The compiler runs it just before the instance’s heap memory is reclaimed — the canonical place to release the resources the instance owns:
public class Lock {
private pointer handle;
public Lock() {
this.handle = Cajeta.lockNew();
}
public ~Lock() {
Cajeta.lockDestroy(this.handle);
}
}
Rules:
- Identifier must match the class.
~Foo()insideclass Baris a compile error. Same convention as the constructor’s identifier. - No parameters, no return type. The body has no inputs to take and nothing to hand back.
~Lock(int32 x)is a parse error. - Not user-callable. Calling
obj.destructor()or similar from user code is rejected — destructors are invoked exclusively by the drop chain at scope exit. - Inside the body,
thisis live. Field access, intrinsic calls, even calls to other instance methods onthisall work. The instance hasn’t been freed yet. - Runs once. Each instance’s destructor fires exactly once, at the point the drop chain reaches its entry. If the instance was transferred via
#to another owner, the original owner’s drop entry is deactivated; only the new owner’s drop fires the destructor.
The runtime mechanism — “drop chain” — is the same machinery the borrow checker uses to reclaim arrays, closures, and other owned heap blocks. A destructor is the user-extensible hook into it: the compiler emits a per-class wrapper __cajeta_<ClassName>_drop that calls your destructor and then frees the instance’s memory. From a developer’s perspective, the contract is simply “write ~ClassName() if you have a resource to release; the language guarantees it runs at the right time.”
Limitations (v1 / known gaps):
- Virtual dispatch on drop. ✅ Done. The vtable header carries a dedicated
drop_fnslot (index 3, byte offset 16; seeStructureMetadata::createVirtualTableType). Heap class locals register the runtime helper__cajeta_class_virtual_drop, which loads the instance’s vtable pointer and dispatches throughvtable.drop_fn— soBase b = heap Derived()fires~Derived(), not~Base(). Pinned bytest/parser/VirtualDropDispatchTests.cpp. Stack allocations stay on static dispatch (alloca size fixes the dynamic type); Task-style custom layouts opt out via CajetaClass::hasVtablePointerAtSlotZero(). - Automatic field drops via live-set claim. ✅ Done.
CajetaClass::getOrCreateDropFunction→emitDropBodyInlinesynthesizes an auto-drop body that, in reverse declaration order, calls each owned-shape field’s drop dispatcher directly:__cajeta_free_array(arrays),__cajeta_iface_drop(interface fields),__cajeta_class_virtual_drop(class-ref fields). Each dispatcher does an atomic claim against the global live-set (__cajeta_live_set_claim): the first caller to claim a field’s address frees it and runs its destructor; any later caller for the same address (the owning local’s chain pop, or another field aliasing it) no-ops. That claim is what keeps aliased stdlib fields —ArrayStream.dataaliasingArrayList.data,Optional.value,Pair.first/second— from double-freeing. Doctrine and walk-throughs indocs/specification/lang/FieldOwnership.md. Pinned bytest/parser/AutoFieldDropTests.cpp. - Implicit destructor chaining — shipped 2026-05-21. ✅ Done. C++ semantics. The compiler-emitted heap-drop wrapper runs: (1) this class’s
~Class()body and its own field auto-drops (in reverse declaration order), (2) every transitive ancestor’s same body+own-field contribution in reverse-DFS deduped order, (3)__cajeta_free(instance). Each ancestor runs exactly once — diamond-shared ancestors via the vbase ABI’s single canonical sub-object are visited only on whichever branch sees them first. Chaining is automatic and non-suppressible.super<Base>.~Base()may be written explicitly for documentation, but it does not change codegen. The stack-drop wrapper has the same shape minus the free. Implementation:CajetaClass::emitDropBodyInline+CajetaClass::collectDestructorChain, both called fromgetOrCreateDropFunctionandgetOrCreateStackDropFunction. Pinned bytest/parser/DestructorChainTests.cpp(9 tests, including multi-inheritance reverse-decl order and the diamond runs-once case). - Block-scoped firing. ✅ Done. Drop entries fire at the closing
}of the declaring lexical block, not method exit. RAII patterns like back-to-backLockGuards in inline blocks now work. Pinned bytest/parser/BlockScopedDropTests.cpp.
No try-with-resources
Cajeta does not have Java’s try (R r = …) { … } syntax. It was briefly in the grammar and was removed 2026-05-20 as strictly redundant: destructors already guarantee deterministic cleanup at the closing } of the declaring block, including LIFO order across multiple locals and on exception unwind. The Java construct exists because Java has GC and no destructors — AutoCloseable.close() needs an external guarantee-mechanism that the drop chain already provides here.
The replacement pattern is “just declare the resource”:
{
FileReader r #= File.openRead(in);
FileWriter w #= File.openWrite(out, OpenMode.WRITE);
int32 n = r.read(buf, 4096);
while (n > 0) {
w.write(buf, n);
n = r.read(buf, 4096);
}
// w.~FileWriter() fires here (flush + close), then
// r.~FileReader() (close). LIFO order, guaranteed on every
// exit path (return, throw, fall-through, break).
}
r.close() is still callable for early release — destructors are idempotent (the standard Phase-A FileReader / FileWriter / File contract: this.fd = -1 after the first close, subsequent calls no-op). Catch blocks still work without modification:
try {
FileReader r #= File.openRead(p);
process(r);
// r drops here on the normal path.
} catch (IoException e) {
// r already dropped — the throw walked the chain back
// to the try-frame's watermark, firing every owned local
// along the way.
log(e);
}
Containers (stdlib convention)
Containers carry no ownership spelling in their signatures — they are the clearest case for caller discretion, since the same container is legitimately used both to own its elements and to index values owned elsewhere:
void add(T element)— the call site decides:add(x)lends,add(#x)gives the container the title. The entry records which, per element.T get(int32 i)— hands back whatever the entry holds: a borrow if the entry is borrowed, the title if it is owned.T remove(int32 i)— membership ends; the return carries the entry’s title if it had one.for (T x : container)—xis a borrow per iteration, bounded by the loop body.
There is no separate “borrowing container” type. One HashMap<K, V> holds owned and
borrowed entries side by side, and drops exactly the ones it owns.
Element slots carry their own ownership bits
“The entry records which” is not container bookkeeping — it is the language’s.
Element slots get the same treatment as fields: data[i] #= v records v’s title
in a compiler-managed per-slot bit, and the synthesized drop frees exactly the
slots whose bit is set.
public void add(T element) {
this.data[this.count] #= element; // slot records what the caller did
this.count = this.count + 1;
}
That is the whole implementation. The array’s own drop walks the bits; slots that
were lent are left alone, slots that were surrendered are freed. Writing a
container no longer requires a parallel owned[] sidecar, an @ElementCount
annotation to avoid dropping garbage past the live prefix, or any read of the
transfer word — all three existed only because slots could not remember their own
state. Field symmetry is the rule: whatever a store means for this.f, it
means for this.data[i].
Arrays that never receive a title store behave exactly as before and cost only
capacity/8 tail bytes. Slot-to-slot moves forward the bit verbatim — a borrow
stays a borrow — which is what shift, sift, and split loops need:
this.data[j] #= this.data[j - 1]; // move whatever title that slot holds
Cajeta.owned(formal) — branching on ownership
Cajeta.owned(v) returns whether this call surrendered v’s title. It exists
for algorithms that genuinely branch on ownership — an interning pool that adopts
what it is given and copies what it is only lent:
public String intern(String s) {
if (Cajeta.owned(s)) { return this.adopt(#s); }
return this.adopt(#s.clone());
}
Correctness never requires it. No container or store pattern should need
owned() to be leak-free and UAF-free — #= and slot bits do that bookkeeping
on their own. Treat it like instanceof: occasionally the honest answer, and a
design smell when it becomes load-bearing. If some pattern seems to need it for
correctness, that is a missing synthesis in the language — report it rather than
working around it.
Structs
A struct is a stack-allocated value aggregate (full spec: docs/specification/lang/Views.md). Its interaction with the memory model:
- Lifetime is the enclosing scope. A struct local is allocated via
allocaand drops at scope exit — same as any other stack-resident owner. - Fields participate in drop chain. A struct field that holds an owned class reference is itself an owner; when the struct drops, owned class fields drop in reverse declaration order before the struct’s bytes are reclaimed.
- Borrowed class refs are tracked. A struct field holding a borrowed class reference contributes to the path-borrow tracker; the borrow is rooted at whatever the field was assigned from.
- Embedded structs in class fields. A struct used as a class field lives inline in the class’s heap layout — no extra allocation. When the class drops, every embedded struct field drops with it (recursively).
- Field-path moves apply.
#s.fieldinvalidatess.fieldfor subsequent reads (existingmarkMovedPathmachinery); siblings of the moved field remain readable. - Pass-by-pointer. Structs cross call boundaries via the existing aggregate pass-by-pointer rule; defensive copies happen at callee entry only when the callee mutates the parameter.
The borrow checker treats structs uniformly with primitive locals — the only difference is that a struct’s bytes can transitively own other resources, which the drop chain unwinds in declaration order.
Views
A view is a typed overlay onto a borrowed byte buffer — an int8[] (full spec: Views.md). Two construction forms:
- Borrow form —
RpcHeader h = RpcHeader(buf);borrowsbuf.his a path-borrow rooted atbuf; the borrow checker enforceshcannot outlivebuf, the buffer cannot be mutated through any other alias whilehis live, andhcannot be sent to another fiber. - Owning form —
RpcHeader h = RpcHeader(#buf);transfersbuf’s ownership intoh. The view is now an owner; standard transfer rules apply (#h, drop-on-scope-exit drops the contained buffer).
Per-field access (h.magic, h.payload[i]) is a path-borrow rooted at the buffer — same machinery as struct-field access. Mutating one field while a borrow into another field of the same view is live is allowed (different paths); mutating the buffer directly while any view borrow is live is rejected (alias-mutation).
Construction-time bounds and length-prefix validation are documented in Views.md § Security; they are the load-bearing guarantee that makes per-access reads bounds-check-free.
Runtime: drop chain with watermark
Layout
Linked list of cleanup entries. The chain head is TLS + per-fiber: __cajeta_main_drop_top (declared __thread in cajeta_runtime.c) is the main thread’s head, and __cajeta_drop_top_ptr() returns the running fiber’s cajeta_fiber.drop_top slot when one is active, falling back to the TLS main slot otherwise. Each DropEntry is alloca’d in the declaring function’s frame, so a fiber’s entries sit on the fiber’s own stack and the per-fiber head pointer keeps the chains independent — safe under multi-carrier execution as long as each fiber’s chain is touched by at most one carrier at a time.
struct cajeta_drop_entry {
void* obj;
void (*drop_fn)(void*);
struct cajeta_drop_entry* prev;
char active;
};
// Main-thread head (per-fiber heads live in `cajeta_fiber.drop_top`):
static __thread struct cajeta_drop_entry* __cajeta_main_drop_top = NULL;
// __cajeta_drop_top_ptr() returns the running fiber's slot, or this one.
Each cajeta_drop_entry is stack-allocated (alloca) in its declaring frame.
Codegen patterns
Conceptual shape (the real push/pop go through runtime helpers
__cajeta_drop_push and __cajeta_drop_pop_run, which read the active
head via __cajeta_drop_top_ptr()):
// Owner declared — entry alloca'd in the frame, pushed onto the active head
cajeta_drop_entry e = { &obj, &drop_T, *top, /*active=*/1 };
*top = &e; // __cajeta_drop_push
// Move-out via `#`
e.active = 0;
// Normal scope exit (per-scope, LIFO over entries) — __cajeta_drop_pop_run
for each entry e in this scope, in reverse declaration order:
if (e.active && e.drop_fn) { drop_count++; e.drop_fn(e.obj); }
*top = e.prev;
// try block entry
exc_frame.drop_watermark = *top;
setjmp(exc_frame.buf);
// throw (runtime helper, before longjmp)
while (*top != exc_top->drop_watermark) {
if ((*top)->active) (*top)->drop_fn((*top)->obj);
*top = (*top)->prev;
}
longjmp(exc_top->buf, 1);
Special cases
- Drop-during-drop: if a
drop_fnthrows, the runtime aborts. Same as C++ noexcept-violation. - Move-out then throw: entries with
active = falseare skipped during unwind. - Catch handler’s locals: catch runs in the same function frame; owners declared inside the try block were unwound by the throw; owners declared before the try are still active.
- Rethrow: standard — walks up to the next outer try.
- Return through a try: normal scope exit pops + drops;
__cajeta_exc_popruns alongside.
Runtime checks (debug aids)
The runtime ships two opt-in debug aids that harden the drop machinery and help catch use-after-free during testing. Both are off by default and toggled at runtime, so they do not change object layout or ABI:
- Poison-on-free (
__cajeta_set_poison_free(1)). On every free, the reclaimed block is overwritten with0xDBup to its allocator-tracked chunk size beforefree(). A subsequent read through a dangling field sees the poison pattern instead of stale-but-plausible data. Implemented in__cajeta_poison_buffer. - Drop-chain validation (
__cajeta_set_drop_chain_validate(1)).__cajeta_drop_pop_runasserts the popped entry is the chain head and has a saneactiveflag, catching out-of-order pops, double-pops, and bit-rot (CAJETA_ERROR_DROP_CHAIN_*corruption traps).
Double-free across aliased fields is prevented unconditionally (not just in debug) by the global live-set claim described under § Runtime.
Planned (not yet built): a generation-counter borrow checker — every heap object carries a generation word, borrows snapshot it at creation, and each borrow access compares snapshot to current. This is the
R1proposal indocs/specification/lang/BorrowSoundness.md, not shipped today.
Transfer demotes its source to a borrow
# moves the title, not the binding. After a transfer the source is a
borrow of the same live instance — it did not die and it is still readable.
Tag orig = heap Tag();
Tag owner #= orig;
orig.setValue(5); // legal — both names denote ONE instance
Tag other #= orig; // error — you cannot transfer from a borrow
Both names point at the same object; only ownership moved. That makes the two
transfer errors one: transferring twice IS transferring from a borrow, so both
raise CAJETA_ERROR_MOVE_OF_BORROW.
You cannot transfer ownership more than once, or from a borrow.
#= is conditional acquisition, not a transfer operator. It means take
ownership if the source has it. Where ownership is statically known the
compiler decides; at a plain formal — whose ownership is fixed by the CALL
SITE — the store forwards whatever the caller did, so the same method body
serves a lend, a transfer, and a fresh construction.
The exposure this accepts: a borrow of a transferred binding dangles once the new owner drops it, and the compiler does not diagnose it. This is the same programmer responsibility the table below assigns to aliases generally.
Tag orig = heap Tag();
{ Tag owner #= orig; } // owner drops here; the instance is freed
orig.setValue(5); // use-after-free — compiles, faults at runtime
Errors caught statically (summary)
| Error | Caught by |
|---|---|
| Use-after-free | Scope-based lifetime check |
| Transfer from a borrow (incl. transferring twice) | CAJETA_ERROR_MOVE_OF_BORROW — a transfer DEMOTES its source to a borrow, so a second transfer is a transfer from a borrow |
| Reading a transferred binding | Not an error. # moves the title, not the binding: the source stays a readable borrow of the same live instance |
| Double-free | Impossible by construction |
| Alias-mutation invalidation | Path-based borrow tracking |
| Drop-order error | LIFO scope analysis |
| Borrow-of-frame-local returned | Signature conformance check |
| Anonymous-owner chained borrow | Expression-level lifetime check |
| Double-free of aliased field | Runtime live-set claim (see FieldOwnership.md) |
| Use-after-free of a borrow whose owner dropped first — including a transferred binding | Programmer responsibility at v1 (Phase 6+ lifetime tracker) |
| Escaping holder retaining a lend of a dying local | Single-hop dangling-lend check (CAJETA_ERROR_DANGLING_LEND) |
Plain argument at a must-own (#T) edge | CAJETA_ERROR_TRANSFER_REQUIRED |
| Two declarations differing only in transfer mode | CAJETA_ERROR_TRANSFER_MODE_OVERLOAD |
| Lend at a local’s last use (warning, not an error) | CAJETA_WARN_LAST_USE_TRANSFER — suggests #; never fails the build |
Deferred / out of scope (v1)
- Multi-threading: needs a
Send/Sync-style protocol. Runtime layout already accommodates per-thread state when added. - FFI safety: checker trusts FFI signatures.
unsafeescape hatch: not in v1.- Reflection / dynamic-dispatch borrow analysis: revisit when virtuals land.
- Cycles: forbidden by single-ownership; documented behaviour, no runtime enforcement needed.
Rollout
Existing Cajeta code (268 tests, all Java-idiom — no #, no borrow/owner distinction) doesn’t conform. Two paths:
- Migration (big-bang). Rewrite all tests + stdlib to use the new model in one PR. Cleaner end state; large patch.
- Opt-in legacy mode. Code without
#runs in “trust me” mode (no borrow checking); new code opts into checked mode via a file-level pragma or compiler flag. Migrate incrementally.
Recommend path 1. The codebase is small enough; the inconsistency of path 2 isn’t worth the maintenance overhead.
Implementation order:
- Parser: add
#token in value-prefix position; allow it as a type-prefix in parameter and return position. - AST: extend
Expressionwith amoveFlagbit; extend signatures with atransferredbit on parameter and return types. - Static analysis pass: add a borrow-checker that runs after
resolveTypes. Intra-function first (scope + path tracking); inter-procedural elision second. - Runtime: define
DropEntry, add push/pop helpers, extend the exception frame with adrop_watermarkfield. Update__cajeta_throwto unwind drops before longjmp. - Codegen: at each owner declaration, emit DropEntry alloca + chain push. At each scope exit, emit pop+drop. At each
#move-out, emitactive = false. At each try-block entry, save watermark. At each return, emit drops for the function’s still-active owners. - Migration: rewrite stdlib runtime helpers (string concat, substring, etc. currently leak) to integrate with drops. Rewrite test suite to use the new ownership idioms.
Known gaps (post-v1 rollout)
The rollout left a few items deliberately out of v1 scope; they’re called out here so future work knows where to pick up.
- String stdlib helpers leak fix. ✅ Done.
LocalVariableDeclarationrecognizes the owned-allocating shapes — binary+lowered to__cajeta_str_concat, and the routed method intrinsicssubstring/toUpperCase/toLowerCase/trim/replaceon a String receiver — and registers__cajeta_freeas the local’s drop fn. String literal aliases andp.namefield-reads remain borrow-shaped (no drop). Pinned bytest/parser/OwnedStringDropTests.cpp. The pragmatic detection at the assignment site replaces the proposed type-systemOwnedStringflag for v1; the flag can land later if a non-LocalVariable owner site appears. - Alias-mutation through writes. ⚠️ Partial.
Scopecarries aliveBorrowsmap (path → borrower set) populated byLocalVariableDeclarationfor pointer-shaped path-read initializers;Scope::findInvalidatingBorrowexists but is not wired up — it has no callers, so no diagnostic fires for an overlapping write today.AliasMutationBorrowTestsaccordingly only asserts that these programs COMPILE. (Verified 2026-08-03 while retiringCAJETA_ERROR_USE_AFTER_MOVE; the gap predates that work.) Pinned bytest/parser/AliasMutationBorrowTests.cpp. - Multi-parameter borrow-return with annotation. Today multi-input free functions can’t return a borrow at all. Rust-style explicit lifetime annotations would lift this restriction; not part of v1.
- FFI /
unsafe/ multi-threading. All explicitly deferred. - Static class fields landed. ✅ Done.
public static int32 total = 0;emits an LLVM global named<class.canonical>.<fieldName>in the declaring class’s home module viaCajetaClass::getOrCreateStaticFieldGlobal. Cross-module references go throughensureGlobalInModule.DotExpressionshort-circuits class-name LHS lookups incanonicalMapand returns the global as an l-value for reads/writes;loadIfLValuetreatsGlobalVariablelike a GEP slot. Static-property literal initializers (= 100,= -7, float literals) are constant-folded into the global’ssetInitializer; complex expressions fall back to zero. Statics are skipped from instance struct layout. Pinned bytest/parser/StaticFieldTests.cpp(9 tests) plustest/parser/LambdaStaticCaptureTests.cpp(2 tests) for lambda-body access through globals (no captures-struct routing needed).