Prototype Mode: Doodle the Flow, Let the Events Catch Up

· 6 min read

Koru’s whole thesis is control flow into the type system. An event declares its terminals, and the continuation — the handler tree hanging off the invocation — is where the program actually lives. Which means the handler tree is also where you think. When you sketch a program, you doodle the flow: fire this, and on done do that, on err do this other thing, and there’s probably a retry case somewhere once I understand it better. The declarations are bookkeeping that follows from the flow, not the other way around.

Until now the compiler refused to meet you in that order. Exhaustiveness was absolute: every declared terminal handled, every handled branch declared, or no binary. The only escape was a lie — | done _ |> _, a discarding stub written purely to satisfy the checker. It typechecks as handled and is indistinguishable from a deliberate drain: a silent fallback, exactly the thing the language repudiates everywhere else.

[prototype] is the honest version. It is a module annotation that relaxes terminal exhaustiveness in both directions — and the two directions have usefully different failure shapes, which is the interesting part.

Direction one: omit a handler, get a loud hole

Under [prototype], leaving a declared terminal unhandled is no longer a KORU022 error. The compiler synthesizes a @panic arm for it — the same body an unhandled | ?! panic branch already gets. The program compiles, and the paths you have built run right now:

[prototype]

import std/io

pub event run {}
| done
| err []const u8

run => err "built path ran; done is still a hole"

run()
| err msg |> std/io:print.ln(msg)

| done is simply absent. In a dev build this compiles, runs the err path, and prints. The hole is a latent runtime risk: if execution ever reaches done, the synthesized arm panics loudly with the branch’s name. Not a drain, not a default — a crash that tells you which branch you haven’t written yet. That is the assert, where the stub was the fallback.

Direction two: handle a branch that doesn’t exist yet

The mirror direction is the one that makes flow-first authoring real. You are doodling the handler tree, and you already know there will be a soon case — so you write its arm, even though the event doesn’t declare soon yet:

run()
| done |> std/io:print.ln("done ran")
| err msg |> std/io:print.ln(msg)
| soon |> std/io:print.ln("undeclared arm — never fires yet")

Outside prototype mode that is a KORU021 error: every handled branch must be declared. Under [prototype] it is tolerated — and here the failure shape flips. The undeclared arm can never fire, because the event never produces it. It is pruned from the lowered switch and has zero runtime footprint. Not a latent panic, not a risk of any kind — pure declaration-debt. A note that the flow has run ahead of the event surface, sitting visibly in the source until you declare soon on run and build it.

That asymmetry is the clean core of the feature. Omit a handler and you carry a runtime risk you might hit. Reference a future branch and you carry a compile-time to-do that costs nothing. One is a hole in what the program does; the other is a hole in what it declares. Prototype mode lets you hold both kinds open at once — which is exactly the state a half-designed program is actually in.

The readout: the frontier, printed

A latent panic you never happen to hit at runtime used to be invisible — you’d learn about it in production, or never. So prototype mode surfaces the whole gap surface at compile time. Any prototype flow with gaps prints a per-event report to stderr, both directions, no path needs exercising:

📋 prototype gaps — event 'run' (sketch.k:12):
     hole       — 'done' unhandled → @panic if reached
     undeclared — 'soon' handled but not declared → pruned (declare it on 'run' to build it)

Every hole is a declared terminal you haven’t handled; every undeclared is an arm waiting for its declaration. The readout is the machine-readable frontier of “thought about, not built yet” — and that makes it a substrate. In a large application you doodle the flow across dozens of events, compile, and hand the readout to an agent as a work list: each line is a self-contained gap with a location and a remedy. Closing gaps stops being archaeology and becomes iteration on a printed list.

The honesty contract

Leniency alone would be a rotting fallback — a [prototype] module that compiles forever is a module nobody ever finishes. Two walls keep it honest.

First, the annotation is the only thing granting leniency. There is deliberately no CLI flag; the opt-in lives in the source, per-file and greppable. Delete [prototype] and the same source fails loudly on both sides: KORU022 for the hole, KORU021 for the undeclared arm.

Second, the release gate. A --release build rejects outright any module in the import graph bearing [prototype] — KORU029, before any lenient synthesis runs. Not “the holes error in release”: the annotation itself is refused, transitively, so an imported prototype dependency breaks the build just as the entry module does. Prototype code physically cannot ship. The workflow of “run the incomplete program now” therefore always terminates in “handle every branch and delete the annotation” — the gate is what forces the deletion, and it is what makes the freedom legitimate rather than a fallback that quietly becomes the default.

One boundary, on purpose: prototype leniency covers terminal (|) branches only. Effect (!) arms keep full exhaustiveness even under [prototype], because they don’t lower to switch arms — they lower to functions in the consumer’s handler surface, where a synthesized stand-in would be indistinguishable from a real install and would corrupt presence truth. A terminal hole is an honest absence; an effect-arm hole would be a lie about what’s installed.

Flow first is the natural order here

In most languages, sketching control flow before the types exist means commenting code out or writing it in a document. In Koru the flow is the typed surface — the handler tree is the thing the compiler checks — so “prototype mode” isn’t a looser dialect, it’s the same language holding two specific doors open: run what’s built, panic at the holes, and print the list of what’s left. Doodle the flow. The events catch up, one readout line at a time, and the release gate makes sure they’ve caught up before anything ships.