The Frame Is a Memory Obligation
This continues the short series answering David Barbour’s questions about Koru. The first took parameters and context; the second took effects and obligations. This one takes the point he aimed squarely at his own target — an assembly language — and it’s the one where his vocabulary and ours collapse into each other most completely.
His point:
To the assembly adaptation, the ‘type’ of a stack pointer (e.g. frame structure) is just another memory obligation. Recursive loops are a little awkward without implicit call stack. Tail recursion is feasible, but I’m still exploring if there’s a better approach semantically.
Three sentences, and they look like three separate remarks. They aren’t. By the end of this they’re one remark, and the thing that fuses them is a property of Koru’s obligations we’d never had a reason to say out loud.
The frame, restated
Keep the meta-frame from the first post in view: he is not here for speed. He’s here for control — a model where nothing is implicit and he holds every lever. That is what makes the stack frame interesting to him at all. On most targets the call stack is the most implicit thing there is: a frame appears when you call, vanishes when you return, and the language never asks your permission. An assembly with no implicit call stack takes that lever back. Which immediately raises the question his three sentences circle: if the frame isn’t handed to you, what is it?
His answer is the one we want to test: the frame is a memory obligation. The stack pointer has a type — the frame’s structure — and that type is the same kind of thing as any other linear resource you have to acquire and release. Then the two follow-ons: without an implicit call stack, recursion gets awkward; tail recursion works, but he suspects there’s something cleaner underneath.
So the question, restated, is: does Koru already have the machinery to treat a stack frame as a memory obligation — and if it does, does that machinery say anything useful about the recursion problem?
What we found, part one: there was never a frame to lose
We answered half of this already, in the first post, without knowing it was about the stack. Koru never made a stack frame part of its semantics. The one place a frame ever entered the story, it was the Zig backend emitting each handler as a function — and a function that calls itself happens to give you value recursion for free.
That “for free” is doing quiet work, and it’s the thing his assembly target removes. On Zig, when a flow re-enters itself and isn’t in tail position — when it has to hold state across the self-call and combine the result afterward — the host’s call stack catches it. A frame appears, because Zig functions have frames. Koru never asked for it; it fell out of the emitter. On a machine with no implicit call stack, that catch is gone. The frame doesn’t appear on its own. You have to make it. That is exactly the awkwardness he names — and it lands precisely on the non-tail case, where there’s real state to keep.
So his second sentence isn’t a separate complaint. It’s the bill for the first one: take away the implicit call stack, and the frame the host used to conjure for non-tail recursion becomes something you must explicitly acquire, hold, and release. A linear resource with a lifetime. A memory obligation.
What we found, part two: an obligation is checked, then it disappears
Here’s the property that makes his metaphor more than a metaphor. In Koru, an
obligation isn’t a runtime tag. It’s a phantom the compiler tracks and then strips out of the emitted code. A resource typed *Resource<active!> — the <active!> says “this carries a live obligation” — emits as plain *Resource.
The compiler’s own type-stringifier does the stripping:
// src/visitor_emitter.zig
/// Strip phantom type annotations from a type string
/// e.g., "*Resource<state!>" -> "*Resource", "[]const u8" -> "[]const u8"
fn stripPhantom(type_str: []const u8) []const u8 { ... } and the AST node for an input records the same thing in its comment: “Zig type as string (phantom states/tags stripped).” The obligation exists only at compile time. It’s bookkeeping the checker does — issued once, discharged once, can’t escape its scope — and by the time bytes are emitted it’s gone, leaving a bare pointer and the memory it points at.
Read that against what a stack frame wants to be on a machine where you hold the levers:
- It is acquired exactly once, on entry. (Issue.)
- It is released exactly once, on return. (Discharge.)
- It must not outlive the call that owns it. (The scope rule.)
- And at runtime it carries no overhead for any of that — it’s a pointer and some bytes. The discipline was all checked beforehand.
That last line is the whole point. A stack frame is the canonical thing you want proven and then invisible: you need to know it’s balanced — allocated once, freed once, never dangling — but you do not want a runtime tag riding along to enforce it. That is the exact shape of a Koru obligation. The checker follows the frame’s lifetime as a phantom; the emitter strips the phantom; the running code is just the stack pointer he started with. The “type of the stack pointer” is a phantom the compiler discharges and erases. His sentence is not an analogy we’re being generous to. It’s a literal description of how the obligation system already works — pointed at a resource we happen to call a frame.
And the scope clauses we pinned for effects are the frame’s safety rules verbatim. You cannot discharge an obligation owned outside the current scope is “you cannot free a parent’s frame from inside a callee.” An issued obligation may escape a branch only if the owning event then handles it is “a frame may be returned upward only if the caller takes responsibility for it.” We didn’t write those rules for stacks. They’re what linearity-plus-scopes forces, and a stack frame is just one more thing linearity governs.
What we found, part three: the recursion split is already the whole answer
Now his third sentence — tail recursion is feasible, is there something better? — answers itself once the frame is an obligation, because the recursion problem splits cleanly into exactly two cases, and Koru already lowers both.
Tail self-continuation needs no frame at all. A flow that re-enters its own event in tail position, forwarding the result unchanged, is a loop wearing recursion’s clothes — and Koru recognizes that at the Koru level and emits a flat loop, not a self-call. We took this apart in The Recursion That Was a Loop. The register machine there compiles to literally this:
__koru_self_loop: while (true) {
if (pc >= n or pc < 0) return .{ .done = .{ .a = a, .b = b } };
// decode ...
pc = r.np; a = r.na; b = r.nb; // reassign only what changed
continue :__koru_self_loop;
} No frame. No stack growth. The invariant state sits in registers and never moves. On his target this is the easy case — a jump, no allocation — and crucially the recognition is the compiler’s, not the host’s. He doesn’t get tail recursion because the machine has a tail-call instruction; he gets it because Koru already decided there was no frame to manage. So “tail recursion is feasible” understates it: in the tail case there is no obligation to discharge, because there was never a frame to acquire.
Non-tail recursion is where the frame — the obligation — actually lives. When
the flow must hold state across the self-call, that held state is the frame, and
on a machine with no implicit call stack it’s a memory obligation you issue on
entry and discharge on return. This is the case the host used to paper over with
its own stack (the 320_095 frame “for free”). Strip the implicit stack and it
becomes explicit — and the explicit form is exactly a linear resource with a
scoped lifetime. Which is the machinery from part two. The “better approach
semantically” he’s reaching for isn’t a cleverer kind of recursion. It’s the
recognition that the two cases are the entire space: tail is a loop with no
obligation, and non-tail is an obligation you already know how to check. There is
no awkward middle once you stop expecting an implicit stack to handle both.
Notice what happened to his three sentences. The stack pointer’s type is a memory obligation (part two). Recursion is awkward without an implicit call stack — because that’s precisely when the frame stops being free and becomes an obligation you must issue explicitly (part one). And tail recursion is the case where the obligation is zero (part three). One observation, stated three ways. He saw the shape before we did.
The honest seam
Here is the part we won’t dress up. Koru does not have an assembly backend, and it does not today model the stack frame as an explicit obligation. What we have is three things that meet at his point without yet being wired together:
- Tail self-continuations lowered to frameless loops, decided at the Koru level and therefore portable to any target — including one with no call stack and no tail-call instruction.
- Non-tail value recursion that currently borrows the host’s frame, because our emitters target hosts (Zig, JavaScript) that have one.
- An obligation system that already does — and then erases — the exact linear, scoped bookkeeping a frame needs.
Making the frame an explicit Koru obligation on an assembly target is a backend
we haven’t built. But — and this is why his point is worth a whole post — it is not a new mechanism. It’s the obligation checker we already ship, pointed at a
resource named “frame,” emitting a stack pointer where it currently strips a
phantom. The hard part, the part that’s usually a research project — proving the
acquire/release discipline statically so the runtime carries no tag — is the part
Koru already does for every <state!> in the suite.
One real boundary, stated precisely so we don’t overclaim: a phantom obligation proves the linearity of each frame — issued once, discharged once, scoped — even when the number of frames is runtime data, because the per-frame discipline is static even if the recursion depth isn’t. What a phantom does not give you is a runtime guard on the quantity — depth limits, stack-exhaustion checks. Those are a count, not a linearity, and a thing the compiler erases cannot enforce a runtime bound. If he wants that, it’s a genuinely different mechanism — runtime-checked, not phantom — and we’d build it as one, loudly, rather than pretend the phantom reaches there. The phantom reaches the discipline. It does not reach the arithmetic of how deep you went.
The lever he was after
Strip all of it back to his frame — control, not speed. An assembly emitter that treats the stack frame as a Koru obligation gives him the thing he said assembly is for: nothing implicit. No frame conjured behind his back, no stack that grows without his say-so. The frame is acquired where he writes the acquire, released where he writes the release, the compiler proves the two balance and that the pointer never escapes its scope — and then it erases the proof and hands him back a bare stack pointer over bytes he holds completely. The discipline is checked; the levers stay in his hand.
He called the frame “just another memory obligation,” almost in passing, as the setup for a worry about recursion. It turned out to be the answer to the worry. In Koru a memory obligation is the one thing that is both fully checked and fully gone by runtime — which is the exact contract a stack frame has always wanted and rarely gets to sign.