Issue In, Discharge Out

· 10 min read

This is the second in a short series answering David Barbour’s questions about Koru. The first took his point about parameters and context. This one takes his point about effects, and it’s the one where his instinct and our implementation line up most exactly — once you look past the examples we usually show.

His point, restated:

An effect (!) shouldn’t be a bare payload. It’s an interaction in its own right: it has an input context, obligations to fulfill, and an expected output context. The common examples don’t show this — but a full effect has all three.

He’s right, and the honest part of his point is the part about the examples. The effect you see in almost every Koru snippet is the bare one:

! v u64

A value goes out, nothing comes back, no obligation in sight. If that’s all you ever see, “an effect is a full event type” reads like something we’d need to go build. We don’t. The bare form is just the easy corner of a shape that already has all three of his parts.

The three parts, in one signature

Here is an effect branch carrying its full type:

! lend usize -> *Resource<!active>

Read it as his triad, left to right:

  • input contextusize, the value the effect hands to its handler.
  • expected output context-> *Resource, the value the handler resumes with.
  • an obligation — the <!active> phantom riding on that resumed resource.

So his “effects MUST have input context, obligations, and output context” is not a feature request. It’s a description of what an effect branch already is. The only thing the common examples hide is the middle word — obligations — because ! v u64 has nothing to fulfill.

The rule that falls out of the firing count

Here is where his question stops being a yes/no and starts being interesting, because an effect branch is not a function call. It fires zero to many times. A ! each generator yields once per element; the proc decides how many.

That one fact — 0-to-N — decides everything about which obligations an effect may carry, and on which channel. There are two channels:

  • the payload, which flows in (proc → handler),
  • the resume, which flows out (handler → proc).

And the rule is: issue in, discharge out.

You may issue an obligation on the payload. The proc creates a live resource and hands it into the handler, which must settle it within that firing. Producer makes it, consumer consumes it, once per firing — balanced.

! make *Resource<active!>

You may discharge an obligation on the resume. The handler cleans the resource up and hands it back out already settled.

! lend usize -> *Resource<!active>

The other two combinations are rejected, and rejected at the signature — before any body is inspected.

You may not issue an obligation on the resume. That would hand a fresh, un-discharged obligation back out to the proc on every firing — straight into host code Koru can’t police. It escapes.

! lend usize -> *Resource<active!>   // rejected

You may not discharge an obligation on the payload. That would promise to discharge some pre-existing obligation exactly once — through a branch that may fire zero times or a hundred. The count doesn’t line up.

! each *Resource<!active>   // rejected

Two channels, two operations, four cells — and exactly the two that keep the obligation count balanced are allowed. Issue in, discharge out.

Why the asymmetry isn’t arbitrary

It would be easy to read those two rejections as arbitrary restrictions. They aren’t — they’re forced, and the thing that forces them is the firing count.

An obligation has to be discharged exactly once. An effect branch fires zero to many times. So whatever an obligation marker promises on a branch’s signature gets multiplied by that count, and only two of the four cells survive the multiplication. Issue on the payload and discharge on the resume both keep the obligation’s whole life inside a single firing — one issued, one discharged, every firing — so any number of firings still balances. The other two let it straddle the firing boundary. Discharge on the payload promises to settle one pre-existing obligation exactly once, through a branch that may fire zero times or a hundred — the count can’t be pinned to one. Issue on the resume mints a fresh obligation each firing and hands it past the branch’s exit with no matching discharge — the issues pile up, uncounted.

The whole asymmetry is just: an obligation’s count has to be provably one, and a single self-contained firing is the only place that holds. The two allowed cells keep the obligation’s whole life in one firing, where the count is one; the two forbidden cells let the 0-to-N multiplier loose on it. Nothing about what the handler is written in, or whether anyone can see inside it — purely a matter of counting.

And it’s enforced, not decorative. The balanced case — issue a resource, pass it to a branch, discharge it in the same firing — runs:

~gen(n: 3)
! each i |> create(id: i)
    | created r |> destroy(r)
| done _ |> _

Take the discharge away — issue the obligation and just use it — and the compiler rejects the program with error[KORU030] … was not discharged:

The other axis: obligations and scopes

“Issue in, discharge out” governs the two channels of a single effect branch. There is a second axis — what happens to an obligation as it crosses a scope: a loop body, an effect’s firing, a branch. The rule there has three clauses, and like the first it isn’t a style choice. It’s forced by linearity plus the fact that a scope can run zero-to-many times.

1. Inside a scope, you cannot discharge an obligation owned outside it. Consume an outer handle inside a loop body and you have type-checked a double-free: the scope may run twice, and the second pass consumes a binding the first already poisoned. Re-issuing a fresh obligation does not rescue it — that is a different obligation on a different binding, not a rehabilitation of the outer one. The compiler rejects it.

2. Inside a scope, you can issue obligations and discharge them locally. Born and settled within the same firing, the outstanding count is one and linearity holds — the in-firing case from the previous section, read as a scope rule.

3. From an effect or continuation branch, an issued obligation may escape the scope — but the owning event must then handle it, by discharging it in its own implementation or passing it to another branch. This is how an obligation legitimately travels through a loop: issue it from a leaf inside the scope and let it escape to an outside disposer — never by consuming an outer binding.

Notice the shape: every clause is flanked by a green test for what it permits and a green MUST_FAIL for what it forbids. The rule isn’t asserted in prose that can drift — it is true in the suite, which is the only place a Koru rule is ever really written down.

Where this is heading

So his point 2 lands as an affirmation with a bonus: yes, an effect is a full event type with obligations — and because effects fire 0-to-N times, those obligations obey issue in, discharge out, checked before a line of host code is read.

That bonus is also the doorway to his next point. An effect that issues an obligation and is forced to settle it, every firing, is not really a single event anymore — it’s a small protocol that runs to completion each time it fires. Hold that thought. It’s where “effect” starts to sound like the wrong word, and “session” starts to sound like the right one.