Scope Boundaries: The Metacircular Way

· 10 min read

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:

  1. open produces an obligation [opened!]
  2. There’s exactly one event that consumes [!opened] - the close event
  3. 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?

  1. | each _ |> _ - terminates each loop iteration
  2. | 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:

  1. Define your own control structures (library transforms)
  2. Mark your own scope boundaries (@scope annotation)
  3. 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:

  1. The pass now knows about for-loops (violates separation)
  2. Change the branch name → break the pass
  3. 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:

  1. outer opens - obligation created in outer scope
  2. Enter for-loop, enter each branch (has @scope)
  3. outer’s obligation is marked “outer scope” - suspended
  4. inner opens - obligation created in INNER scope
  5. At terminator inside each: inner is closed (local to this scope), outer is suspended (from outer scope)
  6. Loop iterates 3 times - 3 inner opens, 3 inner closes
  7. Exit loop, enter done branch (no @scope)
  8. outer’s obligation is active again
  9. At terminator in done: outer is 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:

FileChange
control.kzAdd @scope to each branch
taps.kzAdd @scope to tap continuations
auto_dispose_inserter.zigCheck @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:

  1. New constructs get scopes for free: Async blocks, generators, whatever - just add @scope in your library
  2. Custom disposal strategies: Don’t like auto-dispose? Write your own pass. Or disable it entirely.
  3. Pass stays simple: One annotation check instead of special cases
  4. 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: @scope annotation, 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.