Stage-7 handle … with elaboration mechanics probe (#21 s7probe)
Status: DONE — implemented against the RULED ADR-0095 grammar (accepted 2026-07-10,
docs/decisions/0095-stage7-handler-surface.md), e2e-verified. The original probe was built
against a provisional strawman; once ADR-0095 landed (all five decisions as recommended), the
manager upgraded this unit to a real implementation and ruled two open gaps (below). All three
bang eval reference programs — the ADR's own D1 tracer bullet plus the Stage-2 kernel's
customResume/customAbortCoexist #guards ported to source text — now produce the exact
expected values.
What was built
handle e with Name as h { op(x) => body, … } (param-less) / handle e with (Name init) as h { … } (param-carrying), end to end through the frontend:
Bang/Frontend/Surface.lean:Surf.handleCustomS(6 fields: a resolved-label slot, the effect-name reference, the param-init asSurfArgs, the mandatory cap binder, the clause list, the handled body) +HClausesmutual list (theDArmsprecedent), a bespokepExprparser arm (pHandlerName/pHClause/pHClauses),withnewly RESERVED (§Finding 3), and a REALlowerCarm buildingHandler.custom+Comp.handle(WALL 1 fixed — see below).Bang/Frontend/TypeCheck.lean:synthSC's.handleCustomStyping arm (discharges the label, binds the cap, checks clause coverage + the D4 ret-shape/effect-free property), acheckHClausesmutual sibling,elabS's.handleCustomSarm (RESOLVES the label againstenv.effectsand REWRITES it into the tree's slot — the WALL-1 fix's real half), anelabHClausessibling, and the exhaustive-match completions every otherSurf-matching helper needed.Bang/Frontend/Format.lean: a printer arm matching the ruled grammar (clause-list rendering stays a placeholder — round-trip fidelity forHClausesis a follow-up, not blocking).
Full build green (749/749 jobs), kernel census untouched (26 constructors), no kernel/Backend/Meta files touched.
The two rulings that resolved this unit's open gaps
- D1 binding-order gap (flagged by this probe against the ADR's own tracer-bullet text,
which used an unbound
netbefore any binder introduced it): operator-ruled, reading (b) —as his MANDATORY in v1, no implicit lowercase-of-Name default (rejected: silently shadows a nested same-effect handler). Scope:hbinds in the handled bodye(elaborate the clause-map + install the binder first, theneunder the extended Γ — reading (c)'s mechanics, reading (b)'s surface). The ADR is being amended with a D1a addendum recording this. - WALL 1 (the resolved-label slot): manager-ruled Option A — add the slot directly to
handleCustomS;elabSresolves + rewrites it;lowerCstays a pure function of the tree (noElabEnvthreading — rejected as polluting a structural pass with elaboration state, and the untypedelaborateToComppath lacks a fullElabEnvanyway).
WALL 1 — RESOLVED (Option A, implemented)
lowerC gained no ElabEnv — instead Surf.handleCustomS's first field is Option Label,
none at parse time, rewritten to some ℓ by elabS's new arm (the ONE place with both the
tree and env.effects in scope). lowerC reads the slot directly and fails loud on none
(elaboration never ran, or the effect name never resolved). Option Surf was tried for the
param-init field first and REJECTED for an unrelated but load-bearing reason: Lean's
deriving DecidableEq cannot see through Option <mutual-self-type> across a mutual inductive
group (confirmed via isolated repro) — SurfArgs (already in the file, the .dotPerform
precedent) was reused instead, generalizing the SAME reason SurfArgs/DArms/LetBindings
are bespoke mutual types rather than List/Option of Surf in the first place.
WALL 2 — mechanics lesson (unchanged from the strawman probe, still load-bearing)
Clause-list typing needs a mutual-sibling shape, not a for/let rec:
- A
forloop over a convertedList:sizeOf-based termination can't see through the opaque conversion, so the innersynthSV/synthSCcall failssynthSC's owntermination_byproof. - A local
let rec: silently joinssynthSC's 4-way mutual group; Lean can't find a joint measure, breaking the WHOLE file's termination and cascadingsorry-taint through every downstream#guard.
The fix: checkHClauses is a genuine THIRD mutual partner (the elabS/elabArms
precedent), structurally recursing on HClauses with its own termination_by cls => (sizeOf cls, 4). Generalizes to any future repeated-group Surf payload needing typing recursion.
WALL 3 — RESOLVED, and found to be SYSTEMIC (two occurrences, not one)
elabS's "throwaway inference" helpers seed a FRESH, effects-less USt (.run' {}) — invisible
until a user-effect construct exercised them, since no built-in .dotPerform op ever consults
USt.effects. Two independent occurrences, both fixed:
elabBind(the.lettarm's let-generalization decision): fixed first, threadingeffects(default[]) fromenv.effects.zonkInferC/anfSplit(the A-normalization helper EVERYelabSarm with a computation-position operand calls — 19 call sites): found LIVE by the e2e probe's OWN tracer bullet (net.fetch(1) + 1— the.binopSarm's A-normalization of the left operand hitsanfSplit, whosezonkInferCthrowaway run rejected the already-well-typed.dotPerformwith a wrong "not a declared effect" diagnostic). Fixed the SAME way:zonkInferCandanfSplitboth gained aneffectsparameter (default[]), threaded fromenv.effectsat all 19 call sites.
Generalizes: any "run synthSC/synthSV in a throwaway sub-inference" helper in this file
needs effects threaded, or it silently breaks the FIRST time a user-effect construct appears
under it. Worth an audit pass for any THIRD occurrence not yet exercised.
WALL 4 — RESOLVED (was NOT a pipeline divergence — a genuine bug in the clause-typing arm)
The originally-reported "typed vs untyped path disagree on an identical tree" turned out to be a
RED HERRING from testing artifacts, not a real divergence. The actual bug: checkHClauses
checked each clause body via synthSV (VALUE synthesis) — but a clause body like n * 10 is a
COMPUTATION (.binopS reduces via the kernel's Comp.binop, needing Comp/synthSC typing,
not Val/synthSV). synthSV has no .binopS arm, so ANY non-atomic clause body
unconditionally hit its catch-all "not a value" error — under BOTH typed and untyped framings
of my test harness, which is what made it look like a path divergence; the untyped path simply
never reached this check at all (no synthSC/synthSV runs there), so "worked" for the wrong
reason. Fixed: checkHClauses now uses synthSC (computation typing) + an EXPLICIT
ret-shape/effect-free check (decide (φ.labels = ∅) && φ.tail.isNone after resolveRow —
Finset.isEmpty is noncomputable on this path, the corpus-established decide-based workaround)
— ADR-0092 D4's "ret w is EFFECT-FREE" property is now checked DIRECTLY, and the ADR-0095 D4
teaching diagnostic fires exactly when a clause body performs before resuming (falsified live:
fetch(n) => raise n produces the exact D4 message naming ADR-0065 + Q27).
The e2e verdict: three bang eval programs, all exact
- ADR-0095 D1's own tracer bullet (renamed
read→fetch,net.-perform syntax fixed to the parenthesizednet.fetch(1)call form — see Finding 2 below):→effect Net { fetch : Int -> Int } handle (net.fetch(1)) + (net.fetch(2)) with Net as net { fetch(n) => n * 10 }bang eval= 30, exactly as the ADR promises. - Stage-2 kernel's
customResumeported to source:→effect Reader { fetch : Int -> Int } handle (let r = net.fetch(5) in r + 1) with (Reader 100) as net { fetch(x) => x + 100 }bang eval= 106, matchingBang/Core/Semantics/Eval.lean'scustomResume#guard. - Stage-2 kernel's
customAbortCoexistported to source (nestedhandle,raiseaborting past the custom frame):→effect Reader { fetch : Int -> Int } handle (handle (let r = raise 42 in net.fetch(5)) with (Reader 100) as net { fetch(x) => x + 100 })bang eval= 42, matchingcustomAbortCoexist's #guard.
Findings for the ADR / a future contributor (beyond the two already-ruled gaps)
- A clause list is a repeated group — confirms ADR-0071 ②'s own documented
keywordRuleboundary generalizes here too; not a constraint on the spelling, but on the mechanism (anyhandle … with { … }syntax needs a bespoke parser arm, never a linearChoicerule). - The ADR's own D1 example has a call-syntax slip:
$net.read 1does not parse/type the way the prose implies.$forces its ATOM argument (pAtom, not the dot-chain), so$net.fetch(1)parses as(force net).fetch(1)—.dotPerform's receiver becomes a.force-computation, whichsynthSV(value-only) rejects outright ("not a value"). The cap bindernet/his ALREADY a value (Cap ℓ, bound directly byhandleCustomS's Γ extension) — it never needs forcing; the correct call is a barenet.fetch(1)(no$, matching ADR-0070's existingh.op(args)convention exactly). Separately, space-separated call syntax (net.fetch 1, no parens) parses as(net.fetch) 1— a NULLARY perform applied to1as a function call, not a 1-arg perform — the parenthesized formnet.fetch(1)is REQUIRED; D3's "curried" framing describes the OP SIGNATURE convention (a single-arg arrow), not the CALL-SITE syntax, which stays.op(args)per ADR-0070. Worth a corrected example in the ADR amendment. withneeded to become a reserved word. Without reserving it,e with Name { … }parsed as ONE giant application chain (pApp's juxtaposition fold happily consumedwith/Name/the{…}thunk as successive atoms/arguments ofe), since nothing markedwithas a non-identifier boundary token. Fixed in bothpIdentandpAtom's reserved-word lists — the same class of fix #26 made forread/write/get, generalized to a new keyword.- The carried param's binder name (
param) is INTERNAL, not surface-writable in the current implementation — a clause body cannot reference the carried param by any name (bound under an internal"#param"sentinel). The ADR's own worked example (tick(u) => ret (param + 1)) impliesparamshould be a real, referenceable identifier. This is a real gap, not yet closed — a follow-up should either surfaceparamas an actual bound name incheckHClauses's/elabHClauses's Γ (straightforward: add("param", P)under its real name alongside the"#param"sentinel, or replace the sentinel outright) or the ADR should clarifyparamis reserved-word sugar for the carried value. Flagged for the next slice, not blocking (the e2e programs above don't exercise param-referencing bodies). - Reserved-keyword collision at the op level: an
effectop sharing a name with a built-in (read,get, …) fails at PARSE time (pIdentrejects the keyword) rather than the more informative "reserved" diagnosticbuildEnvalready gives for the DECL itself. Not a blocker (the decl already can't declareread, so a clause naming it is dead code either way) — a one-line ADR footnote would save a future contributor's confusion.
Files touched (frontend-only)
Bang/Frontend/Surface.lean—Surf.handleCustomS(final 6-field shape),HClauses,pHandlerName/pHClause/pHClauses, thehandle e with …parser arm,withreservation,hClausesToList,eraseLettMultiHClauses, the reallowerC/lowerHClausesarms.Bang/Frontend/TypeCheck.lean—synthSC's typing arm,checkHClauses(D4 ret-shape check),elabS's arm (the label resolve+rewrite),elabHClauses, the exhaustive-match completions, theelabBindANDzonkInferC/anfSpliteffects-threading fixes (WALL 3, both occurrences).Bang/Frontend/Format.lean— the printer arm (clause-list rendering stays placeholder).
No Bang/Core, Bang/Backend, or Bang/Meta file touched. Kernel census unchanged at 26.