ADR-0095 · #44 Stage 7: the handler surface — handle … with syntax, the Q38 posture, and the clause calling convention
- Status: Accepted
- Summary: Stages 1–6 landed the kernel arc for user-defined effects (
Handler.customrep, one-shot dispatch, the typed custom-handle rule, and the trusted-three census clean through soundness — ADR-0085/0092, ADR-0085 §Stage-6 STATUSMET). What remains is Stage 7: the surface a user writes and the end-to-endbang evalthat lowers it. Theeffect Name { op : A -> B }half of the declaration surface already landed at Stage 3 (ADR-0092 §Status D1/D2 EXECUTED844931f+88e0f55); this ADR decides thehandle … withhalf. It is also the stress-test the operator deferred Q38 TO (ADR-0093 §Q38-posture: "module-as-file deliberately left no construct to collide with it … run the stress-test THEN") and the decision issue #78 was parked TO (the trait-ops calling-convention fork). Five decisions, each operator-ruled: (D1) the concretehandle e with Name { op(arg) => body }syntax — the Flix/Effekt method-impl clause shape (def op(args, resume) = body), recommended over the Koka match-arm and OCaml exception-style forms because it (a) is syntactically identical to a traitimpl(serving Q38 for free), (b) degrades to v1's ret-shape/one-shot constraint by treatingresumeas an implicitly-tail-called bound name, and (c) grows to multi-shot by loosening the TYPING rule, not changing the surface (rq38 §1 degradation verdict) — amended D1a (2026-07-10): the handled body names the capability via a REQUIRED explicitas hbinder,handle e with Name as h { … }, scoping over the body (the ADR-0070state … as nameprecedent; implicit-lowercase rejected — silently-shadowing nested same-effect handlers — and the optional-default sugar deferred as purely additive), with the decl-order-dependent label resolved at elaboration into a slot on the Surf constructor so lowering staysElabEnv-free; (D2) the Q38 posture — a SEPARATEhandleconstruct now, NOT syntactic convergence withtrait/impl, per the taxonomy's "unify the MACHINERY, keep the surfaces separate until this stress-test rules" (laws-taxonomy §5 caveat) and the census finding that every unification pays at the implementation layer, not the interface (rq38 §2); the interface ALREADY unifies in bang's glossary, so convergence buys nothing and costs the binding-time knob; (D3) the clause calling convention — effects are CURRIED (op(x) => bodyis sugar for a curried clause,opperformed as$cap.op argcurried), decided ONCE with #78's tuple-vs-curried finding in view; the existing trait ops (tuple-stylefn eq(a,b), stranger-test-2 §S3 papercut) DIVERGE-documented in v1 and are flagged for convergence to curried in a follow-up (#78 option B), NOT retrofitted here; (D4) the ret-shape restriction (ADR-0092 §D3-as-landed: v1 clause bodies areret w) surfaces as a SPECIFIC diagnostic ("clause body must be aret-shape value in v1; compute-then-return needs binop typing (ADR-0065) + grade surfacing (Q27)") naming the exact entry gate, NOT a bare type error; (D5) resume's surface spelling — IMPLICIT tail-resume in v1 (a clause body that isret wresumes withw; noresumebinder needed), withresumeRESERVED as a binder name so the explicitresume(w)form and the eventual multi-shot first-classk(Q22/Q27) slot in without a surface break. What Stage 7 does NOT do (explicitly scoped out): the IO prong is ADR-0084's own unit (unblocked BY this stage, not part of it); multi-shot / first-classkstays Q22/Q27 (v1 one-shot pin, ADR-0085 D2); param-UPDATE (put-like clauses) stays ADR-0092 D5 / ADR-0087 §Open-questions; op-name namespacing end-to-end (Net.readdissolving the builtin-name reservation) is named by ADR-0092 §Status as Q34/Q38 module-interface work and rides the module system, not the handler surface. Rejected: the Koka match-arm form (handle(e){ op(x) -> body }— reads as exception-handling, obscures the interface-impl framing that serves Q38); the OCamleffect Op k ->form (exposeskas a first-class binder v1 cannot honor one-shot, and reads as exception matching); syntactic convergence ofhandlewithtrait implin v1 (the taxonomy's implementation-layer-pays finding says the interface unification is free and the surface convergence is the untested claim — this ADR keeps them separate to KEEP the stress-test honest, not to foreclose it); tuple-style effect clauses matching today's trait ops (would double down on the #78/stranger-test-2 §S3 inconsistency the language should shed, not entrench); an explicit-kbinder in v1 (ADR-0085 D2 one-shot pin means a visiblekwould over-promise a control the kernel cannot deliver). - Depends-on: 0085 (the coexist custom-handler arc + one-shot v1 pin), 0092 (the typed custom-handle rule + the ret-shape D3 wall + the
effect-decl surface already landed), 0093 (module-as-file, the Q38-testable-later posture this now runs), 0070 (named-cap surface:as hbinder +$h.opperform — the lowering target already exists) - Relates-to: #44 (Stage 7 of the arc — the surface + e2e eval), #78 (the trait-ops calling-convention fork parked TO this decision — D3 rules it), Q38 (the module≟trait≟effect stress-test — D2 takes the position), Q22/Q27 (multi-shot + resumption grades — D5 leaves the door open, does not enter), Q34 (op-namespacing — the reservation-dissolving fix rides the module interface, out of scope here), ADR-0084 (IO/Net — the first real consumer, unblocked BY this stage), ADR-0065 (binop typing — half the D4 entry gate),
docs/notes/q38-handler-surface-survey.md(the census + degradation verdict this ADR steals from),docs/notes/laws-taxonomy.md§3/§5 (one-theory-three-coats + the machinery-not-surface caveat),docs/notes/stranger-test-2.md§S3 (the trait tuple/curried inconsistency real users hit)
Status
Accepted (2026-07-10, operator ruling: "Adr 95 Approved" — all five decisions as recommended:
D1 Flix/Effekt-shaped handle e with Name { op(x) => body } · D2 separate handle construct,
machinery-unified surfaces-separate (the Q38 stress-test's answer) · D3 effects CURRIED, trait
ops diverge-documented (#78 half-ruled) · D4 the teaching diagnostic for ret-shape · D5 implicit
tail-resume with resume reserved). Implementation = the Stage-7 lane, founded on this ADR +
the s7probe mechanics findings. Originally Proposed same day.
Amended 2026-07-10 (D1a, operator-ruled): the s7probe lane surfaced a genuine gap in D1's
own tracer example — the handled body performs via $net.read, but nothing bound net. Ruled:
a REQUIRED explicit as h capability binder — handle e with Name as h { clauses } — with
h scoping over the handled body e. See §D1a below for the rationale and rejected
alternatives; the examples in D1 are corrected accordingly. This is exactly the §Revisit-if
channel firing as designed.
- Layer: F (frontend — parser + elaborator + error messages) + product docs. Kernel
untouched by construction (invariant #5): the surface LOWERS to the already-landed
Handler.custom+ typed custom-handle rule (ADR-0085 D1, ADR-0092 D3); the five primitives never learn the surface exists (the ADR-0075/0088/0091/0093 elaborate-away move, applied again). The stratification principle's language leaf — the surface is the tested-not-verified edge, checked differentially against the kernel oracle.
Context
The moat — "a language whose paradigm and runtime are VALUES" (CLAUDE.md) — becomes visible
to a user only when they can write their own effect and handle it end-to-end. The kernel
arc for that is DONE (ADR-0085 §Stage-6 STATUS MET; the custom handler dispatches, types, and
rides preservation/progress/type_safety census-clean). The effect Name { op : A -> B }
declaration surface ALSO landed (ADR-0092 §Status: D1/D2 EXECUTED on main — decls parse, labels
allocate ℓ := 4 + declIndex, the program-derived EffSig types performs at user labels,
user labels render in rows). What is left is the OTHER half of ADR-0085 D4's sketch: the
handle e with Name { … } surface, and the bang eval that lowers a user program to a running
kernel term.
This stage is uniquely load-bearing for TWO parked decisions:
- Q38 (module ≟ trait ≟ effect — one construct or several?). ADR-0093 §Revisit-if:
"Stage 7 lands the
effectdeclaration surface → run the Q38 stress-test THEN (module-as-file deliberately left no construct to collide with it)." The module system was built to leave the ground clear for exactly this test. This ADR runs it. - #78 (are trait ops callable by name, and by what calling convention?). Issue #78's own
recommendation: "(C) — park until Stage 7, where the
effectop surface forces the same convention questions; decide both at once." The effect clause surface forces the tuple-vs-curried question; D3 decides it for effects and states what happens to trait ops.
The banked design inputs (docs/notes/q38-handler-surface-survey.md, the rq38 census;
docs/notes/laws-taxonomy.md §3/§5, the one-theory-three-coats framing;
docs/notes/stranger-test-2.md §S3, the real-user friction) are the evidence each decision
below cites. The census's own bottom line — "steal Flix's surface" and "unify the
machinery, keep the surfaces separate" — is the spine of D1 and D2.
The starting point (what the surface must lower to, already landed)
kernel term Handler.custom : Label → Val → List (OpId × Comp) (ADR-0085 D1, ADR-0087 rep)
typed rule handle-custom: clauses typed pointwise, body : opRes ! φ', (ADR-0092 D3)
v1 ret-shape (body = ret w), B-occ anti-escape carried
perform surface $cap.op arg (named-cap: as h binder + h.op, identity dispatch) (ADR-0070)
effect decl surface effect Net { read : Int -> Int } → program-derived EffSig (ADR-0092 D1/D2, LANDED)
The surface this ADR designs is the handle e with Name { … } form that BUILDS the
Handler.custom value and installs it — nothing more. Everything below it is proven.
Decision detail
D1 — the concrete handle … with syntax: the Flix/Effekt method-impl clause shape
Recommendation. Adopt the method-impl clause shape, def-less to match bang's existing
=>-arrow clauses:
handle e with Net as net { -- `as net` binds the capability in e (D1a)
read(x) => x + 100 -- one-shot tail-resume: the BARE value body IS the resume value (D1c)
write(s) => unit -- likewise
}
and, for a handler carrying a parameter (the ADR-0025 state mechanism, read-only in v1):
handle e with (Counter init 0) as ctr {
tick(u) => param + 1 -- design intent: `param` names the carried Val (NOT yet surface-nameable, #87; v1 read-only)
}
A complete v1 bang program using it (the tracer-bullet the implementation lane targets;
call syntax per D1b — bare h.op(args), NOT $-forced):
effect Net { read : Int -> Int } -- the decl surface, already landed (ADR-0092)
let main =
handle
(net.read(1)) + (net.read(2)) -- performs through the D1a-bound `net` (D1b call form)
with Net as net {
read(n) => n * 10 -- one-shot; the bare body IS the resume value (D1c, D4/D5)
}
-- evaluates to (1*10) + (2*10) = 30 -- VERIFIED e2e: examples/handle-custom-tracer
Why this shape (rq38 §1 "Degradation-to-v1 verdict", the census's sharpest finding):
- It is syntactically identical to a trait
impl.handle e with Net { read(x) => body }andimpl Show for Foo { show(x) => body }are the SAME clause form dialed by binding time — which makes Q38's "handler = trait-impl" unification VISIBLE in the syntax, for free (rq38 §1, the method-impl family; laws-taxonomy §3, "one mathematical object, three coats"). This is the payoff D2 then declines to spend prematurely — the shapes rhyme without merging. - It degrades to v1's constraint most gracefully.
resumeis an ordinary bound name, not a keyword, so v1 = "the clause tail-resumes with itsretvalue" (D5) and the SURFACE does not change when D5's typing rule later makesresumefirst-class/multi-shot — only the typing rule loosens (rq38 §1; ADR-0085 D2's "loosen the typing rule, not the surface"). - It matches ADR-0085 D4's own sketch (
handle e with Net { read(x)=>…, write(x)=>resume(…) }) and Stage-3's landedeffectdecl — the two halves compose into one coherent surface.
Rejected alternatives (rq38 §1 census rows + the pattern-match rationale):
- (A) the Koka match-arm form —
handle(e){ read(x) -> body }(orwithsugar). Reads as exception handling generalized (the handler is a pattern-match over operations), which obscures the interface-impl framing D1 wants and D2 needs visible. Koka's real gift is thefun/ctl/final ctlgrade markers (rq38 §4) — those are worth stealing FOR Q27, on theeffectdecl, but not the base clause shape. Rejected for the base form; noted for the grade channel. - (B) the OCaml 5 exception-style form —
match e with | effect Op k -> …. Exposeskas a first-class binder, which v1 (one-shot, ADR-0085 D2) cannot honor without over-promising a control the kernel does not deliver; and it reads as exception matching, the same framing cost as (A). Rejected.
The census verdict is unambiguous (rq38 TL;DR #1): the method-impl family with a named resume
is "the ONE that degrades most gracefully to bang's v1 ret-shape constraint AND grows best
toward D5." D1 adopts it.
D1a — the capability binder: REQUIRED explicit as h (post-approval addendum, operator-ruled 2026-07-10)
The gap (found by the s7probe lane, the §Revisit-if channel firing as designed): D1's
original tracer example had the handled body perform via $net.read with net bound nowhere.
This is not optional plumbing — the kernel's core principle is typing by label, dispatch by
identity (glossary; ADR-0052 rejected dynamic nearest-label dispatch): the body performs
through a named capability value, and nameable caps are what make nested same-effect handlers
expressible at all. Flix needs no cap because it dispatches dynamically by effect name; bang
deliberately does not.
Ruling. The grammar is handle e with Name as h { clauses } — the as h binder is
mandatory in v1, and h binds in the handled body e. Elaboration order: resolve the
effect name and clause-map, install the binder, then elaborate e under the extended context
(the binder is textually after e but scopes over it — same move as a where clause).
This is the ADR-0070 precedent applied unchanged: the built-ins already write
state init as name in body; the user surface inherits the same explicit-binder discipline.
Rejected alternatives:
- Implicit lowercase only (
Netimplicitly bindsnet, matching the original erroneous example). Rejected: nested same-effect handlers become inexpressible — the inner handle silently shadows the outer, so the surface could not express a program the identity-dispatch kernel handles fine. It is also implicit binding, against the explicit-context discipline (invariant #6's spirit; the agent-first lens). - Optional
aswith a lowercase default. Rejected for v1: the default is the footgun above in disguise, and the sugar is purely ADDITIVE — it can be layered later without breaking any explicit-form program, so v1 buys nothing by shipping it now. Not foreclosed. - Binder-first restructure (
handle h : Name { clauses } in e). Rejected: reverses the just-ruled D1 Flix shape for a scoping-presentation benefit the elaboration order already delivers.
Implementation note (ruled with D1a, on s7probe's probe facts): a user effect's label is
decl-order-dependent and known only post-elaboration, but lowerC is a pure structural pass
with no ElabEnv (true even on the typed checkAndLower path). The ruled mechanism is a
resolved-label slot on the handleCustomS Surf constructor: elaboration resolves the label
and rewrites it into the tree; lowering stays a pure function of the tree. Pre-elaboration the
slot holds a placeholder; lowering an unresolved slot is a defined loud error. Rejected: threading
ElabEnv through every lowerC call site (pollutes a structural pass with elaboration state;
the untyped elaborateToComp path has no full ElabEnv to thread). Probe evidence:
docs/notes/stage7-elab-probe.md.
D1b — corrections from the implementation (2026-07-10, e2e-verified)
Three findings from the implementation lane, recorded at landing (the examples above are already corrected):
- The call syntax in this ADR's original examples was WRONG. They wrote
$net.read 1; that form does not parse or type — the D1a-bound cap is already a value, so$would force-then-perform on a non-thunk. The correct perform is the bare parenthesized callnet.read(1), exactly ADR-0070's landedh.op(args)convention. D3's "curried" describes the op's SIGNATURE and its desugaring (write(k, v)⇒ curried), NOT a$f x-style call-site spelling. Independently confirmed by the first consumer's design lane (ndet, G5). withis now a RESERVED word. Without reserving it,pApp's application-fold silently swallowedwith Name { … }as an ordinary application chain — no error, wrong tree. The reservation is the fix; a program usingwithas an identifier now fails loudly at parse.- The carried param has NO surface-writable binder yet. D1's
(Counter init 0)form parses and the param is threaded internally (bound under a sentinel), but no clause can NAME it from source — theparamidentifier in D1's Counter example is design intent, not landed surface. This is the named NEXT SLICE of the handler surface. Priority note from the first consumer (ndet/DST,docs/notes/ndet-dst-design.md§7): its stateless-seed design RETIRED its need for the param binder — the consumer's actual critical-path ask is compute-then-return clause bodies (D4's exit gate: ADR-0065 binop typing + Q27), which therefore outranks the param-binder slice in the queue. LANDED 2026-07-10 — see D1d below:paramis now clause-nameable, read-only, exactly as this bullet's own example intended.
D1c — two more corrections at reference-documentation time (2026-07-10, binary-verified)
Found by the #88 reference lane re-verifying every claim against the live binary rather than transcribing this ADR:
- Clause bodies are spelled BARE — there is no
retkeyword at the surface. This ADR's original examples wroteread(n) => ret (n * 10); the landed grammar isread(n) => n * 10— the bare value body IS the resume value. D4'sret wis the KERNEL typing shape, supplied by elaboration; D5's implicit tail-resume is implicit in the spelling too, not just the semantics. All examples above are corrected. (Independently found by the ndet validation: the drafts that wrote explicitretfailed until corrected.) - D5's
resumereservation is NOT implemented.effect Foo { resume : Int -> Int }checksoktoday — a program may bind or declareresume, so the future explicitresume(w)form would be a BREAKING change, exactly what D5's reservation existed to prevent. Filed as a surface gap; the fix (addresumeto the reserved-op/binder list, with a teaching diagnostic) is a small slice on the effects-surface lane.
D1d — the carried param becomes clause-nameable (2026-07-10, issue #87, landed)
D1's own worked example (tick(u) => param + 1) named param as design intent from the start;
D1b finding 3 recorded that the binder was NOT yet wired to any clause-body identifier — the
(Name init) form's init was threaded internally (bound under an unreachable #-sentinel) but
nothing in a clause body resolved to it. Landed exactly as D1's example specified: the bare
identifier param is RESERVED as a binder keyword (pIdent, the same mechanism with/resume
already use — no clause-arg, as h cap-binder, let/fun name, etc. can shadow it) and, inside
a (Name init) as h { … } clause body, param resolves to the carried init value via the
SAME ordinary variable lookup every other identifier uses (the internal #-sentinel indirection
is retired — param is pushed onto the lowering/typing context as the literal string, safe by
construction because the reservation makes collision unrepresentable). Read-only, matching D1's
own scope (ADR-0092 D5's param-UPDATE protocol stays deferred — no write surface was added).
examples/handle-custom-resume now reads its carried 100 through param for real, retiring
the "hardcodes the literal" gap #87's report found.
Recommendation. Keep handle/effect as their own surface constructs, distinct from
trait/impl, in v1. Do NOT converge the syntaxes even though D1 makes them rhyme. This is
the taxonomy's standing verdict made concrete (laws-taxonomy §5 caveat, verbatim):
"the rq38 census shows surface unifications pay at the implementation layer — so unify the MACHINERY (one propagation engine + one law gate), keep
trait/effect/axisas separate surface declarations until the Stage-7 stress test rules."
This ADR IS that stress test, and the position it takes is: the machinery is already unified; the surfaces should stay separate. Two grounds, both from the banked census:
-
The interface unifies for free; every attempt PAID at the implementation layer (rq38 §2, the unification table). 1ML unified modules≡functions and paid in inference (undecidable → annotate). Effekt unified effects≡capabilities and paid in first-class-ness (caps 2nd-class). bang has ALREADY unified the interface — its own glossary says "a handler is a value implementing an effect's operations; a trait impl is a value implementing a trait's operations" (the same sentence) — and has ALREADY paid the predicted prices: the answer-grade wall (ADR-0092 D3, currency #1) and the cap-escape arc (ADR-0063, currency #2). So surface convergence buys nothing bang doesn't already have, and the one thing it would have to prove — that the binding-time knob (static trait-resolution vs dynamic handler-install) fits on ONE resolution story — is the exact claim NO shipped language has demonstrated together with the row/label discipline bang commits to (rq38 §2 verdict-shape).
-
The semantic unification is already fixed; the surface split loses nothing (laws-taxonomy §3). Plotkin–Power: effects ARE algebraic theories, handlers ARE their algebras; a trait is the same theory with a data carrier, a module the same signature with no laws. "Whatever surface Stage 7 picks, the SEMANTIC unification is already fixed." Keeping the surfaces separate does not re-fork the semantics — it keeps the binding-time knob (the load-bearing difference) EXPLICIT rather than hidden behind a merged syntax that would have to disambiguate it anyway.
The verdict-shape the evidence points to (rq38 §2, and Tang & Lindley POPL'26 "Rows and Capabilities as Modal Effects", refs.bib) is "one interface construct, two modalities (static/label vs dynamic/capability) dialed by binding-time + grade — NOT one flat construct, and NOT four separate ones." bang's already-committed typing-by-label / dispatch-by-identity architecture IS that graded-modal split. D2's position — separate surfaces over unified machinery — is the faithful surface for that architecture: the modalities are visibly different constructs BECAUSE they are different modalities, not despite it.
Rejected alternatives:
- Syntactic convergence of
handlewithtrait implin v1 (one construct, dialed by a keyword). Rejected: it spends the interface-unification payoff on an UNTESTED claim (that the binding-time knob fits one resolution story) precisely when the census says that claim is the load-bearing risk. It would ALSO force the op-namespacing (Q34) and grade-channel (Q27) work to land together with the surface, coupling three deferred questions into one. Keeping the surfaces separate keeps them independently schedulable. NOT foreclosed — if a future stress test shows the knob fits cleanly, convergence becomes a surface refactor over the same machinery (the same "later refactor once the risk is proven" shape as ADR-0085 D5). - Four fully-separate constructs with no shared machinery (the Unison "keep separate on
purpose" arm, rq38 §2). Rejected the other direction: bang has ALREADY unified the machinery
(the graded-row engine, the law gate,
EffSigas the shared interface) and would throw that away. The position is unify-machinery / separate-surface, not separate-everything.
D3 — the clause calling convention: effects are CURRIED; trait ops diverge-documented
Recommendation. Effect operations are curried, matching the rest of the language. A
clause read(n) => body is surface sugar for a single-parameter curried clause; a two-argument
op write(k, v) desugars to a curried write(k) => fun v => … at the elaborator, and the
perform site is curried too ($net.write key val, not $net.write (key, val)). This decides
#78's fork (2) — "the calling-convention inconsistency" — ONCE, for effects.
What happens to trait ops (#78's explicit ask — "decide ONCE for effects, traits follow or
diverge-documented"): trait ops today are TUPLE-style (fn eq(a, b), applied eq(3, 4) as one
pair — ADR-0068, confirmed by stranger-test-2 §S3 as a real papercut: "the rest of the language
is curried … but a trait op is declared fn eq(a, b)"). In v1, effects are curried and trait
ops STAY tuple-style — a documented divergence, flagged loudly in the reference and NOT
silently inconsistent. The convergence (making trait ops curried too, #78 option B's
.app-shaped resolution path) is a SEPARATE follow-up, because it is kernel-adjacent (an
elaboration rule mirroring the .binopS arm, ADR-0068) and entangled with #78's fork (1) —
trait ops callable by name at all — which is its own decision. Coupling the trait-op refactor
into this surface ADR would drag a kernel-adjacent change into a frontend-leaf unit.
Why curried for effects (and why not follow the trait precedent):
- The perform surface is ALREADY curried —
$cap.op arg(ADR-0070 named-cap:as hbinder +$h.opperform). Making the clause tuple-style would mean the DECLARATION and USE sites of the same op disagree on shape — the exact inconsistency stranger-test-2 §S3 flagged for traits, now avoided for effects by construction. - Curried is the language's ground convention (
fun x => …,$f x, effect op typedInt -> Int). Effects are new surface; they should be BORN consistent, not inherit the trait ops' historical tuple shape. The agent-first lens (ride conventions unless semantics is novel — memoryagent-first-ergonomics-lens) says a new construct matches the dominant pattern.
Rejected alternatives:
- Tuple-style effect clauses (
read(n)as one pair-arg, matching today's trait ops). Rejected: entrenches the #78/stranger-test-2 §S3 inconsistency the language should shed. The clauseread(n)LOOKS like a paren-call but is really a curried single binder; the paren is grouping/readability sugar, not a tuple — the elaborator treatsop(a, b) =>as curriedop a b =>. - Deciding trait ops in this ADR (retrofit them to curried here). Rejected: #78 fork (1) (name-callability) and fork (2) (convention) are entangled and kernel-adjacent; this is a frontend-leaf surface ADR. D3 rules the convention for the NEW construct (effects) and names the trait convergence as a separate, sequenced follow-up — the honest scope boundary.
D4 — how the ret-shape restriction surfaces: a specific diagnostic, not a bare type error
The constraint (ADR-0092 §D3-as-landed): v1 custom clause bodies must be the RETURN shape
ret w (a HasVTy premise). This is forced by the answer-GRADE wall — the perform's returner
grade is free, a general body's grade is structure-pinned, and no re-grading lemma exists
(ADR-0092 D3, evidenced by three committed probes). A clause like read(n) => ($net.read (n+1))
(compute-then-return, effectful body) does NOT type in v1.
Recommendation. When a clause body is not ret-shaped, emit a SPECIFIC diagnostic that
names the constraint AND the documented path to the general form, not a bare "type mismatch":
error: handler clause body must be a `ret`-shape value in v1
in `read(n) => <body>`
v1 clause bodies return a value directly (`ret w`); a compute-then-return body
is not yet typeable. This needs binop typing (ADR-0065) + resumption-grade
surfacing (Q27) — tracked as the general-body entry gate. See the handler
reference § "v1 clause restriction".
This rides the project's fail-loud invariant and the stranger-test finding that "parser error messages double as a teaching tool" (stranger-test-2 §Strengths — a user with zero docs reconstructed the trait grammar from the error messages). The diagnostic teaches the fix and names the exact ADRs (0065 + Q27) that constitute the entry gate, so a user (or agent) hitting the wall knows precisely what has to land for the general form — it is a scoped restriction with a named exit, not a mysterious rejection.
The documented path to the general form (ADR-0092 D3, verbatim): the compound entry gate is binop typing (ADR-0065) + grade surfacing (Q27). Until both land, the flagship compute-then-return clause stays untyped-fragment-only. The diagnostic points there.
Rejected alternatives:
- A bare type-mismatch error (the elaborator's generic "expected
ret A, got …"). Rejected: it does not name the v1 restriction as a restriction, so a user reads it as "my program is wrong" rather than "this form is deferred" — the difference between a papercut and a teaching moment (stranger-test-2 §S3 is exactly the papercut version for traits). Fail-loud means loud AND specific. - Silently accepting the clause and failing later (at the machine, or with a stuck term). Rejected outright by invariant #1 (proof rides the reference) and the fail-loud principle — a clause the type system cannot honor must be rejected at check time, at the clause, with the reason.
D5 — resume's surface spelling: implicit tail-resume in v1, resume reserved for the future
Recommendation. In v1, resumption is IMPLICIT: a clause whose body is ret w (D4) resumes
the captured continuation with w. There is NO resume binder in v1 clause bodies — the
ret-shape body IS the resume value (mirroring the built-ins' identity return-clauses, ADR-0092
D3). BUT resume is a RESERVED binder name (a user effect may not declare an op named resume,
nor bind it), so the explicit form slots in without a surface break:
v1 (implicit): read(n) => n * 10 -- the bare value body = the resume value (D1c)
future (explicit): read(n) => resume (n * 10) -- `resume` first-class; tail position in one-shot,
-- multi-shot when Q22/Q27 land
Why implicit-with-reserved-name (rq38 §1 continuation-appearance ladder + ADR-0085 D2):
- v1 is one-shot tail-resumptive (ADR-0085 D2, the load-bearing scope pin). The implicit form is
the honest surface for that: there is nothing to name because the continuation is invoked
exactly once, at the tail, with the
retvalue. Frank and Koka'sfunclauses do exactly this — "nokin source; compiles to a stack frame" (rq38 §1 ladder, IMPLICIT tail row). - Reserving
resumemeans the spelling does NOT foreclose the hybrid future (rq38 TL;DR #1: "resumeis an ordinary bound name, so v1 hardcodes tail-callresumeat the end while the syntax is already ready for the dayresumebecomes first-class"). When Q22 (closure cap-rep)- Q27 (resumption grades) land,
resume(w)becomes a bound name the clause may invoke — one- shot in tail position first, then multi-shot when the grade admits it (the rq22 ω-channel future, Q27'sfun/ctldistinction). The surface grows by ADDING the explicit form, never by breaking the implicit one.
- Q27 (resumption grades) land,
- This matches the grade channel D1's rejected-Koka-note reserves: Koka's
fun(implicit tail) /ctl(namedresume, general) split (rq38 §4) is exactly the v1-implicit → future-explicit ladder, dialed by the operation's declared grade at theeffectdecl. v1 = allfun; theresumereservation is what letsctlland later without a surface break.
Rejected alternatives:
- An explicit
kbinder in v1 (read(n, k) => k (n*10), OCaml-style). Rejected: v1 is one-shot (ADR-0085 D2); a first-classkthe user can name promises a control (store it, invoke it twice) the kernel cannot deliver until Q22. Surfacingkwould over-promise — the same over-promise D1 rejected the OCaml form for. Implicit tail is the surface that matches the actual one-shot semantics. - No reserved name at all (let users name an op or binder
resume). Rejected: it would force a breaking surface change when the explicit form lands (a program usingresumeas an op name would collide). Reserving it now (elaboration-only, zero runtime cost — the same move ADR-0092 §Status made for builtin op names) keeps the future free.
What Stage 7 does NOT do (explicit scope boundary)
concern status owner / gate
──────────────────────────────────────────────────────────────────────────────────────
IO / Net effect UNBLOCKED BY, not part of ADR-0084's own unit (its {Net} handler
this stage is the first real consumer of D1's surface)
multi-shot / first-class k DEFERRED Q22 (closure cap-rep) + Q27 (grades);
D5 reserves `resume` so it lands cleanly
param-UPDATE (put-like) DEFERRED ADR-0092 D5 / ADR-0087 §Open-questions;
v1 clauses are read-only param (D1 `param`)
op-namespacing (Net.read) DEFERRED Q34/Q38 module interface (ADR-0092 §Status);
— dissolves the ADR-0092 rides the module system, NOT this surface
builtin-name reservation
trait-op convention DIVERGE-documented in v1 #78 fork (1)+(2) — a separate follow-up
convergence (kernel-adjacent, D3 names it)
The Net effect (ADR-0084) is the sharpest confirmation this ADR's surface is right-sized: ADR-0084
gated its genuine {Net} effect on #44 (ADR-0085 §Context), and D1's handle e with Net { read(x) => … } is EXACTLY the surface an ADR-0084 IO handler installs. Stage 7 opens the door; ADR-0084
walks through it — as its own unit.
Invariant compliance
- #5 (five primitives): the surface LOWERS to the already-landed
Handler.custom(a fourth constructor of the existing handler primitive, ADR-0085 D1) via the elaborate-away move — no kernel change, no sixth primitive. The parser/elaborator own thehandle … withform; the kernel never learns it exists (ADR-0093 §Layer, fourth application of ADR-0075/0088/0091). - #1 (proof rides the reference): the e2e
bang evalof a user program is diff-tested againstSource.eval(the kernel oracle) — the same differential-#guard discipline ADR-0093's v1 oracle uses (elaborate(surface) ≡ the hand-built kernel term). No execution path ships without the oracle behind it. The tracer-bullet program (main = handle … with Net as net { read(n) => n*10 }→ 30) is the first such #guard. - #6 (no implicit capture; reactivity is the operator): the
handle … withform installs a handler explicitly at the use site — the "runtime is a handler installed at the use site" thesis made surface-visible (ADR-0093 D5's manifest framing). Nothing captures implicitly.
Revisit if
- The implementation lane (s7probe) finds the
handle … withelaboration needs a surface shape D1 did not anticipate (e.g. multi-op clause-map parsing collides with block syntax) → surface the obligation; D1's method-impl shape is the design intent it refines FROM (the same "sketch-stands-as-intent" clause ADR-0092 D3 used). - Q22 (closure cap-rep) + Q27 (resumption grades) resolve → D5's
resumereservation activates: add the explicitresume(w)form and thefun/ctlgrade markers on theeffectdecl (the Koka split, rq38 §4), one-shot-tail first then multi-shot. Surface grows additively. - ADR-0084 lands IO → the first real
handle e with Net { … }consumer exercises D1 end-to-end; the capability-manifest checking (main's row ⊇ what the runtime provides, ADR-0093 D5) lands with it. - A future stress test shows the binding-time knob fits ONE resolution story cleanly → D2's separate-surface position becomes a candidate convergence refactor over the same machinery (the ADR-0085 D5 "collapse once the risk is proven" shape). NOT foreclosed by v1.
- #78 fork (1) (trait ops callable by name) gets ruled → D3's trait-op convergence-to-curried lands with it (the follow-up D3 named).
Evidence
docs/notes/q38-handler-surface-survey.md (rq38: §1 the handler-surface census + degradation
verdict — "steal Flix's surface, method-impl clause shape degrades most gracefully"; §2 the
unification table — "interface unifies for free, every attempt pays at the implementation layer";
§4 the grade-as-dial thesis — Koka fun/ctl, Tang POPL'24, the future D5 grows into);
docs/notes/laws-taxonomy.md §3 (one-theory-three-coats — Plotkin–Power, "semantic unification
already fixed, whatever surface Stage 7 picks") + §5 (the machinery-not-surface caveat, verbatim —
"unify the MACHINERY, keep the surfaces separate until this stress test rules");
docs/notes/stranger-test-2.md §S3 (the trait tuple/curried inconsistency real users hit) +
§Strengths (error-messages-as-teaching-tool, the D4 rationale); ADR-0085 (the coexist arc +
one-shot D2 pin + §Stage-6 STATUS MET); ADR-0092 (the typed custom-handle rule + the ret-shape
D3 wall + the effect-decl surface LANDED); ADR-0093 (module-as-file, the Q38-testable-later
posture this runs); ADR-0070 (the named-cap perform surface D1 lowers onto); issue #78 (the
trait-ops calling-convention fork D3 rules); ADR-0084 (the IO consumer unblocked BY this stage).