Scope Boundaries: The Metacircular Way
Scope Boundaries: The Metacircular Way
Yesterday we shipped phantom obligations - compile-time resource tracking with semantic auto-dispose. The compiler writes your cleanup code for you.
But here’s a question we glossed over: where does the cleanup go?
And here’s a bigger question: who’s actually doing this?
Auto-Dispose: The Magic
When you write this:
~app.fs:open(path: "data.txt")
| opened _ |> _ Something sees:
openproduces an obligation[opened!]- There’s exactly one event that consumes
[!opened]- thecloseevent - The flow terminates with
_
And synthesizes:
~app.fs:open(path: "data.txt")
| opened f |>
app.fs:close(file: f.file)
| closed _ |> _ Magic. But notice: the close call appears right before the terminator. The _ is the terminator - it says “this flow ends here.” Cleanup is inserted just before that ending.
But who does this? Not the compiler. A pass does.
It’s Not the Compiler - It’s a Pass
Koru has a configurable pass pipeline. Auto-dispose is just one pass among many:
Parse → Transform → Flow Check → AUTO-DISPOSE → Phantom Check → Emit The auto-dispose pass (auto_dispose_inserter.zig) runs on the AST, finds terminators with unsatisfied obligations, and inserts disposal calls.
The compiler doesn’t know about auto-dispose. It just runs passes. You could:
- Disable auto-dispose entirely (
--auto-dispose=disable) - Get warnings instead of insertions (
--auto-dispose=warn) - Write your own disposal pass with different rules
- Skip the pass for specific modules
This isn’t a hardcoded feature. It’s a tool in your pipeline.
The Problem: Loops
Now consider:
~app.fs:open(path: "outer.txt")
| opened f |>
std.control:for(0..3)
| each _ |> _
| done |> _ Where are the terminators?
| each _ |> _- terminates each loop iteration| done |> _- terminates after the loop
The file opens ONCE, before the loop. If the auto-dispose pass inserted close(f) at terminator #1, it would run THREE TIMES. Use-after-free on iteration 2.
If it inserted at terminator #2, it runs ONCE. Correct.
The pass needs to distinguish between “end of an iteration” and “end of the whole flow.”
What IS a Scope?
This is where scopes come in. A scope is a boundary that separates inner terminators from outer obligations.
Think of it this way:
OUTER SCOPE (file opened here)
│
├── FOR LOOP
│ │
│ ├── INNER SCOPE (@scope boundary)
│ │ │
│ │ └── | each _ |> _ ← terminator, but INSIDE inner scope
│ │ outer obligations are "suspended"
│ │
│ └── | done |> _ ← terminator, OUTSIDE inner scope
│ outer obligations can be satisfied here
│
└── file closed here (auto-dispose pass inserts in done branch) The @scope annotation marks a boundary. Obligations created OUTSIDE that boundary cannot be satisfied INSIDE it - they’re suspended, not lost. They become active again when we exit the scope.
Why “Suspended” Not “Error”?
An earlier version reported an error when outer obligations existed at an inner-scope terminator. That was wrong.
Consider this valid code:
~app.fs:open(path: "log.txt")
| opened log |>
std.control:for(0..100)
| each i |>
write(log, "Iteration " ++ i) // Use log inside loop
_ // Iteration ends - log is SUSPENDED
| done |>
write(log, "Complete!")
_ // Flow ends - log is CLOSED here The log file is:
- Opened once (before loop)
- Written 100 times (during loop)
- Closed once (after loop)
At | each _ |> _, the log obligation exists but is suspended. That’s not an error - it’s correct! The obligation will be satisfied in | done |>.
The Metacircular Stack
Here’s the beautiful part. Let’s count the layers of “not hardcoded”:
Layer 1: For-loops are library code
The for-loop isn’t a language primitive. It’s a transform in control.kz. The compiler doesn’t know what a for-loop is.
Layer 2: @scope is just an annotation
How does the auto-dispose pass know that | each |> is a scope boundary? The library tells it:
// In control.kz - the for-loop transform
branches[0] = .{
.name = "each",
.annotations = &.{"@scope"}, // ← Library declares scope boundary
};
branches[1] = .{
.name = "done",
.annotations = &.{}, // ← No scope - outer obligations satisfied here
}; Layer 3: Auto-dispose is just a pass
The pass just checks for @scope:
fn branchHasScope(branch: *const NamedBranch) bool {
for (branch.annotations) |ann| {
if (std.mem.eql(u8, ann, "@scope")) return true;
}
return false;
} No hardcoded branch names. No special cases for loops. No compiler knowledge of auto-dispose.
Three layers of indirection, each one configurable:
- Define your own control structures (library transforms)
- Mark your own scope boundaries (
@scopeannotation) - Configure or replace the disposal pass (pipeline configuration)
The Wrong Way We Almost Built
Early on, we had this in the auto-dispose pass:
// DON'T DO THIS
if (std.mem.eql(u8, branch.name, "each")) {
// Hardcode: "each" is a loop body
markAsScopeBoundary(branch);
} This works but it’s wrong:
- The pass now knows about for-loops (violates separation)
- Change the branch name → break the pass
- New constructs (taps, generators, async) need more hardcoding
We deleted this code and replaced it with @scope. Now any library can define its own scope boundaries, and the pass just checks the annotation.
Taps Need Scopes Too
Taps are observation points - code that runs alongside a flow without modifying it.
The main flow:
~app.fs:open(path: "data.txt")
| opened f |>
process(f)
| done |>
app.fs:close(file: f.file)
| closed |> _ A tap that observes file opens:
~tap(app.fs:open -> *)
| opened f |>
log("File opened!") The tap sees f but doesn’t own it. If the tap could dispose f, it would break the main flow.
So taps.kz adds @scope to tap bodies:
// In taps.kz
return ast.Continuation{
.binding_annotations = &.{"@scope"},
// ...
}; Same mechanism, different construct. The auto-dispose pass doesn’t know what a tap is - it just sees @scope and respects it.
The Complete Picture
Let’s trace through a complex example:
~app.fs:open(path: "outer.txt")
| opened outer |>
std.control:for(0..3)
| each i |>
app.fs:open(path: "inner.txt")
| opened inner |>
process(outer, inner)
_ // inner is closed here (auto-dispose)
// outer is SUSPENDED (inside @scope)
| done |>
_ // outer is closed here (auto-dispose) Step by step:
outeropens - obligation created in outer scope- Enter for-loop, enter
eachbranch (has@scope) outer’s obligation is marked “outer scope” - suspendedinneropens - obligation created in INNER scope- At terminator inside
each:inneris closed (local to this scope),outeris suspended (from outer scope) - Loop iterates 3 times - 3 inner opens, 3 inner closes
- Exit loop, enter
donebranch (no@scope) outer’s obligation is active again- At terminator in
done:outeris closed
Three inner files opened and closed. One outer file opened and closed. All at compile time. Zero runtime tracking.
The Implementation
Three files changed:
| File | Change |
|---|---|
control.kz | Add @scope to each branch |
taps.kz | Add @scope to tap continuations |
auto_dispose_inserter.zig | Check @scope before inserting disposal |
The phantom semantic checker also learned about scopes - it tracks which obligations are “outer scope” and doesn’t expect them to be satisfied inside @scope boundaries.
What This Enables
Because everything is configurable:
- New constructs get scopes for free: Async blocks, generators, whatever - just add
@scopein your library - Custom disposal strategies: Don’t like auto-dispose? Write your own pass. Or disable it entirely.
- Pass stays simple: One annotation check instead of special cases
- Semantics are visible: You can SEE in library code that a branch is a scope boundary
The Lesson
Auto-dispose feels like compiler magic, but it’s not.
It’s a pass. That checks an annotation. That libraries define. On constructs that are also library-defined.
Four layers of “not hardcoded”:
- Control flow: Library transforms, not language primitives
- Scope boundaries:
@scopeannotation, not hardcoded branch names - Disposal logic: A pass in the pipeline, not compiler internals
- Pass behavior: Configurable via flags, not fixed
This is what metacircular design looks like. The compiler is just a pipeline runner. Everything else is configurable.
That’s the Koru way.
Koru is a general purpose programming language exploring event-driven control flow, phantom types, and compile-time resource safety. The compiler is written in Zig and the language is 100% AI-implemented.