Goose

Ownership without destructors: an own qualifier for pool handles and coroutine frames

Research note, November 2026. Nothing here is implemented, and every piece of syntax is a placeholder. The question was whether Goose can have a notion of ownership strong enough to settle what coroutines.md leaves open at R5 (a suspended coroutine can be dropped or copied, so no cleanup written inside it is reliable) and what llm_frontier.md §5.4 sketches for pools (every allocation freed exactly once), without giving up the property the language is built on: every value is plain data, a copy is a memcpy, a drop is nothing, and no code runs at a scope exit that the programmer did not write. The answer proposed here is that the notes use "ownership" for two different things, that only one of them is expensive, and that the cheap one is enough for both problems.

0. Summary

1. What the three notes decided, and where they meet

scope_exit_cleanup.md chose defer and rejected destructor types, for six reasons listed in its §4.7: a new value class touching every destination rule; destruction at every removal from every container; abandoned partial results; exclusion from everything that copies bytes; double destruction; and control flow at exits where the language has none. It recommended pool finalizers (§3.3) and scoped allocations (§3.4) next, and neither is built. defer landed as recommended, including the rule that a body may not use long-distance returns, checked with one transitive flag over the call graph (CheckDefers).

coroutines.md makes a frame an ordinary fixed-size value, copyable and droppable, by forbidding references into its own locals across a yield (R4). Abandonment is then free ("it owns no stack, there are no destructors, and whatever it names in pools is the pools' to free", §4.10), and defer is banned in scope at a yield (R5) because its block's exit would happen in a later resume, from a different resumer, if it happens at all. The server of its §5 frees a connection by convention: the loop that sees the frame finish calls conns.free on the connection the frame's parameter names. Nothing checks that it does, and nothing can free what a coroutine acquired inside its body after the first yield. The note's §9 keeps the defer question open and suggests a flag byte per defer.

llm_frontier.md §5.4 sketches owning handles: a pool operation returns an own T& that is consumed exactly once, the compiler frees what the program does not, and a release is a shrink for every plain reference derived from the handle. It is marked the most invasive item in that note, "a value class the language does not have (non-copyable), touching assignment, copy, literals, match by value, HOF captures, generics, return … from, defer, workers and images", at "several thousand lines", and ordered last.

The three meet at one sentence in coroutines.md §2.6: pools own what a coroutine cannot. That settles where a coroutine's resources live; it does not settle who releases them when the coroutine is abandoned, and every answer to that question is some form of ownership. The alarm the question raises is the one scope_exit_cleanup.md §4.7 wrote down: an owned value makes memory special. The rest of this note argues that it need not, because the expensive half of ownership is separable from the half that coroutines and pools need.

2. Ownership is two features

Take the Rust Box<T> as the full bundle: it cannot be copied (the type system), it must not be used after a move (a flow check), and when it dies the compiler calls drop (emitted code). The first two are an obligation: facts the checker establishes about the program text, which reject programs and emit nothing. The third is a release: a call the compiler writes at every point where a value of the type dies, which means the compiler must find every such point, in every container and on every exit path, and must know a function to call.

Apply the split to scope_exit_cleanup.md §4.7's six costs:

§4.7 cost Belongs to Why
1. a new non-copyable value class in every destination rule obligation the checker refuses a copy; codegen never sees one
2. destruction at every removal (pop_n, clear, shrinks, reassignment, a container's death) release each is a point where a value dies and a call must be emitted
3. abandoned partial results release a half-built result is dying values with no program text for them
4. exclusion from everything that copies bytes (queues, images, worker globals) obligation one predicate, as flat is one predicate
5. double destruction release only emitted code can run twice
6. invisible control flow and calls at exits release by definition

Four of the six are costs of the release alone. The two that are costs of the obligation (1 and 4) are costs Goose has paid once already, for values with self-relative links (section 3).

The distinction is not new. Austral's linear types reject a program that drops a linear value and never call anything on their own; the author reports the checker at 600 lines (llm_frontier.md §5.5). Wadler's original proposal is the same shape: linearity is a typing discipline, and what the compiler gets from it is permission (there, to update in place) rather than a duty (wadler). OCaml 5's continuations are linear at run time and released by nobody: "every captured continuation must be resumed either with a continue or discontinue exactly once", and "not resuming continuations will leak the memory allocated for the fibers as well as any resources that the suspended computation may hold" (ocaml-effects). Rust's async working group recorded the two readings of "linear" explicitly: a type-system #[must_use], or a guarantee that Drop runs (wg-async-linear). Goose wants the first reading, because the second is the thing its memory model deliberately has no room for.

What the obligation preserves, stated as the invariants the rest of the compiler assumes:

Nothing in codegen, the optimizer, the runtime, the JIT backend or the Wasm plan reads a type's own-ness. That is the sense in which the feature does not bleed: the set of files it touches is the checker and the front end. What it does reach is every place in the checker that judges a copy or a death, and that set is the subject of the next section.

3. The precedent: a non-copyable fixed-size class exists already

Reference §11 ("Copying values that hold self-relative links"): a value containing a T&<u32> "cannot be copied in any way: by assignment from a variable, as a by-value argument or match binder, as an element copy, or by copy(x)"; it is constructed in place, bound with .=, matched with &-binders, and returned only as a fresh value built at the receiver. The class is transitive through fields (HasRelRefT), so a struct holding such a value is non-copyable too. That is precisely the "non-copyable fixed-size value class" of §4.7 item 1, and the compiler enforces it today at these sites:

Where Function What copy it refuses
copy(x) NoRelRefCopy in the builtin the explicit copy
a return value NoRelRefCopy in CheckReturn copying out of storage; a fresh value built at the receiver is allowed (BuiltWhereReceived)
a function value's tail NoRelRefCopy a block's result
a value used as an rvalue NoRelRefCopy in the value path reading storage by value
a value coerced to an expected type NoRelRefCopy in CheckValue arguments, stores, literal fields, assignments
a let/var from a decayed reference result NoRelRefCopy let n = f() where f returns a reference
a let/var from an initializer NoRelRefCopy let n = other
.move_from, .resize's fill, .realloc_slice, .append HasRelRefT element copies inside builtins
the format hook's by-value parameter HasRelRefT the copy into a user overload
a payload passed by value to a case function HasRelRefT dispatch
a match arm binding a payload by value HasRelRefT &-binders required
a for binding an element by value HasRelRefT for &x in xs required
in-place decisions (BuiltInPlace, InitOrderStoreSafe, a temporary's contents) HasRelRefT where a value may be materialized
codegen: an assignment and two return paths NoSelfRelCopy assertions that the checker caught everything

Eight general sites, about ten builtin-specific ones, three assertions. Every one of them already asks "is this type non-copyable, and why?" and reports a message naming the fix. An own qualifier adds a second reason to the same predicate (a NonCopyable(t) returning which of the two) and a second message; no site is added for copying, because the list above is the list of copies the checker can see, found the hard way. This is the measured cost of §4.7's item 1, and it is small. It is also the one place where llm_frontier.md §5.4 says ordinary code changes ("reading it out of an array or a field is by reference, .=, &, and never by the silent copy"); that change is the one a Node with a Node&<u32> field already imposes, and the language has lived with it.

What self-relative values do not have is an obligation: they may be dropped freely, since they are just bytes. The obligation is the new part of own, and it is a flow property, not a copying one (4.4).

4. The design: own as a qualifier

4.1 What is owned

own qualifies a fixed-size type the way const qualifies one: own T&, own T[:], own T&<u32 in pool.items> (the hard case, excluded in v1, 4.6), and own S for a fixed struct such as a native handle. An own value is produced by an operation that declares its result own (a pool's take, an extern create) and consumed by an operation that declares a parameter own (the pool's release, an extern destroy), or by a few language positions (4.3).

The important sentence: what is owned is a claim, not memory. An own Node& into a slot_pool<Node> is eight bytes (four as a relative link) pointing at a slot whose bytes are as plain as any other slot's. The pool owns the memory and frees it at its own scope exit as today; release pushes an index onto a freelist as today; a plain Node& to the same slot is as valid after the release as it is now (type-safe staleness, §9.4), unless the program opts into the stronger rule of 4.8. The handle carries an obligation the checker discharges; the slot carries nothing. This is the answer to the alarm bells: no byte of memory becomes special, and no operation on memory changes. Like const, own is checked per specialization and invisible to codegen. Unlike const, it cannot be inferred, because an obligation is a promise the program makes, not a fact it exhibits; it must be written.

4.2 Not copyable

The sites of section 3, with own as the second reason. By-value for and match bindings over own values are errors naming the & form, as for self-relative values. let m = h is a move: h is consumed (4.3) and m is held. A struct with an own field is own, transitively. copy of an own value is an error. default<T>() for a T with a non-optional own field does not exist; with an optional one it is the empty handle (4.6).

4.3 What consumes

A held own value is consumed, once, by:

  1. passing it to a parameter declared own (by-value parameters of plain type reject it, by 4.2; reference parameters of plain type borrow it, 4.8);
  2. returning it, which hands the obligation to the caller;
  3. storing it into an own field: in a struct literal, or by replace (4.6);
  4. a let/var binding to a new name, which moves it;
  5. a defer body that does any of the above, which claims it: every exit of the block then counts as consuming it there, and any other consume is a double consume;
  6. the trusted end. A consumer has to end somewhere: slot_pool::release reads the index out of the handle and pushes it, and after that the obligation must simply stop. Two forms: an extern fn parameter declared own is consumed by the call (the C side takes responsibility, which is what destroy(handle) means), and a builtin available only to the standard library ends the obligation of a handle whose own fields are all empty (4.6). The stdlib already uses privileged builtins (check_length, range_index), so this is in character, and it keeps the set of trusted consumers to the pools and the externs.

index_of(h) yields a plain index with no obligation, as §5.4 rule 6 says; the integer escape hatch stays, and a program that mixes free(i) with own handles into the same pool has only the guarantees of the part that stays in the own world.

4.4 The flow rules

The checker keeps, for every own variable in scope, whether it is held or consumed, beside the definite-assignment bits the FlowState already holds and merges (JoinFlow, MergeFlow). The rules:

The error messages come out in the shape llm_frontier.md §3.9 found tractable for machine authors: a complete, local rule whose message names the fix ("h is held at this return; release it, return it, or claim it with defer release(h)").

4.5 Linear now, affine later

llm_frontier.md §5.4 proposed the affine default: where the program did not consume a handle, "the compiler frees it at the block's exit, as a defer it wrote itself". That is the one piece of the design that is a release in the sense of section 2, and it has a cost the linear form does not:

The recommendation here is the other order. The linear form is strictly smaller: it has no declaration to add, calls nothing the program did not write, and keeps "no code runs at an exit that is not in the source" true to the letter, since a defer is in the source. It is also what coroutines need (5.3 uses the claim rule and nothing else). The affine form is an increment that adds one declaration and routes it through emission that defer already has; it can wait for evidence that the double-consume restriction bites in real code, as errdefer is waiting. 4.9 sets defer beside the two forms.

4.6 Containers: the boundary where static becomes checked

The flow rules of 4.4 hold for variables, because a variable's history is in the program text. A pool element's history is not: a Conn in conns.items[i] was filled by some earlier call and will be freed by some later one, and nothing in the text of either says what its own fields hold. So at the container boundary the guarantee changes character, and the design has to say how.

Own fields. A fixed struct may have own fields of a kind with an empty value: own T&? (null) or own T[:] (the empty slice, which free_slice already accepts harmlessly). A literal provides each such field with an own value or leaves it empty. Reading one is destructive: take(e.buf) yields the own value and leaves the field empty; a plain read e.buf yields a borrow (a plain T&? or T[:], 4.8). Writing one is replace(e.buf, v), which consumes v and yields the previous content as an own value the caller must consume (release of an empty handle is a no-op, so p.release(replace(e.buf, v)) is the idiom). Cyclone's swap operator and Pony's destructive read are this (llm_frontier.md §5.5). A plain assignment e.buf = v is an error naming replace, because it would drop the old content. These are builtins, like copy; they add two sites to the checker and nothing to codegen (a take is a load and a store of the empty value).

Freeing an element. When a slot whose element has own fields is freed, something must consume what the fields still hold, or the guarantee has a hole exactly where pools are used. Three ways:

  1. A runtime check. free aborts if any own field of the element is non-empty, with the line. A load and a compare per own field, at a free, which is already a call. It turns the leak into an abort, as gen_pool turns a stale read into one; it is not static.
  2. A compiler-written release, the affine form of 4.5 applied to fields: free releases each non-empty own field through the declared release function. This is the "finalizer the compiler writes from the field types" of §5.4 rule 3.
  3. A finalizer the program writes, checked for completeness. The pool's consuming free takes a trailing block (Goose has them, and the stdlib's alloc_with already takes one), conns.release(h) { bytes.release(take(it.buf)); }, and the checker requires that at the block's end every own field of it is definitely empty: each take(it.f) records emptiness of the path it.f as a flow fact, in the path-keyed form the flow state keeps for kills already, and the trusted end (4.3 item 6) that follows the block in the library's release demands those facts. Since the block is spliced into the library function's specialization, the facts flow from the block to the trusted end with nothing new. The finalizer is written once per pool, next to the pool, and a forgotten field is a compile error there.

The third is scope_exit_cleanup.md §3.3's finalizer, with the completeness check that note could not have without own, and without the language hook that note needed, since a trailing block on release is library-expressible. It is the recommended form. The first is the fallback for a pool whose release the program does not want to write out. The second belongs with the affine increment.

Arrays. What makes an array different from a variable is bulk death: clear, pop_n, resize down, assignment of the whole array, and the scope exit of a local array end many elements at once, with no program text per element, so the checker cannot know what each element's own fields hold. The rules that keep this static, except for one compare:

The same applies to a pool itself: a local pool of own-holding elements must have every slot free at its scope exit (free_slots.len == items.len, one compare), since the finalizer block emptied each element as it was freed, or it releases them all through a library walk (conns.release_all { … }). The server's pools are globals and never exit. own in a plain global variable, outside an array or a pool, has no scope exit and nothing that could consume it, and is forbidden in v1.

4.7 Threads, images, generics, return … from

4.8 The other half of 5.4: a release is a shrink

§5.4's second idea is independent of the first and can be built after it: treat release(h) as a shrink of the slot for every plain reference derived from h (let r: Node& = h;), so that no such reference is used after the release or stored in a holder. The machinery is the grow-shrink rule of §5.2: references into a [>..<] live in variables only, and a shrink is an error while any such variable is used afterwards (UsedAfter, the store record, the shrink summaries through calls). A handle as a root for the references bound from it is the new piece: those references are rooted at the handle variable exactly, as &x is rooted at x, so the existing test finds them.

What it buys is the "no stale read through a reference" claim for handles that stay in the own world. What it does not reach is a stored relative link in another element, which §5.4 already concedes to generations. It adds no codegen either. It is listed here as a separable increment because the leak and double-free guarantees do not depend on it, and because it is the part of §5.4 whose checker cost is least clear until the first part exists.

4.9 own beside defer

Both attach a cleanup to the exits of a scope, so the overlap has to be stated. The forms differ in who forces the cleanup and who writes the release call:

form who forces the cleanup who writes the release call
defer alone nobody the call site, at every use
linear own the producer's declaration (-> own T&) the call site, as defer release(h); the checker verifies it is on every exit
affine own the producer's declaration the declaration, once per handle kind; the compiler emits the call

Under the linear rules, then, a defer is not a way to satisfy the checker. It is the release, and own is what makes omitting it, doubling it, or using the handle after it an error. The affine form moves the release text from every call site to one declaration, and leaves defer four jobs:

Two things the overlap gives:

On spelling, let h: own Foo? = init(); is longer than it needs to be. A local needs no ?: held or consumed is a flow fact, and ? is for fields, where emptiness has to be a state at run time (4.6). It needs no annotation either, since the type comes from the producer, so let g = lock(m); is the whole guard (the first question of section 9). What needs a rule is the unnamed guard, a bare lock(m); that drops its result. Under the linear rules that is an error already: an own value died unconsumed. The affine form should keep it an error and require a name, rather than release at the end of the statement, which is the mistake Rust's let _ = m.lock() makes: the guard is gone before the code it was meant to cover.

5. Coroutines

5.1 R5 restated

coroutines.md gives two reasons for R5 (no defer in scope at a yield): the block's exit "would now be in a later resume, from a different resumer", and (§4.10) a frame may be abandoned, in which case the exit never comes. The first is not a problem once a frame's locals are hoisted: a defer body that names a local keeps it live across the yield (R3), so it is reloaded in whichever resume reaches the exit, and which thread or loop that is does not matter, because the body names only the frame's fields and things outside the frame (R4). The second reason is the real one, and it has two halves: a suspended frame may be dropped, so the exit may never happen, and it may be copied, so the exit may happen twice. defer is the only construct in Goose whose meaning depends on both never happening, and a frame is the only value in Goose for which both can happen to code that has started running.

Stated this way, the fix is not a flag per defer (coroutines.md §9), which would make the normal exit run the right bodies but does nothing for a frame nobody resumes. The fix is a frame that can be neither dropped nor copied, and that is what an own value is. Two designs follow, one that keeps frames out of the own world and one that lets them in.

5.2 Design A: own joins R1

R1 says no nonfixed local may be in scope at a suspension point, because a suspended frame cannot own a data stack. Add: no own local may be in scope at one, because a suspended frame cannot discharge an obligation. The fix the error names is the fix §7.8 names for growable data: own it outside.

struct Conn { fd: i64, inbuf: u8[..4096], scratch: own u8[:] }   // the run is the connection's

coro fn serve(c: Conn&) resume Event {
    loop {
        guard fill(c) else { return; }
        {
            let s = bytes.take_slice(need);          // own u8[:], held
            parse_into(c.inbuf[..], s);
            replace(c.scratch, s) |> bytes.release;  // consumed before the yield below
        }
        guard flush(c) else { return; }              // yields inside
    }
}

// The loop frees the connection; the pool's finalizer block is checked to
// consume everything a Conn owns, so the frame never has to.
guard suspended else {
    conns.release(conns.handle_of(tasks[i].c)) { bytes.release(take(it.scratch)); }
    tasks.free(i);
    continue;
}

(Syntax placeholders throughout; |> is a stand-in for passing the replace result to release.) Frames stay plain: copyable, droppable, flat when their fields are, serializable, exactly as coroutines.md §4.9 and §4.10 describe. What changes: one item in R1, and the guarantee that every resource a coroutine uses is owned either by a local of one resume segment, which 4.4 discharges, or by an own field of a pool element, which the pool's finalizer discharges when the loop frees it. A leak is then only possible where the loop forgets the element, and that is one free in one function, which is where coroutines.md's convention already puts it.

What A gives up: a resource acquired before a yield and released after one must pass through a field between them, even a scratch run used only by the one coroutine. This is the discipline the language already asks for growable locals across a yield and across a cycle call, and the server shape wants it anyway (the kernel writes into the connection's buffer while the frame is suspended). defer stays out of coroutines at yields.

5.3 Design B: own frames and cancel

Let an own local cross a yield, and let a defer be in scope at one, under one rule: at a yield, every own local in scope is claimed by a pending defer (4.3 item 5). Then:

B is A plus three things: the frame type's own-ness derived from its yields, the cancel emitter, and the relaxation of R5 to "a defer in scope at a yield makes the frame own". What it buys is that cleanup can be written inside the coroutine, next to the acquisition, and is guaranteed to run exactly once, on the normal exit or on cancellation, whichever comes:

coro fn serve(c: Conn&) resume Event {
    loop {
        guard fill(c) else { return; }
        let s = bytes.take_slice(need);
        defer bytes.release(s);                      // claims s: runs at the block's exit, or at cancel
        parse_into(c.inbuf[..], s);
        guard flush(c) else { return; }              // yields inside; s is held, and claimed
    }
}

// The loop: hangup or shutdown cancels the task, which releases whatever
// its body holds at the point it is suspended; then both slots go back.
guard suspended else {
    tasks.release(tasks.handle_of(i)) { cancel(it); }
    conns.release(conns.handle_of(c)) { bytes.release(take(it.scratch)); }
    continue;
}

The first reader of that frame's generated C sees a serve_resume and a serve_cancel, each a switch over the same tag, and recognizes both.

5.4 What B would change in coroutines.md

Nothing in §4.4 (calling a coroutine from a coroutine) changes: a called coroutine's locals and defers are spliced into the caller's segments and tags, so a defer in the callee pending at the caller's yield k is simply among the bodies cancel runs for k.

5.5 The server, both ways

Design A Design B
where a connection's resources live own fields of the pool element the same, or own locals claimed by defer inside the body
who releases on hangup the loop, through the pool's finalizer block cancel, then the pool's finalizer block
frame type plain plain unless the body holds a resource or a defer across a yield
frames through queues, in images yes plain ones yes; own ones no
defer in a coroutine not at a yield anywhere; claims what it releases
what the compiler generates nothing new one cancel function per own frame type
rules changed in coroutines.md one line in R1 R1, R5, 4.9, 4.10 conditional; cancel added
the forgotten-release failure the loop forgets conns.release (one site) the same site, or a cancel the finalizer check forces

A is a subset of B: a program written under A is a valid B program with plain frames. So A can ship with the coroutine v1 and B can follow when a body wants to hold a resource across a wait, with no change to code written in between.

5.6 Prior art specific to frames

6. Where the compiler changes, and where it does not

For the linear design with A, then B:

Area Change Size, anchored on what exists
lexer, parser, AST, clone, dump, LSP, tree-sitter, reference appendix A the own qualifier on types, take/replace/cancel as builtins as const and defer were: parser 45 lines for defer
type predicates NonCopyable(t) with a reason, replacing the bare HasRelRefT test at the sites of section 3; IsFlat, ImageSafe the 8 + 10 sites change a call and a message; two one-line additions
flow a held/consumed bit in FlowState, joined in JoinFlow with the agreement rule, checked at exits with the definite-assignment exit logic; path emptiness facts for take the largest new piece: defer's flow work was 196 lines in typecheck_flow.h; Austral's whole checker is reported at 600 lines
consuming positions own parameters at call sites, return, own fields in literals, let moves, defer claims, the trusted end, extern parameters a case each where arguments, returns and literals are checked today
return … from one query of FnSpec::needs at calls while an unclaimed own is held reuses CheckDefers's flag
coroutines, A own in R1's class test one line, in the CycleCallTemps reuse step 3 of coroutines.md §8 plans
coroutines, B frame own-ness from the yields' scopes; a cancel function per own frame type: a switch over tags, each arm the pending defers via EmitDeferBody the one codegen change in the whole design; bounded by the number of own frame types
stdlib take/release/handle_of on slot_pool and slice_pool beside the existing operations, with the finalizer-block form of release tens of lines of Goose
spec a section beside §9.5 (writability) for the qualifier and the flow rules; pool additions in §5.4's successor; coroutines' R1/R5 comparable to §5.2
codegen of copies, assignments, exits, returns; optimizer; runtime; JIT; Wasm; threads; serialization nothing, apart from the length compare at the scope exit of a local array of own-holding elements (4.6) the invariants of section 2 hold by construction

The last row is the answer to the question this note was asked. The sites that must know about own are the checker's copy sites (already enumerated), its flow state (one bit), its exit logic (one query) and, for B, one generated function per own frame type. Everything that makes Goose's values plain stays as it is, because an accepted program never asks it to do anything new.

7. Directions, side by side

Direction What it is Guarantees What it rejects that compiles today Compiler Runtime
0. Status quo plus gen_pool generations in a library; finalizers and scoped allocations as scope_exit_cleanup.md recommends stale reads and double frees abort; leaks reported in debug nothing none a generation compare per lookup
1. Linear own, design A sections 3, 4 (linear), 5.2 a handle in the own world is released exactly once; a pool element's fields are released at its free; a coroutine holds nothing across a yield own in a plain global; a bulk removal of own-holding elements without a finalizer block; early release plus defer on one handle; own through queues and images; own across a yield checker and front end, as section 6 none
2. Direction 1 plus design B 5.3 plus: cleanup written inside a coroutine runs exactly once, on exit or cancel; own frames cannot be forked or forgotten plus: copying or queuing a frame that holds a resource plus one emitted function per own frame type none
3. Affine release 4.5 on top of 1 or 2 the same guarantees with the compiler writing the release the program omitted as 1, less the early-release restriction: a handle released on one path is released at the exit on the others (4.9) a release declaration per handle kind; emission through the defer path none
4. Destructor types scope_exit_cleanup.md §3.2 release on every death, in every container nothing §4.7's list walks at container death

Dependencies: 1 needs nothing (the pools are in the stdlib; defer exists); 2 needs 1 and the coroutine v1; 3 needs 1; 4 stays rejected. 4.8's release-is-a-shrink is an increment on 1 with no dependents.

8. Recommendation and order

own is a decision of its own. coroutines.md is the coroutine design of record and stands without it: R5 and §4.10 are consistent on their own terms, the loop's convention plus gen_pool's debug report is the leak story in that case, and nothing in this note is a precondition for building it. If own is adopted, it is built first and the coroutine design is built on top of it, with design A as one line in R1 and design B as the later relaxation; if it is declined, coroutines go ahead as written. The order below is for the first case.

  1. Decide the vocabulary, build nothing yet. Write into the spec's TODO list that ownership in Goose, if it comes, is an obligation on handles and never a release of memory: own as a qualifier, linear with defer claims, pools and finalizer-block removals as the only ways an element dies, no destructor on plain data. This costs a paragraph, and it follows llm_frontier.md §5.5's lesson to decide before i64 handles are everywhere in user code.
  2. Ship gen_pool first, as llm_frontier.md §7 step 2 says. It costs nothing to write and it produces the evidence this note cannot: whether leaks and double frees in real pool code are frequent enough to pay for a checker feature.
  3. If they are, build direction 1: the qualifier at the section 3 sites, the flow bit, the trusted end, take/replace, the pool operations and array removals with the finalizer block, two predicate lines, the spec section. Rewrite 26_file_tree's release and the LRU benchmark's eviction on it as the acceptance test, and measure that the generated C for both is unchanged apart from the finalizer calls the program wrote. Design A's rule goes into R1 at the same time, whether coroutines are specified by then or not.
  4. Design B when a coroutine body wants to hold a resource across a wait and A's field discipline is what stops it: the cancel emitter and the rule changes of 5.4, on top of direction 1 and the coroutine v1, never instead of either. The server benchmark of coroutines.md §8 step 2 is where that want would first show.
  5. Affine release (direction 3) and release-is-a-shrink (4.8): wait for demand, as errdefer waits; for handles the two demands are one (4.9).

9. Open questions

References

Spec and implementation sections referred to above: §3.9 relative references, §4.1 passing by size class, §5.1 grow-only, §5.2 grow-shrink, §5.4 reusable arrays, §6.6 defer, §7.8 recursion, §7.9 return … from, §9.4 type-safe reuse, §9.5 writability; reference.md §11 relative references, §12 lifetimes, §13 growing and shrinking, §14 pools, §16 defer; the stdlib's slot_pool and slice_pool; scope_exit_cleanup.md, coroutines.md, llm_frontier.md, whose §5.5 holds the survey of linear and affine type systems (Austral, Swift, Mojo, Rust, OxCaml, Cyclone, Pony, Vale, Cogent) that this note does not repeat.

Compiler functions named: NoRelRefCopy, NoRelRefTempRead, HasRelRefT (src/typecheck_types.h), NoSelfRelCopy (src/codegen_construct.h), CheckDefer, CheckDefers, JoinFlow, MergeFlow (src/typecheck_flow.h), UsedAfter (src/typecheck_exprs.h), RegisterDefer, EmitRestores, EmitDeferBody (src/codegen_frames.h), IsFlat, ImageSafe (src/typecheck_types.h), FnSpec::needs (src/ast.h). The defer landing is commit e1d95ed0.