A Binding Is Not a Lift
Every FFI wrapper ever written makes the same quiet compromise. You take a
proven C library, you write a layer of glue, and the C library’s footguns
walk straight through the glue untouched. pcre2_match_data_free is still
yours to forget. yaml_event_delete is still yours to skip on every loop
iteration. SSL_VERIFY_NONE still looks exactly like a secure connection.
The wrapper made the calls prettier; it did not make the mistakes
impossible.
koru-libs — the ecosystem package repo — refuses that compromise, and it enforces the refusal with a rule sharp enough to disqualify most of what the world calls a “binding”:
If your wrapper would port unchanged to Python or Go, it is a binding, not a lift, and it doesn’t count.
A lift has to move the footgun into the type system. The compiler has to be the thing that catches the leak, the use-after-free, the skipped verification — at build time, by construction. Anything less is glue.
The challenge
The way this happens is a standing brief that lives in the repo: LIFT_CHALLENGE.md.
It is not a task list; it is a generator, replayed
from zero, forever. Each replay grows the catalog in one of two directions
— a new library lifted, or an existing lift’s worst gap closed — and the
brief is explicit that it is never “done.”
Two things about it are unusual. First, the contestant is the AI agent. The brief opens by telling the agent, in as many words, to stop asking which library to pick or whether its idea is good enough and just choose, build, ship — because variance across contestants is the entire point. Ask-first is the wrong reflex here; the choice is the contribution. Second, every entry is held to four pillars at once, and buying one by sacrificing another is an automatic fail:
- Developer experience — so clean you forget there’s a crusty C library underneath.
- Performance — at or above hand-written use of the raw C library; the lifting happens at compile time, never as a runtime tax.
- Correctness — wrong usage is a build error, not a runtime surprise.
- Resource safety — leaks and use-after-free are uncompilable.
There is a hard gate attached to pillar 3 that keeps everyone honest: every entry must ship a negative test — a misuse that fails to compile — or the claim that “the obligations bite” is just a claim.
The bonanza
On July 3rd the brief was replayed six ways in parallel, and all six contestants merged in a single commit. Four new lifts, two quality passes. Here is what each one made impossible.
PCRE2 — the match buffer you never hold
pcre2/ · PCRE2 is the Perl-compatible regex engine inside
git, PHP, nginx, and Apache. Its C API hands you two heap resources to free
by hand in order, and makes you drive iteration with a raw ovector cursor
where an empty match loops forever unless you code the nudge yourself.
The lift turns the compiled pattern into a phantom obligation
(Regex<compiled!>) and makes the match-data buffer never touch your
hands at all — find.all owns it, iterates every match for you (empty-match
nudge included), and frees it exactly once. Each match arrives as a borrow
valid only inside its ! match body:
libs/pcre2:compile(pattern: "(\\w+)@(\\w+)")
| ok re |> libs/pcre2:find.all(re, subject: "alice@example and bob@work")
! match m |> libs/pcre2:group.text(m, index: 1): user |> libs/pcre2:group.text(m, index: 2): host |> std/io:print.ln("{{ user:s }} @ {{ host:s }}")
| done |> _
| err e |> std/io:print.ln("match error: {{ e.msg:s }}")
| err e |> std/io:print.ln("bad pattern: {{ e.msg:s }}") A newcomer never learns that PCRE2 has a match-data object, an ovector, or
a code-unit width — and never writes a free, either. The compiled pattern is
a tracked obligation (Regex<compiled!>), but the discharge is filled in for
them; the section below shows exactly what the compiler is doing.
libyaml — the per-event leak, gone
yaml/ · libyaml is the reference YAML parser
behind PyYAML and Ruby’s Psych. Its streaming API has one infamous footgun: every event returned by yaml_parser_parse owns heap buffers you must yaml_event_delete, and a loop that forgets it leaks on every iteration —
silent, unbounded, and invisible to any passing test.
The lift fuses the two-level resource machine (a parser you must delete, and
a stream of events you must each delete) into a single Cursor<live!> handle. There is exactly one thing to carry at any moment, and forgetting to
delete an event or close the parser is a compile error:
libs/yaml:open(input: "name: Ada\nrole: pioneer\n")
| ok p |> libs/yaml:begin(parser: p)
| at c |> libs/yaml:kind(cur: c): name |> std/io:print.ln("{{ name:s }}") |> libs/yaml:finish(cur: c)
| eof pp |> libs/yaml:close(parser: pp)
| err ep |> libs/yaml:close(parser: ep)
| err e |> std/io:print.ln("could not open: {{ e:s }}") It borrows the language’s own resource idiom, the same way Koru’s enforced
lifecycle suite fuses a connection and its open transaction into one Transaction<active!> rather than juggling two handles.
OpenSSL EVP — the algorithm that doesn’t parse
evp/ · EVP_get_digestbyname("sha266") fails at runtime, silently, with a typo.
The evp lift makes each digest its own event — sha256.init, sha512.init — so a misspelled algorithm doesn’t parse. The context
carries a hashing! obligation, and final consumes the handle, so
reading a digest before finalizing or updating after it is a build error,
not the undefined behavior it is in C. One line for the common case:
libs/evp:sha256.hex(input: bytes) | digest h |> … and the full NIST test vectors come out the other end. (SHOWN, this session,
on current main: sha256("abc") → ba7816bf…f20015ad — the published vector, exactly.)
OpenSSL TLS — verified by default, uncompilable otherwise
openssl/ ·
The raw OpenSSL client API is the most footgun-famous in existence: a
connection with a forgotten SSL_set1_host looks identical to a secure one
and has produced decades of CVEs. The openssl client-TLS lift makes
verified-by-default the only unmarked path. connect always verifies the
peer certificate and the hostname; there is no parameter to weaken it.
The only way to an unverified channel is to type a different, loudly-named
event — connect.insecure. Skipped verification cannot happen by omission,
only by intent.
koru/openssl:connect(host: "example.com", port: 443)
| ok conn |> koru/openssl:write(conn, data: "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")
| ok w |> koru/openssl:read(w.conn, buf: buf[0..])
| data d |> std/io:print.ln("{{ d.bytes:s }}") |> koru/openssl:shutdown(d.conn)
| ok s |> koru/openssl:close(conn: s)
| err e |> koru/openssl:close(e.conn)
| eof ec |> koru/openssl:close(conn: ec)
| err re |> koru/openssl:close(re.conn)
| err we |> koru/openssl:close(we.conn)
| err e |> std/io:print.ln("connect failed: {{ e.msg:s }}") The handshake and shutdown are phantom states (<open> → <closing>), so
“write after shutdown” doesn’t compile, and the whole SSL / SSL_CTX /
socket free-chain is one obligation discharged by a single close.
gzip — the stream you must actually use
gzip/ ·
A quality pass, and a clean illustration of why “binding, not a lift” has
teeth. The old gzip shipped only a one-shot API — whole payload in, whole
payload out — with the entire z_stream lifecycle opened and closed inside a single proc. Nothing threads to the caller; the type system is
idle; that wrapper would port unchanged to Python. It was a binding.
The pass added the one operation where zlib genuinely needs a session type: streaming. The z_stream now lives across calls as a handle the
compiler threads — and the interesting part is where the states are placed:
deflate.init mints <open!> — live, nothing fed yet
deflate.push needs <!open|!fed>, mints <fed!> — feed a chunk
deflate.finish needs <!fed>, mints <done!> — only a fed stream can finish
deflate.release needs <!done> The barrier is the distinct fed state, and it is deliberately not the
state init produces. finish accepts <!fed> and nothing else, and fed is reachable only through push. So opening a stream and immediately closing
it — init → finish, never feeding a byte — is a build error:
error[KORU030]: Phantom state mismatch: expected 'libs.gzip:fed'
but got 'libs.gzip:open!' for argument 'd'
error[KORU030]: Resource 'd' <open!> was not discharged.
Call: libs.gzip:deflate.push This is the asymmetry worth naming. Rust’s Drop — RAII in general —
guarantees a resource is cleaned up, but it cannot force the resource to be used. An acquired-and-discarded File, a Transaction you open and drop
without a single query, a z_stream you never feed: all of them run their
destructor and none of them complain. The phantom obligation closes that gap
from the other side. Discharge is not “run cleanup”; it is “reach the state
that only real work produces.” It is the exact shape of the enforced-lifecycle
suite’s transaction rule, where commit accepts only <!active> and the
active state is reachable only through exec — an empty BEGIN; COMMIT; doesn’t compile. Here, an empty gzip doesn’t either.
And it costs the legitimate case nothing: a zero-length push("") still
reaches <fed!> (zlib is happy to frame an empty payload), so genuinely
gzipping nothing stays expressible — you just have to say so. What the
barrier rejects is the silent no-op, not the intent.
This is a fourth resource-safety guarantee, distinct from the three RAII
already gives (no leak, no use-after-free, no double-free): no premature
finalization — you cannot discharge a resource you never used. And once you
see it, the obligation is bracketing the resource’s entire valid window. You
cannot even name a Deflater before init mints its handle — there is
nothing to pass, so use-before-init isn’t a caught error, it is an
unwriteable program. You cannot finalize one before push has fed it (the fed gate above). And you cannot touch one after finish discharges it:
because finish moves the handle to <done!>, calling push on a finished
stream is a build error too — the same zlib use-after-free that only exists at
runtime in C. Front, middle, and back — all three closed at compile time,
where Drop closes only the back.
ai — composing the sanctioned way
ai/ ·
The ai LLM-generation package didn’t compile at all against the current
language, and worse, its premise rested on an invented cross-module escape
hatch — it reached the curl sibling through symbols no passing test
attested. The quality pass repaired it to compose with curl the sanctioned
Koru way (a flow-position call, not a fabricated .call(...)), and threaded two obligations end-to-end: the request scratch buffer (Request<built!>)
and the response connection (curl’s own Response<open!>). Both are
uncompilable to forget; the err branch is unskippable.
The discharge you don’t have to write
Look at that PCRE2 example again: there is no free in it. Not because there is
nothing to free — the compiled pattern carries Regex<compiled!>, and dropping
that obligation is still a build error — but because you do not have to be the
one who types the discharge.
find.all only borrows the compiled pattern to drive its loop; it never
frees it, so the obligation stays with the caller across the whole match. At
each exit of the flow, Koru’s auto-discharge pass looks for the one event
that legally consumes <compiled!>, finds exactly one — libs/pcre2:free — and
inserts the call for you, on every branch, done and err alike. You keep the
guarantee and lose the ceremony.
You can always write it out, and sometimes you want to. The identical flow, by hand:
| ok re |> libs/pcre2:find.all(re, subject: "alice@example and bob@work")
! match m |> libs/pcre2:group.text(m, index: 1): user |> std/io:print.ln("{{ user:s }}")
| done |> libs/pcre2:free(re)
| err e |> libs/pcre2:free(re) |> std/io:print.ln("match error: {{ e.msg:s }}")
| err e |> std/io:print.ln("bad pattern: {{ e.msg:s }}") Both compile, both run identically, and the emitted code is the same pcre2_code_free_8 at each exit — the only difference is who wrote the line. The
shipped package keeps both forms as tests on purpose, so neither reads as the
“real” one.
The important part is that auto-discharge is exact, not magic: it fires only
when there is a single, unambiguous disposer. Give a resource two legal ways to
close and the compiler refuses to guess. A docker container comes back as Container<running!>, and both stop and kill discharge it — so forgetting
to close one is a KORU030 with no flag needed, because silent cleanup between
two valid choices was never going to be safe. The pass fills in the obvious and
makes you decide the ambiguous.
And it is a convenience over the guarantee, never a replacement for it. Turn it
off with --auto-discharge=disable and the forgotten free is the leak error
it always was underneath:
error[KORU030]: Resource '_' <compiled!> was not discharged. Call: libs.pcre2:free That is the line auto-discharge answers for you, every time you leave it on.
The negative test is the proof
The four-pillar bar is why every one of these ships with a misuse that fails to compile. This is not decoration — it is the receipt. And the diagnostics are the tell: three different libraries, three different ways to get it wrong, each rejected in plain language that names the fix — no raw C, no host-level stack trace, no line of generated code. SHOWN this session against today’s compiler:
# evp — read a hash before you finalized it
$ koruc run evp/tests/negative/forget_finalize.kz
error[KORU030]: Resource 'd' <hashing!> was not discharged.
Call one of: final.bytes, final.hex
# pcre2 — use a compiled pattern after you freed it
$ koruc run pcre2/tests/negative_use_after_free.kz
error[KORU030]: Use-after-discharge: binding 're' was already
discharged and cannot be used
# openssl — write to a TLS session after you shut it down
$ koruc build openssl/tests/negative/write_after_shutdown.kz
error[KORU030]: Phantom state mismatch: expected 'koru.openssl:open'
but got 'koru.openssl:closing!' for argument 's' Three failure shapes — forgot-to-finalize, use-after-free, wrong-state — each
a KORU030 that reads like a sentence. The last one is the SSL_write-after-
shutdown footgun that has produced decades of CVEs, turned into a build error
that tells you the session is already closing.
No executable is produced. The leak and the use-after-free are not caught by a linter or a runtime assertion — they are rejected by the compiler, and the diagnostic names the exact discharge event you were supposed to call. That is the whole difference between a binding and a lift, printed to your terminal.
The findings are the real yield
Here is the part that matters most, and it is the reason the challenge is worth running at all. koru-libs sits directly downstream of the Koru compiler, and both are greenfield. When honest, idiomatic library code is rejected — or compiles to broken output — the brief’s rule is absolute: stop, pin it, report it, never route around it. A toolchain defect surfaced by clean library code is worth more than a shipped package that hides it.
The bonanza surfaced a stack of them, because library code driven end to end exercises paths the compiler’s own suite never had a reason to:
- PCRE2 was the first library to drive an effect-branch (
!) producer wrapping a C library through end-to-end codegen, and it surfaced two real compiler defects in that path — module-scope loss in an inlined effect body, and a dotted auto-release event name mis-referenced in codegen. Both would also bite thesqlite3exemplar’s! rowpath, but nosqlite3test drives it, so neither had ever been caught. - libyaml pinned a confirmed bug: a cross-module phantom obligation threaded through a loop back-edge is rejected with a namespace mismatch. The identical obligation threads fine in a straight line, and same-file obligations loop fine — it is specifically the cross-module back-edge that drops the import-alias normalization. That single wall is the only thing standing between the package and its natural streaming loop, so the loop is pinned red and the passing tests walk a bounded number of events.
- ai pinned three cross-module composition gaps — an obligation lost in a non-head subflow branch, a pipe-binding in a subflow continuation emitting malformed Zig, and no working spelling for a sibling package’s Zig type in a consumer’s output binary. The middle one forced the whole building-block design.
Several of these findings share a shape the project has a name for: the error is a raw host-level Zig error leaking through generated code instead of a Koru-level diagnostic. That is itself a defect — the Koru-level wall simply isn’t built at that boundary yet — and naming it is first-class work, not a footnote. Every finding is pinned as a minimal red repro in-tree, floated for a design call, and routed around in the shipped package with the gap stated in the open. Not one was papered over.
This is the whole thesis of Koru in miniature. The toolchain is the product. A library is an instrument — a way to press honest, real-world code against the compiler and find exactly where its guarantees run out. Six premium C editions are the visible output of the July 3rd bonanza. The compiler gaps their honest code exposed are the valuable one.
A step on the way, not a destination
To be clear about where this sits: these libraries are early, and none of
them is ready to npm install and ship. Koru itself is well past its
infancy — the language and compiler are real and capable, and they enforce the
guarantees shown above today — but it is not mainstream, and the libraries are
younger than the compiler that carries them. Every one is a scoped v0: openssl does client TLS and nothing else, pcre2 has no flags or named
groups, yaml can’t yet run its own natural streaming loop because a compiler
bug stands in the way, gzip streams compression but not decompression. The
scope cuts are listed honestly in each package’s README, and the list is long
on purpose — a v0 that names its edges beats a v1 that hides them.
This bonanza is one replay of a challenge that is designed to be replayed
forever. It is a step: proof that the lift shape works, that the obligations
bite, that the compiler can be pushed to catch a whole class of C footguns at
build time. It is not a finished ecosystem, a stability promise, or an
invitation to npm install and ship. Treat these as what they are —
sharp, honest sketches of what the definitive editions will eventually be,
and a working instrument for finding out what the compiler still can’t do.
The catalog grows forever, and the bar never moves.