Firing Is a Call
Yesterday, Effects Get the Full Return ended where every post in this arc ends — at the named frontier:
Subflow-implemented effects (
400_115) — still red, still the genuinely new ground: effect firings checked at the flow level instead of trusted to opaque host code.
That test is green now, along with the eight pins that spell out the whole feature. This post is the payoff.
The trust boundary we were living with
Until today, every effect-bearing event in the entire corpus was ~proc-backed. Fifty-nine files declare a ! arm; before this change, zero
implemented one from Koru. The generator everyone has seen by now:
~pub event gen { n: usize }
! each usize
| done usize
~proc gen|zig {
for (0..n) |i| { each(i); }
return .{ .done = n };
} — `400_100`, the proc-backed shape The proc is a trust boundary. Koru hands it the effect symbols and believes whatever it does with them — the firing happens inside opaque host code, “unsafe”-style. The consumer side of effects has been checked at the flow level for a long time; the producer side never was, because there was no producer written in Koru to check.
The ruling: no new grammar
The design question was: what does a subflow spell to fire an arm? The
original 400_115 sketch guessed at a statement form — ! each i in the
impl body, the handling glyph reused as a firing glyph. That spelling is
dead. The ruling is simpler and, once you see it, obvious:
Inside the implementation of the declaring event, the event’s own arms are callable. Firing an arm is calling it.
That is exactly what the proc body was already doing — each(i) is a call
there too. So the subflow does the same, and everything downstream reuses
established grammar:
~pub event gen { n: usize }
! each usize
| done usize
~gen = for(0..n)
! each i |> each(i)
| done => done n — `400_115`, the historic pin, now green on the ruled spelling Read the impl: for(0..n) drives the iteration, its ! each i handler
chains into a call of gen’s own each arm — a stock |> step — and
for’s | done terminal maps to gen’s terminal with the stock => branch
mapping. Three lines, zero new glyphs. The only novelty in the language is who may call an arm, and that’s the point of the next section.
The pure-.k twin (400_131) pins the same shape with no ~ anywhere, and
the minimal case is one line — a void arm fired as the impl’s head, the same
shape as ~run = step():
pub event ping { x: i64 }
! pong i64
ping = pong(x) — `400_130`; the consumer's `! pong reply |> ...` prints 42 The resume comes back in flow-land
An arm that resumes needs its value back in the impl. The proc binds it
with const a = ask(q). The subflow binds it with grammar that already
existed — which spelling depends on what the arm declares, and the
declaration decides, same as everywhere else in this arc.
A single -> T resume binds at the call site with : and continues — this
is byte-for-byte the bind-then-construct shape from 100_053:
pub event query { q: i64 }
! ask i64 -> i64
| done i64
query = ask(q): a => done a — `400_132`; the consumer answers `! ask v -> v + 1`, prints 42 A multi-arm resume sum — yesterday’s feature — is consumed as | branches at the firing site, each mapped to a terminal with =>. Where the
proc switches a tagged union in Zig, the subflow writes what it always
writes for a sum, 240-style branch mapping:
pub event request { payload: i64 }
! ask i64
| halved i64
| timeout
| done i64
request = ask(payload)
| halved v => done v
| timeout => done 0 — `400_133`; the consumer answers `=> halved n - 10`, prints 10 The full-return symmetry closes here: the resume side of an effect has named
branches, and now both implementation styles consume them idiomatically —
the proc with a host switch, the subflow with branch handlers. Payloadless
arms fire with a bare call (request = ask(), 400_134), and record arms
bind and field-access like any other payload (| result v => done v.quadrupled + v.halved, 400_135).
All the firing-site rules are enforced, not documented: a multi-arm fire
must handle every arm (the proc gets that exhaustiveness from Zig’s
switch; the subflow gets it from the checker), a -> T fire takes a : bind and no branches, and a void fire takes nothing.
Bare values and named fields
One design conversation surfaced after the pins went green: pong(x) looks
positional — does that deviate from the language, where event calls name
their fields (ping(x: 41))? The resolution is that the event-call mirror
is the wrong one. Arms are branches, and firing is construction’s
call-twin, so the fire follows the branch payload’s shape:
- identity payload → one bare value:
pong(x), the call-twin of=> done n. No pun is possible —! pong i64has no field name to pun against. - record payload → named fields, the call-twin of
=> result { halved: ..., quadrupled: ... }:
pub event scan { n: i64 }
! token { kind: i64, val: i64 } -> i64
| done i64
scan = token(kind: 1, val: n): t => done t — `400_139`; the consumer answers `! token t -> t.kind + t.val`, prints 42 A positional fire against a record arm would read as punning, and it hides which value lands in which field — so it’s a wall, and the wall spells out the fix:
error[KORU030]: effect 'token' carries a record payload —
fire it with named fields: token(kind: ..., val: ...) — `400_140` (MUST_FAIL) The declaration lives three lines above the fire, so the names read against their contract in place. Arity is walled in the other directions too: a payloadless arm fires bare, an identity arm takes exactly one value.
What it compiles to
The proc path lowers each(i) through a comptime handler struct — the
consumer’s ! each body becomes a function, the producer calls it, and
everything monomorphizes flat. The subflow path lands in the same
machinery. This is gen’s emitted handler for 400_131, tidied for
whitespace only:
pub fn handler(__koru_event_input: Input, comptime __H: type) Output {
const n = __koru_event_input.n;
for (0..n) |__koru_item_0| {
const i = __koru_item_0;
__H.each(i);
}
return .{ .done = n };
} The for template’s loop splices directly into the handler; the arm-fire is
a comptime __H.each(i) dispatch into the consumer’s handler function; the
terminal mapping is a plain return. No indirection, no allocation, no
runtime dispatch — the subflow impl costs exactly what the proc impl cost.
The difference is entirely in what the compiler gets to see on the way.
Only the impl may fire
Arm names are not events. They resolve as calls in exactly one place — the body of the declaring event’s own implementation. Anywhere else, the wall goes up, and it names the rule instead of shrugging:
error[KORU040]: 'pong' is an effect arm of event 'input:ping' —
only that event's own implementation may fire it — `400_138` (MUST_FAIL), a second event's impl trying to fire ping's arm This wall got built the day the feature did, because the feature is about the boundary: an effect arm is the declaring event’s contract with its
consumer, and letting anyone fire it from anywhere would dissolve the thing
the checkers reason about. While we were at it, the diagnostic in the other
direction got fixed too — an unknown event in a chain step used to surface
as a nameless coordination error with a host stack trace; it now reports KORU040 with the event name and source location, like it always should
have.
The prize: obligations, proved per firing
The reason this arc exists. An effect arm fires 0-to-N times, so the
obligation rule is per firing: what’s born in a firing dies in that
firing, because ! each carries no resume that could carry it out. With a
proc-backed generator, that rule held by trust. With a subflow-backed one,
the phantom machinery sees the whole circuit — producer, firing, consumer —
and the pins prove both directions with zero host code: the obligation
source is std/field (new issues <field!>, free discharges it).
The balanced case runs: the consumer’s handler creates a field and frees it
inside the same firing, three firings, three clean lifecycles (400_136).
The unbalanced case taught us something better than what we pinned. The
handler creates the field and doesn’t free it — and the first thing the
suite said was: that program is legal. Under default flags, std/field:free is the unambiguous discharge for <field!>, so the
auto-discharge inserter settles the obligation per firing, inside the
handler body — the per-firing rule holding by insertion, identically for
proc-backed and subflow-backed generators. The pin as written contradicted a
feature the language already had.
So the negative pin now runs under --auto-discharge=disable, the same
contract 330_025 and the phantom-explorer tests use: insertion off,
enforcement on. And that surfaced a real hole — the enforcement side only
balance-checked at explicit terminators, so a pipeline that ends on an
invocation (which is every effect-handler leak, proc or subflow) was never
checked at all. The insertion side had always treated that as an implicit
exit; the enforcement side now does too:
error[KORU030]: Resource 'f' <field!> was not discharged. Call: std.field:free — `400_137` (MUST_FAIL), the leak caught through the subflow-implemented firing path Where it stops now
Same discipline as always — the frontier is named, not hidden.
- Flow-level accounting for arm discharges, carried over from yesterday: the emit is proven, the ledger integration is the remaining depth.
- Arm-fires beyond the pinned positions. The pins exercise arms as impl heads and as chain steps inside a driving template’s handlers. A multi-arm fire as a nested chain step, and module-level events implemented by arm-firing subflows, are wired in the emitter but not yet pinned — and in this house, not pinned means not claimed.
The tests are the spec. If this post and the compiler ever disagree, the compiler wins and this post is the bug.