The Resource Bridge: Obligations That Outlive the Call
Koru’s phantom obligations answer a question inside one compilation: this file was opened, was it closed. The answer is a compile error when it wasn’t.
The resource bridge asks the same question across a conversation. A turn opens a file. A different turn — later, from somewhere else, possibly decided by someone who wasn’t there for the first one — closes it. In between, the resource sits on the bridge, still owed.
import std/bridge
import std/io
import app/session
[with]std/bridge:create(id: "session-1", scope: "files"): br |> run(br, source: "open(path: \"a.txt\")")
| result r1 |> run(br, source: "close-file(handle: \"file_1\")")
| result r2 |> std/io:print.ln("turn 1: {{ r1.handles:d }} held, turn 2: {{ r2.handles:d }} held")
| exhausted _ |> std/io:print.ln("FAIL: turn 2 exhausted")
| parse-error _ |> std/io:print.ln("FAIL: turn 2 parse error")
| validation-error _ |> std/io:print.ln("FAIL: turn 2 validation error")
| shape-error _ |> std/io:print.ln("FAIL: turn 2 shape error")
| event-denied _ |> std/io:print.ln("FAIL: turn 2 event denied")
| dispatch-error e2 |> std/io:print.ln("FAIL: turn 2 {{ e2.message:s }}")
| scope-not-found _ |> std/io:print.ln("FAIL: turn 2 scope not found")
| exhausted _ |> std/io:print.ln("FAIL: turn 1 exhausted")
| parse-error _ |> std/io:print.ln("FAIL: turn 1 parse error")
| validation-error _ |> std/io:print.ln("FAIL: turn 1 validation error")
| shape-error _ |> std/io:print.ln("FAIL: turn 1 shape error")
| event-denied _ |> std/io:print.ln("FAIL: turn 1 event denied")
| dispatch-error e1 |> std/io:print.ln("FAIL: turn 1 {{ e1.message:s }}")
| scope-not-found _ |> std/io:print.ln("FAIL: turn 1 scope not found") That is a whole entry file. Two run calls, two separate programs, one pool of
resources between them. The open in the first turn is discharged by the close-file in the second, and the counts say so: turn 1: 1 held, turn 2: 0 held.
Three things about how it reads, none of them about the bridge. It is a .k file, which is Koru’s pure contract — no Zig, no ~, plumbing only; the events
and their implementations live next door in session.kz, which also registers
the scope. And [with] opens the bridge’s vocabulary for the rest of the flow,
which is why run is bare after the first line. That opening is unresolved-only: a name the entry already owns is never shadowed by it.
The session is itself an obligation, and nobody types the hang-up
create hands back *Bridge<session!>. Only close takes <!session>. So a
session is a resource under exactly the same rule as the file it holds.
Now count the close calls in that sample. There are none. There are also
sixteen exits — one happy path and fifteen ways a turn can fail — and every one
of them hangs up, because the compiler appends the call. The emitted output
carries fifteen close_event.handler invocations that nobody authored.
That is auto-discharge, and the condition it turns on is worth stating exactly, because it is a rule about your signature rather than about your resource: a disposer is insertable only when there is nothing to bind. The inserter appends a bare call, so a disposer that returns a value — or branches — needs a binding the compiler cannot invent, and it silently stops being a candidate.
close is therefore void. An earlier version returned the number of handles
still held, which reads like honesty and is not: nothing obliges a caller to
read a return value, so an unread count is exactly the shape of the bug it was
meant to prevent. Failing to release is not a value. It panics.
The rule generalises past bridges: a cleanup verb that reports is a cleanup verb that cannot be inserted. Every diagnostic added to a disposer’s return trades an automatic guarantee for an advisory one, and you see the trade only as a chore appearing at every call site.
What “you cannot forget to hang up” actually means
It does not mean the compiler refuses your program. In the default mode it finishes it. The refusal shows up when you tell the compiler not to help:
import std/bridge
import std/io
[with]std/bridge:create(id: "s1", scope: "files"): br |> get-handles(br): n |> std/io:print.ln("holding {{ n:d }}") Compiled with --auto-discharge=disable:
error[KORU030]: Resource 'br' carries obligation <session!> was not discharged. Call: std.bridge:close disable opts out of inserting a discharger. It never opts out of the
obligation being seen. That distinction is the load-bearing one — without it,
making a disposer auto-insertable would quietly retire the wall along with the
chore, and the corpus would show a green board over a language that had stopped
checking.
Hanging up actually releases
close returns nothing, and it cannot quietly fail: a session still holding
anything when the call returns panics, naming the session and what was stranded.
It can be that blunt because there is exactly one discharge loop in the system. The interpreter’s end-of-run auto-discharge and a bridge’s hang-up are the same act at two different lifetimes, and they call the same code — a bridge releasing a file logs it the same way a run does:
close-file() ran for 'file_1'
[BRIDGE] Invoked 'close-file' for handle 'file_1' [main:opened]
held before hang-up: 1 Reaching that last line is the assertion. It prints after the hang-up, so the bridge got there without stranding anything.
Possession is the authority, and it is checked
A handle that crosses a turn boundary is a name you hold. The rule that makes that mean anything: a turn may only discharge a handle it was actually given.
forged close refused: HandleNotHeld
close() ran for 'file_1'
held after open: 1, after honest close: 0 The first line is a run naming a handle the bridge never issued. It is refused before the discharge event runs, and that ordering is the whole property. A check that runs afterwards can only describe what already happened — releasing a resource you were never handed cannot be undone by noticing, one line later, that no obligation was retired.
It is worth being precise about which half of this is the guarantee. The
compile-time refusal — <session!>, KORU030 — is the part that has always been
real, and it is what the language sells. The runtime pool underneath it is
bookkeeping, and bookkeeping is easy to mistake for enforcement because a
faithful counter is the cheapest thing in the system to get right. The counter
being correct never implied anything was guarded. Now something is.
What this is not, yet
The bridge is in-process. There is no wire, no second bridge, and no adversary — every handle lives inside one program, behind that program’s own type system. Three things follow, and they are the shape of the next work rather than omissions:
- A handle’s name is whatever its author returned.
"file_1", a string, from theopenproc. That is fine while nothing crosses a trust boundary and useless the moment something does. Whether the name should be minted by the bridge, derived from the resource’s type, or structured the way a store’s row handle already is — brand, slot, generation, in one integer — is undecided, and nothing built so far depends on the answer. - You cannot ask a handle what it affords. The vocabulary of a resource is
computable from its phantom state: the events that accept its current state are
exactly the ones you may call, and after
closethat set is empty. The machinery exists — it is how a discharger is discovered — and it has never been exposed as a question you can ask at runtime. - Metering is parked, deliberately. A budget earns its keep on a public-facing
API, not on an internal bridge, so
rundoes not expose one.
The cost still visible is the sixteen arms — the discharges are free now, the
exhaustiveness is not. An obligation’s ergonomic price is not its lifetime; a
long-lived handle down one happy path costs nothing. It is the number of exits it
spans, and run mirrors an eight-outcome vocabulary because forwarding one
invents no spelling. Narrowing that set is the legitimate relief. A catch-all arm
that skips the hang-up is not, and it is the one the pressure points at.