Firing Is a Call

· 10 min read

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 i64 has 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.