Phantom Obligations

· 12 min read

Phantom Obligations

Every resource leak bug in the history of software. Every unclosed file handle. Every uncommitted transaction. Every orphaned network connection.

What if they were all compile errors?

Not runtime errors. Not “oops, the test caught it.” Not “hope the code reviewer notices.”

Compile. Errors.

How Other Languages Try

Every language has a strategy. None of them fully solve the problem:

# Python: Hope and pray
conn = db.connect()
tx = conn.begin()
do_work(tx)
# Did we commit? Rollback? Close the connection? YOLO.
// Go: Manual defer, easy to forget
conn, err := db.Connect()
defer conn.Close()  // At least this part
tx, err := conn.Begin()
// But what about commit vs rollback?
// defer can only do ONE thing
// Java: try-with-resources, but still one cleanup path
try (var conn = db.connect()) {
    var tx = conn.begin();
    doWork(tx);
    // AutoCloseable.close() runs... but commit or rollback?
}
// C#: GC handles memory, but resources need IDisposable
using (var conn = new SqlConnection(connString)) {
    var tx = conn.BeginTransaction();
    DoWork(tx);
    // Dispose() runs on conn... but what about tx?
    // GC will collect it "eventually" - not good enough for transactions
}
// Rust: Drop handles cleanup... but which cleanup?
let conn = db::connect()?;
let tx = conn.begin()?;
do_work(&tx)?;
// tx drops here. Does it commit? Rollback?
// Drop doesn't know what STATE you're in.

Each language gets closer. Rust’s ownership is brilliant. Java’s try-with-resources is convenient. But none of them can express:

  • “This transaction must be committed OR rolled back before scope exit”
  • “This lock was acquired in read mode, upgraded to write, and must be released”
  • “This HTTP response MUST be sent before the handler returns”
  • “This connection has a transaction open - you can’t just close it”

These are protocol obligations. State machines with mandatory transitions. Drop runs ONE cleanup path. It doesn’t know the semantic state of your resource.

“But What About Typestate?”

You can encode state machines in type systems. The typestate pattern exists in Rust, and similar patterns exist in TypeScript, Haskell, and others:

struct Transaction<S: State> { ... }
impl Transaction<Open> {
    fn commit(self) -> Transaction<Committed> { ... }
}

Now try to:

  1. Have MULTIPLE valid exit paths (commit OR rollback)
  2. Make the compiler auto-insert the right cleanup
  3. Do this without your API becoming a generic nightmare
  4. Handle nested resources (connection owns transaction owns savepoint)

Typestate is theoretically possible and practically unusable. The ergonomics are terrible. The complexity explodes. Nobody ships it in production libraries.

Enter Phantom Obligations

Koru has a different idea, and it flows directly from the language’s core primitive: event continuations.

When every operation explicitly declares its branches, and every branch must be handled exhaustively, the compiler has complete visibility into control flow. That visibility enables something new: phantom obligations - compile-time tracking of what you must do before a scope exits.

Two symbols change everything:

[state!]  - suffix !  - obligation PRODUCED (you must dispose this)
[!state]  - prefix !  - obligation CONSUMED (this is a disposal path)

That’s it. Let’s see it in action.

A Complete Example: Database Transactions

Here’s a realistic database module:

// db.kz - Database module with proper obligation tracking
const std = @import("std");

const Connection = struct { handle: i32 };
const Transaction = struct { conn: *Connection };

// Open connection - creates obligation
~pub event connect { host: []const u8 }
| connected *Connection[connected!]  // Must close this!

~proc connect {
    std.debug.print("Connecting to {s}\n", .{host});
    const c = allocator.create(Connection) catch unreachable;
    c.* = Connection{ .handle = 42 };
    return .{ .connected = c };
}

// Begin transaction - TRANSFORMS the obligation
~pub event begin { conn: *Connection[!connected] }
| begun *Transaction[in_transaction!]

~proc begin {
    std.debug.print("BEGIN TRANSACTION\n", .{});
    const tx = allocator.create(Transaction) catch unreachable;
    tx.* = Transaction{ .conn = conn };
    return .{ .begun = tx };
}

// Commit - consumes transaction obligation, returns connection
~pub event commit { tx: *Transaction[!in_transaction] }
| committed *Connection[connected!]

~proc commit {
    std.debug.print("COMMIT\n", .{});
    return .{ .committed = tx.conn };
}

// Rollback - also consumes transaction obligation
~pub event rollback { tx: *Transaction[!in_transaction] }
| rolled_back *Connection[connected!]

~proc rollback {
    std.debug.print("ROLLBACK\n", .{});
    return .{ .rolled_back = tx.conn };
}

// Close connection - consumes connection obligation
~pub event close { conn: *Connection[!connected] }
| closed

~proc close {
    std.debug.print("Connection closed\n", .{});
    return .closed;
}

Now let’s use it:

~import "$app/db"

// Happy path: connect → begin → commit → close
~app.db:connect(host: "localhost")
| connected conn |>
    app.db:begin(conn)
    | begun tx |>
        do_work(tx)
        app.db:commit(tx)
        | committed conn |>
            app.db:close(conn)
            | closed |> _

But what if we forget to close?

~app.db:connect(host: "localhost")
| connected conn |>
    app.db:begin(conn)
    | begun tx |>
        app.db:commit(tx)
        | committed conn |> _  // ERROR: conn has [connected!] obligation!

Compile error. Not runtime. Not a test. The compiler catches it.

What if we forget to commit?

~app.db:connect(host: "localhost")
| connected conn |>
    app.db:begin(conn)
    | begun tx |> _  // ERROR: tx has [in_transaction!] obligation!

Compile error. You cannot abandon a transaction.

Auto-Dispose: The Compiler Writes Your Cleanup

Here’s where it gets magical. What if there’s only ONE way to dispose a resource?

// Simple file module - only one disposal path
~pub event open { path: []const u8 }
| opened { file: *File[opened!] }

~pub event close { file: *File[!opened] }
| closed {}

Now:

~app.fs:open(path: "test.txt")
| opened _ |> _  // Underscore says "I acknowledge but don't need to name it"

The compiler thinks: “There’s an [opened!] obligation. There’s exactly ONE event that consumes [!opened]. I’ll insert it for you.”

The generated code automatically includes the close call. You didn’t write it. The compiler synthesized it.

This is NOT RAII. RAII runs the same destructor regardless of state. This is semantic auto-dispose - the cleanup depends on what state the resource is in.

When Auto-Dispose Can’t Help

If there are multiple disposal paths (like commit vs rollback), the compiler can’t choose for you:

~app.db:connect(host: "localhost")
| connected conn |>
    app.db:begin(conn)
    | begun _ |> _  // ERROR: Multiple disposal paths exist (commit, rollback)
                    // Cannot auto-dispose - you must choose!

The compiler says: “You have a transaction. You can commit OR rollback. I can’t pick. You must.”

This is the right behavior. Silent auto-rollback would be a semantic bug hiding in your cleanup code.

Scope Boundaries: The Hard Part

Here’s where naive implementations break. Consider a loop:

~std.control:for(0..3)
| each _ |>
    app.fs:open(path: "test.txt")
    | opened _ |> _  // Auto-dispose runs HERE, once per iteration. Correct!
| done |> _

The obligation is created INSIDE the loop. Auto-dispose inserts cleanup at the end of EACH iteration. Three opens, three closes. Correct.

But what about this?

~app.fs:open(path: "outer.txt")
| opened outer |>
    std.control:for(0..3)
    | each _ |> _  // Can we dispose outer here? NO!
    | done |> _

If auto-dispose inserted close(outer) inside | each |>, it would run THREE TIMES. That’s use-after-free.

The compiler catches this:

error: Cannot auto-dispose outer-scope obligation inside repeating branch
  --> input.kz:5:0
  |
5 |     | each _ |> _
  |                 ^ 'outer' has [opened!] but disposing here would run 3 times

Scope-aware obligation tracking. The compiler knows which scope created the obligation and refuses to dispose in an inner scope that runs multiple times.

The Discard Contract

Clear rules. No ambiguity:

| opened f |> _      // ERROR: unused binding 'f' has obligation
| opened _ |> _      // OK: explicit discard, auto-dispose if possible
| opened f |> use(f) // OK: binding explicitly used
| done |> _          // OK: empty payload, nothing to track

The underscore _ says: “I see this value. I choose not to name it. Handle it for me if you can, error if you can’t.”

What This Enables

Phantom obligations with auto-dispose enable patterns that are impossible elsewhere:

HTTP handlers that MUST send a response:

~event handle { req: *Request, res: *Response[must_send!] }
| handled {}

// Compiler ensures every code path sends the response

Locks that track their mode:

| acquired lock[read!] |>
    upgrade(lock)
    | upgraded lock[write!] |>
        // Compiler knows this is now a write lock

GPU resources with proper lifecycle:

| allocated buffer[gpu:allocated!] |>
    upload(buffer)
    | uploaded buffer[gpu:uploaded!] |>
        // Different state, different disposal path

The Implementation

This isn’t theoretical. It’s implemented. It’s tested.

The phantom types test suite covers:

  • Single and multiple disposal paths
  • Obligation escape through return signatures
  • Nested loops and conditionals
  • Scope boundary enforcement
  • Use-after-disposal detection
  • Cross-module phantom states

The compiler tracks:

  1. What obligations exist in current scope
  2. What disposal paths are available for each
  3. Whether auto-dispose can synthesize cleanup
  4. Which scope created each obligation
  5. Whether disposal would run multiple times

All at compile time. Zero runtime overhead.


Semantic Space Lifting

Here’s the practical power: you can apply phantom obligations to existing code.

Every C library has implicit protocols. SQLite expects you to close what you open. OpenGL expects you to delete buffers. OpenSSL expects you to free contexts. These protocols exist in documentation and programmer discipline - not in the type system.

Wrap them in Koru events:

// Lift SQLite's implicit protocol into semantic space
const c = @cImport(@cInclude("sqlite3.h"));

~pub event open { path: []const u8 }
| opened *c.sqlite3[opened!]  // Obligation: must close

~proc open {
    var db: ?*c.sqlite3 = null;
    _ = c.sqlite3_open(path.ptr, &db);
    return .{ .opened = db.? };
}

~pub event close { db: *c.sqlite3[!opened] }  // Consumes obligation
| closed

~proc close {
    _ = c.sqlite3_close(db);
    return .closed;
}

Now the compiler enforces SQLite’s protocol:

// Name it but don't use it? Error.
~sqlite:open(path: "test.db")
| opened db |> _  // ERROR: unused binding 'db'

// Use it, and auto-dispose handles cleanup
~sqlite:open(path: "test.db")
| opened db |> query(db)
    | result _ |> _  // OK: auto-dispose calls close(db)

// Discard explicitly, auto-dispose handles cleanup
~sqlite:open(path: "test.db")
| opened _ |> _  // OK: auto-dispose calls close()

You didn’t modify SQLite. You lifted its implicit contract into semantic space where the compiler can see it. The C code is unchanged. The safety is new.

This works for any library:

  • OpenGL buffer lifecycle
  • OpenSSL contexts
  • File descriptors
  • Network sockets
  • Mutex locks
  • GPU resources

Write the wrapper once. Get compile-time safety forever. Zero runtime cost.

GC Convenience, Zero GC Cost

Here’s something easy to miss: phantom obligations don’t just solve the “which cleanup path” problem. They solve memory management too.

C# and Java programmers spend their days trusting the garbage collector:

var data = new BigObject();  // GC will handle this... eventually
DoWork(data);
// When does memory get freed? Sometime. Maybe. GC decides.

The tradeoffs are well-known:

  • Unpredictable latency (GC pauses)
  • Memory overhead (objects live longer than needed)
  • No determinism (cleanup happens “eventually”)

Rust solved this with ownership - deterministic Drop, zero runtime cost. But you still write the cleanup logic.

Koru gives you both:

  • GC-like convenience: You don’t write cleanup. The compiler synthesizes it.
  • RAII-like determinism: Cleanup happens at compile-time-known points.
  • Zero runtime cost: No GC, no refcounting, no runtime tracking.

The compiler does at compile time what GC does at runtime. Auto-dispose isn’t just about resources - it’s about all cleanup, including memory. You get the ergonomics of a managed language with the performance of an unmanaged one.

Standing on Shoulders

The insight behind phantom types isn’t new. F#‘s units of measure showed that compile-time annotations can provide powerful safety guarantees - then disappear completely at runtime:

let distance: float<meter> = 10.0<meter>
let time: float<second> = 2.0<second>
let speed = distance / time  // Compiler infers meter/second!
// At runtime: just floats. Zero overhead.

F# applied this to dimensional analysis. Koru applies the same insight to resource lifecycles and state machines. Different domain, same principle: semantic annotations that exist only to guide the compiler, then vanish.

Why This Is Hard Elsewhere

It’s not that other language designers haven’t thought about this. The constraints are real:

  1. RAII/Drop is unconditional: One cleanup path, no state awareness
  2. Typestate explodes in complexity: Generic soup makes APIs unusable
  3. Backwards compatibility: Adding semantic cleanup would break existing code
  4. Cleanup is an afterthought: Most languages bolt it on, not build it in

Phantom obligations are a different primitive. They track semantic state, not just ownership or scope. They know the difference between “committed” and “needs rollback.”

Could existing languages add this? Maybe. But it would fight with existing cleanup semantics. Rust’s Drop, Java’s AutoCloseable, Python’s context managers - they all assume ONE cleanup path.

Koru started fresh. This is what you can build when obligation tracking is foundational, not an afterthought.


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.