docs / specification / lang
Ownership Transfer at Call Sites
AMENDED (2026-07-03,
slice-spec.md§9): with thesharedstate,#transfers an owning stake — formerly always the unique ownership, now possibly one stake among co-owners of an immutable slice backing. A#-move is rc-neutral (memcpy + source-drop deactivation, no count traffic — a codegen path distinct from copy). Everything below is unchanged in shape; read “ownership” as “stake.”#remains the zero-cost escape path (list.add(#s)costs neither a copy nor a count); the explicit trichotomy is borrow (=) / move (#) / copy-detach (clone()), and share has no spelling — it is exclusively a compiler-inserted resolution.
Status. Phases 1–3a (caller-side
#xsyntax, the(#T, x)rejection, and the body-side return/re-transfer escape check) are shipped:CAJETA_ERROR_TRANSFER_REQUIREDandCAJETA_ERROR_BORROW_PARAM_ESCAPESare thrown fromMethodCallExpression.cpp,CreatorRest.cpp, andStatement.cpp. Field-store of a parameter is no longer deferred: it is governed by the field-store rule (specs/field-store-ownership-spec.md), described below. One body-side shape remains deliberately deferred — closure-capture of a borrow (see the end of this doc andBorrowSoundness).
Motivation
Cajeta’s class-typed locals carry a drop entry tied to the declaring scope. A function call that hands the local off to a callee — where the callee will store it into a field, push it into a collection, or otherwise retain the reference past the call — needs the caller’s drop entry deactivated. Without that, the original local AND the callee’s retained reference both own the same allocation; both drop chains fire at scope exit and produce a double free.
The codebase already had one half of the answer: MethodCallExpression.cpp (the #-transfer block, ~5881+)
deactivates the caller’s drop entry when the callee’s formal parameter is
marked #T. The other half — what shape this takes at the source level
when both ends want to participate — is what this document specifies.
Use cases that drive the design
Two real shapes coexist in cajeta code, and a one-sided model breaks one of them:
Owning containers. Pair<K, V>, JsonValue.setArray/setObject,
ActionResult.output — these hold the value in storage that outlives the calling
frame, and cannot cope with the caller keeping the title. The signature
requires transfer: heap Pair(k, v) without # is an error, because
silently transferring would let a caller-side typo (using the local twice)
slip past the type system. Wrappers that merely hold a value for the
caller’s convenience — Optional<T>, Mutex<T> — do not need that
rigidity, and their ctors take plain T.
Index / cache / view collections. A HashMap<EntityId, Entity> used
as an index alongside a primary EntityWorld owner shouldn’t force
transfer at map.put(id, entity). The map indexes; the world owns. Same
HashMap type, same put signature, two ownership stories: caller
decides per-call. A callee-side #V here collapses one of the two
stories.
The 0.15.0 uniform-
#K/#Vexperiment was itself reversed. For one release the stdlib declared every collection key and element parameter#K/#V; that is no longer the language. Collections are not containers — they can be treated as containers by a developer who elects to transfer in, but they are not designed to own by default.HashMap.put,HashSet.add,Cache.put,BPlusTree.put,RedBlackTree.put,Heap.push, theArrayListmutators and theLinkedListmutators all carry plainK/Vformals again, somap.put(k, v)lends andmap.put(#k, #v)transfers — the caller decides per call, exactly as this section describes. Genuine containers, whose storage outlives the caller’s frame, still declare#formals and are correct as they stand:Pair,HashMap.operator[]=,ActionResult.output, andJsonValue.setArray/setObject(the DOM owns its children). Code that uses a collection as such a container — theDnsCacheentry store,ActionResult.outputsMap— spells the ownership at the call site instead:this.store.put(#key, #entry).
The model
Superseded in part (rev 2). The two-sided model below was the original design: the callee declared the contract, the caller acknowledged it. It was revised once the consequence became clear — a signature that fixes the transfer mode asks the author to predict how every future caller will use the method, and for containers (where the same
putis legitimately used both ways) the honest endpoint was to mark every parameter “either.” Transfer is now the caller’s decision at every call site, and signatures carry no ownership spelling by default.#Ton a parameter survives as an opt-in must-own edge. Current semantics live inMemoryModel.md§ Function signatures: the transfer ABI; the rejected shapes and their rationale are in the title-tracking spec §4.6. The rest of this document is retained for the design history and for the caller-side#exprstory, which is unchanged.
-
Caller-side
#expr— transfer at the use site. The caller is saying: “I am surrendering this here; deactivate my drop entry.” It’s the symmetric partner of the storage-class annotations on construction (heap/stack): storage class on the construction expression, transfer class on the use expression. Both are expression-level decisions, both visible at the source line. This is the whole model now — the caller’s spelling is what decides. -
Callee-side
#T param— opt-in must-own. Retained only 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. It refuses a lend (CAJETA_ERROR_TRANSFER_REQUIRED). It is no longer the way to permit a transfer — a plain formal already accepts one. The store that keeps it is still#=, because the formal frees its argument at return. -
Callee-side
^T paramis borrow-only. The callee receives a borrow on every call and may keep it with a plain=. It refuses a transfer or an owned temporary (CAJETA_ERROR_TRANSFER_INTO_BORROW_PARAM).
Interaction matrix (rev 2)
| Callee formal | Caller writes | Behavior |
|---|---|---|
T param | x | Lend — the caller keeps the title and drops the value at its own scope exit. The callee may keep it with this.f #= param, which records a borrow. A field that outlives the caller’s value dangles, and no check catches that. |
T param | #x | Transfer — the caller’s drop deactivates; the callee is a runtime owner. If it consumes the value (#param into a field / a forward / a return) the title moves on; if it doesn’t, the value drops in the callee. |
#T param | x | Compile error CAJETA_ERROR_TRANSFER_REQUIRED — the must-own edge refuses a lend. |
#T param | #x | Transfer — same as row 2; the signature simply made it mandatory. |
Note what changed: row 1 no longer forbids the callee from storing the value, and
row 2 is reachable without any signature change. The callee learns which row it is
in at runtime, from a hidden per-call flag (MemoryModel.md § the transfer ABI).
Why keep #T at all?
Discoverability, in the narrow case where it is genuinely load-bearing. A reader of
public Pair(#K first, #V second) knows on the signature line that
calling this surrenders. But that is a documentation win, not an enforcement one,
and it is only worth the rigidity where a borrow is truly unusable — so it is opt-in
rather than the default.
Borrow-checker rules
Title-tracking Unit 7 amendments. (a)
#is valid only on a FORMAL and on a RETURN type. It carries no meaning in any other type position — type arguments, local declarations and type parameters all error withCAJETA_ERROR_TYPE_TRANSFER_RETIRED; ownership is per-call, spelled at call/store sites. (b) An authored#Tformal is a hard must-own edge in EVERY instantiation — the old borrow-mode dissolution is gone. (c) Rule 2 below is retired for class-typed formals (see the rev-2 note further down): a plain formal is a RUNTIME owner, so#paramforwards whatever flag it actually holds. (d) Rule 3 below no longer lets a#Tformal be stored with a plain=. A#Tformal frees its argument at return, so the store that keeps it is#=(see the field-store section below).
The new rule is paired:
-
At the call site: if formal is
#Tand the argument is not#expr-prefixed, throwCAJETA_ERROR_TRANSFER_REQUIRED. If formal is plainTand argument is#expr, deactivate the caller’s drop entry (same machinery as today’s#T-formal path). -
In the callee body: if a formal is plain
T(class-typed), the parameter is a borrow. The body cannot:- Store it into
this.field = param(field-store). - Return it from a method whose return type isn’t
&T/ borrow-shaped. (Borrow-returning multi-param check already exists; extends to single-param when the source is a plain-Tparam.) - Capture it in a returned closure / lambda that outlives the call.
- Pass it as a
#-marked argument to another call (that would transfer the borrow ownership, which the caller didn’t authorize).
Violation:
CAJETA_ERROR_BORROW_PARAM_ESCAPESat the offending site in the body, with a fix-it: “mark the formal#Tto require transfer, or restructure to not retain the borrow past the call.” - Store it into
-
#Tformal: inside the body,paramis owned. All four restrictions in (2) lift — store, return, capture, re-transfer freely. The parameter participates in the body’s drop chain the same way an inline local does, so if the body neither stores nor transfers it, the parameter’s drop fires at the body’s scope exit.
Lifetime elision: why a multi-parameter free function cannot return a borrow
A borrow-returning function does not say whose memory the result points into — it is inferred from the parameters. With exactly one reference parameter the inference is forced: the result can only borrow from that one, so its lifetime is the argument’s and the caller already holds it. That is cajeta’s whole lifetime-elision rule, and it is why the single-parameter case needs no annotation.
With more than one reference parameter the inference has no unique answer.
Str pick(Str a, Str b) might return a, might return b, and the compiler
cannot tell which — so it cannot tell the caller which argument must outlive
the result. Rather than guess, or demand explicit lifetime annotations, cajeta
rejects the shape (CAJETA_ERROR_BORROW_RETURN_MULTI_PARAM):
// error: no single parameter's lifetime is implied
public static Str pick(Str a, Str b) { ... }
// fine — one reference parameter, the lifetime is inferred from it
public static Str first(Str a) { ... }
// fine — the result is a TITLE, so it borrows from nothing
public static #Str join(Str a, Str b) { ... }
The fix is almost always the third form: a function combining two inputs is
producing a new value, not lending one of them back. The # goes on the
RETURN type; for a type parameter the spelling is #T. There is no #? —
? is the wildcard sentinel, not something you can write.
Value returns are exempt. An sret-constructed result (return stack Optional<R>(...)) is copied into the caller’s slot and borrows from nothing,
so Tasks.withTimeout(Duration, Task<R>) -> Optional<R> is legal.
Grammar
The arg-expression rule extends to admit a leading #:
arguments : '(' (argument (',' argument)*)? ')'
argument : parameterLabel? REFERENCE? expression # REFERENCE = '#'
# may prefix any argument expression — a bare identifier, a field read
(#this.field), an element read (#arr[0]), a call result (#make()), or
an unnamed temporary such as #("k" + i). Nothing has to be hoisted into a
named local first. The drop-tracking follows the identifier, field and
temporary shapes precisely; #arr[i] where the element is itself a borrow
is a known defect — #arr[i] moves out of a borrowed element, which
double-frees — and
is still under work.
Implementation phasing
The work breaks into three independent commits, each shippable on its own (each tightens the model without breaking valid prior code):
Phase 1 — caller-side #x syntax
- Grammar: add
REFERENCE? expressionto the argument rule. - Parser/AST: capture the REFERENCE flag on each
ParameterEntry/ argument node. - Codegen: in
MethodCallExpression.cpp(the#-transfer block, ~5881+) and the matching block inCreatorRest.cpp(the #67 ctor-call-site transfer), trigger the drop-deactivation when the argument carries the caller-side#. A#Tformal does not itself deactivate anything — it only rejects an argument that lacks#(CAJETA_ERROR_TRANSFER_REQUIRED), after which the caller’s#performs the transfer. The rule is asymmetric:#at the call site is always sufficient, and a#Tformal is never sufficient on its own. - Tests: caller-side
#xagainst a plain-Tformal deactivates the drop correctly (no double-free); without#xthe source stays owned by the caller (the existing borrow lifetime applies).
Backwards-compatible: every existing call site (no # on args) keeps
working. New syntax is opt-in.
Phase 2 — (#T, x) rejection at call sites
- Add the
CAJETA_ERROR_TRANSFER_REQUIREDcheck at both call sites: if formal is#Tand the argument isn’t#-prefixed, throw with the fix-it message. - Update the stdlib call sites that construct a wrapper whose ctor
actually requires transfer. As shipped that is
Throwable(#String message)alone —Optional(boolean present, T value),Mutex(T initial)andSelectResult(int32 index, T value)all take plainTand carry the caller’s mode through#=, so they need no#at the call site (a string literal or other fresh rvalue passes plain even to a#Tformal). - Tests: the matrix cases —
(T, x)borrow;(T, #x)transfer;(#T, x)rejected;(#T, #x)transfer.
This is the breaking change. Land it as a single commit per region (stdlib first, then user code) to keep blast radius contained.
Phase 3a — body-side borrow-param escape check (return + transfer)
Retired in rev 2.
CAJETA_ERROR_BORROW_PARAM_ESCAPESno longer fires for class-typed formals. The check rested on “a plain formal is a borrow, so it has no title to give away” — and under caller discretion that premise is false: a plain formal is a runtime owner whose title is whatever the caller handed it.#paramis now legal and forwards the flag it actually holds, so a lent value forwarded onward arrives lent, and nothing double-frees. The hazard the check was aimed at (a borrow escaping past its owner’s scope) is now caught where it actually occurs — at the escape, by the dangling-lend check — rather than by banning the forward. Non-class formals keep the old rule.
- Extend the existing
BORROW_RETURN_*checks to fire when a plain-Tparameter flows into:- A return statement against a
#T-return signature (callee promises ownership transfer but returns a borrow it doesn’t own). - A
#-prefixed argument to another call (callee claims to transfer a value they don’t own).
- A return statement against a
- Tests: each escape shape reports
CAJETA_ERROR_BORROW_PARAM_ESCAPESwith the fix-it. The borrow pass-through shapes (plainTreturn + plainTparam +return p;, and#Tformal +#xtransfer) continue to work.
Phase 3a catches the two escape shapes whose alternative — silently accepting them — produces double-free or use-after-free at runtime far from the offending source line. The remaining two (field-store of borrow, closure-capture of borrow) are deliberately deferred to a future phase pending the reference / lifetime feature that would let the language reason about whether the storing object’s lifetime is bounded by the borrow’s lifetime.
Field-store of a parameter: the field-store rule
A store into a field, an element slot or a static outlives the call, so the
compiler checks what it keeps. CAJETA_ERROR_CAPTURED_BORROW_PARAM is retired
and replaced by these rules.
- A formal kept with
=must be spelled^T. A^Tformal is a borrow on every call. A caller cannot pass#x, a freshheapvalue, an owned#Tresult or a+String into it (CAJETA_ERROR_TRANSFER_INTO_BORROW_PARAM). - A
Tor#Tformal that is kept is stored with#=.set(x)lends andset(#x)transfers, and the field records which. - An owned local (bound from
heap, a call,#=or a+) stored into a field or slot that outlives it is stored with#=. - A value read out of a
Tor#Tformal, or out of an owned local (p.child), is never stored with=. For a formal, spell it^T. For either,#=takes the title the slot held. - Strings follow every rule here, like every other type.
=never moves. There is no last-use move, so an owned local stored with=is rejected even when nothing reads it again.- A producer written in place takes the title with
=: a literal, a primitive,null, aheap T(...), a call, or a String+. - A local holder whose field or slot is stored into counts as a field when
it escapes: returned, passed or stored with
#, or stored into a field, a slot, a static, or another holder that escapes or is not the frame’s own. A holder bound from a parameter, a field read or a call is not the frame’s own. A holder declared in an outer block that keeps an owned local from an inner block is checked too. A holder that stays local is exempt.
Rule 4 for a T or #T formal reports
CAJETA_ERROR_INTERIOR_KEEP_NEEDS_BORROW_PARAM. Every other violation
reports CAJETA_ERROR_KEEP_NEEDS_SHARP_STORE. The rules apply to a direct
this.field = p, to this.slots[i] = p, and to a nested path such as
this.head.prev = p. Declaring the formal #T does not make a plain =
safe, because a #T formal frees its argument at return. The sink model
(§2.3) is rule 2, and it is how Optional and ArrayStream are written.
public void set(T v) { this.value #= v; } // the caller chooses
public void watch(^T v) { this.seen = v; } // only ever a borrow
How the stdlib collections spell it. Collection key and element parameters are plain
K/V:HashMap.put,HashSet.add,Cache.put,BPlusTree.put,RedBlackTree.put,Heap.push, theArrayListmutators (add,insert,appendAll,set,operator[]=) and theLinkedListmutators (add,addFirst,addTail,addHead).map.put(k, v)lends;map.put(#k, #v)transfers; both are legal and the call site decides. A per-entry title bit records which one an entry got. Wrapper ctors are plain as well —Optional(boolean present, T value),Mutex(T initial).The alternative — uniform
#K/#V, shipped for one release as uniform-transfer-semantics (0.15.0) — was weighed and reversed. The argument for it was that an entry whose ownership is decided at the call site has to be recorded, and then teardown, eviction, replace, rehash and remove all branch on that bit; the bit produced a double-free/leak family (Cacheevicting a borrowed value;HashMap.removedisplaced-releasing one) that no static check could sort out. Owning uniformly removes the branch, but it also removes the indexing story, and the language owner’s ruling is that collections are not containers: they can be treated as containers by a developer who opts to transfer in, but they are not designed to own by default. The per-entry bit stays, and the branches with it.Genuine containers — storage that outlives the caller’s frame — do still require
#, and are correct as declared:Pair,HashMap.operator[]=,ActionResult.output, andJsonValue.setArray/setObject, where the DOM owns its children. A collection standing in for one (theDnsCacheentry store) elects the same ownership at its call sites.The residual exposure is a lifetime one: a short-lived borrow parked in a longer-lived collection dangles once its owner’s scope exits, and no compiler check catches it today (
MemoryModel§1.7). That is the lifetime tracker’s job.
The unsoundness window for plain user-class field stores — parking
k in something that outlives k’s primary owner — is the same one
Rust closes with lifetime annotations. Cajeta will close it the same
way once reference types land; until then, the safety burden sits with
the caller exactly where it does in C and pre-borrow-checker code.
See BorrowSoundness for the interim static
lint and debug-build runtime checks that target this gap without
waiting on reference types.
Closure-capture of borrow: deferred to the lifetime work
return () -> useParam(borrowedParam); — capturing a borrow in a
returned closure that outlives the call — is the same lifetime
question in closure form. The existing CAJETA_ERROR_BORROW_ESCAPE
already catches returned closures with borrow captures from
function-typed locals; the parallel for borrow PARAMETERS is part
of the same future work as field-store. BorrowSoundness
covers the same lint + debug-runtime strategy for the closure shape.
Backwards compatibility
Phase 1 alone is purely additive — no existing code breaks. Phase 2
breaks code that constructs #T-formal types without the corresponding
#x argument; the compile error is unambiguous and the fix is
mechanical (add # at the argument). Phase 3 catches new classes of
bugs that previously slipped through to runtime; it may break code that
was silently relying on a stored-borrow happening to live long enough.
The phasing lets a project upgrade incrementally:
- Pull in Phase 1 — gain expressive transfer syntax with no breakage.
- Add
#xat known transfer sites (warnings can guide this). - Pull in Phase 2 — call-site contract becomes enforced.
- Pull in Phase 3 — body-side promise becomes enforced.
Recovery of the per-call ownership story
Restating the use case the design serves:
EntityWorld world = heap EntityWorld(); // primary owner
HashMap<EntityId, Entity> byTransform = ...; // secondary index
HashMap<EntityId, Entity> byRenderable = ...; // another secondary index
for (Entity e : world.spawned()) {
// Index without transferring ownership — world still owns e:
byTransform.put(e.id, e);
byRenderable.put(e.id, e);
}
// Later, transferring the same entity into an `Optional<Entity>` slot
// genuinely takes ownership (the world surrenders this one):
Entity claimed = world.remove(id);
Optional<Entity> held = stack Optional<Entity>(true, #claimed);
HashMap.put(K, V) is declared with plain K, V — both index calls
above borrow. The Optional ctor is plain T as well, so the #claimed
is the caller electing transfer rather than obeying the signature — and it
is the semantically correct election (the world is handing this entity off;
the Optional is the new owner). Against a #T formal such as
Pair(#K, #V) the same # would additionally be required.
The index above stores borrows, which is legal and intended — but it is
the caller’s job to keep world alive for as long as the indexes are read.
A short-lived borrow parked in a longer-lived collection is an ordinary
lifetime error, and the compiler does not yet diagnose it.
Without the two-sided model, you’d either lose the index story (callee forces transfer) or lose the contract enforcement (callee can silently keep a borrow). Both halves earn their place.
Open design questions
-
#exprfor non-identifier sources. Shipped. Caller-side#prefixes an arbitrary argument expression — the grammar isparameterEntry : parameterLabel? REFERENCE? expression(antlr4/CajetaParser.g4), and#this.field,#someCall()and unnamed temporaries such as#("k" + i)all transfer correctly. The residual issue is narrower:#arr[i]where the element is a borrow rather than an owned slot is theowned-array-element-movedefect, and the title tracking for that shape is still open. -
#Ton the return position — CLOSED (spec §4.6, 2026-08-15). The caller-side acknowledgement exists and is required: a#Tresult must be received with#=(Foo x #= somefn();); a plain=isCAJETA_ERROR_OWNED_RESULT_NEEDS_TRANSFER(src/cajeta/ownership/OwnedBindCheck.cpp), enforced at BOTH positions — the declaration (T x = f()) since plan 8.2.7 and the assignment (x = f(),this.f = f(),arr[i] = f()) since plan 8.2.12. It is load-bearing exactly because it makes an acquisition legible without opening the callee:int8[] w #= s.toBytes()frees,int8[] w = s.root()does not. (root(), nottrimView()—trimViewreturns a fresh borrow-WINDOW wrapper and is truthfully#String;root()is the plain borrowed handle.) Note the marker goes on the BINDING, not the type —#Foo x = somefn();isCAJETA_ERROR_TYPE_TRANSFER_RETIRED. -
#Tand templates. When the formal is#Twhere T is a template parameter, the requirement should apply only when the instantiated T is class-typed (transferring a primitive is a no-op). The codegen path naturally handles this — the deactivation is gated on the local having a drop entry, and primitive locals don’t — but the rejection in Phase 2 needs the same gate so primitive instantiations don’t get spuriousCAJETA_ERROR_TRANSFER_REQUIRED. -
Plain return of a frame-owned interior slot — DECIDED (plan 8.2.25, 2026-08-17): lint, never an error, no runtime protection.
Cell f() { Cell[] a #= heap Cell[1]; a[0] #= heap Cell(5); return a[0]; // borrow of storage this frame frees }compiles, and the caller’s first read is freed memory: the array owns the armed element, and the array’s scope-exit drop takes it down before the return value is ever used. This can never be a hard error — slot ownership is runtime state, and
a[0] = longLived; return a[0];(a LENDING store) is the same syntax and perfectly legal. The compiler instead shipswarning: [plain-return-of-owned-slot]for the provable subset: the return expression isa[i]with a literal index,ais a frame-owned array declared in the same body, and the arming store structurally dominates the return (arming inside a conditional or loop does not count, per §7.2’s never-block-on-cannot-prove).The correct spellings: declare the return
#Celland extract the title (return #a[0]— the slot’s bit decays and the array’s drop skips the element), or store the element un-owned (plain=) when the array was never meant to own it.Sharp edge, documented: shapes the proof cannot see — conditional arming, variable indexes, aggregates received from elsewhere — get no diagnostic and remain a genuine use-after-free at runtime. There is deliberately no runtime check: hot-path returns pay nothing for a defect a reader can find, and the lint catches the shape people actually write.
See also
BorrowSoundness— interim lint + debug-runtime detection for the deferred field-store and closure-capture shapes.MemoryModel— the broader borrow / ownership / drop chain model this extends.FieldOwnership— how fields participate in the drop chain (relevant to the body-side escape check).MethodCallExpression.cpp(the#-transfer block, ~5881+) andCreatorRest.cpp— the existing call-site transfer machinery this builds on.