ADR-0085 · User-defined effects: a general custom handler ALONGSIDE the built-in triple (coexist, one-shot v1)
-
Status: Proposed
-
Summary: #44 (user-defined effects — the moat: "paradigm and runtime are values") is blocked at the KERNEL, not the surface — the
Handlertype is a CLOSED triple (state/throws/transaction,Bang/Core/IR.lean) with operations hardcoded inhandlesOp/dispatchOn. The performer side is ALREADY general (perform/uproutes throughEffSig.opArg/opRes— any label, any op;EffSigalready IS the user-effect interface), so #44 is a HANDLER-side generalization. Decision: COEXIST — add a fourthHandler.custom : Label → Val → (OpId → Option Comp)constructor ALONGSIDE the three built-ins, NOT collapse them into instances of it; scope v1 to ONE-SHOT tail-resumptive clauses (multi-shot deferred to Q22/Q27). Coexist QUARANTINES the soundness risk behind the constructor seam (the project's own stratification principle): the trusted-three (preservation/progress/type_safety) can stay axiom-clean because their frozen statements are CONSTRUCTOR-AGNOSTIC (no Spec.lean statement changes — the ripple is ~424 ADDITIVE proof cases, not a re-freeze), and any gap that won't close lives in the NEWcustomcases WITHOUT regressing the built-ins' clean census. Rejected: INSTANCES (Option A — onecustomctor, the three derived from it) — truer to minimality but routes the currently-CLEAN trusted-three THROUGH unproven general-handler soundness, risking a census regression; kept as a LATER census-preserving refactor once the general soundness + multi-shot grade (Q27) are settled. The single riskiest obligation is invariant #4 — the calculated-machine re-derivation (generalizeSStore/THeap→ a param store, DERIVE the customevalD/compile/execarm), entangled with the route-B metatheory TAIL (ADR-0052's core LANDED — see Amendment 2026-07-08; U5b-handler completeness + the binary-LR re-index + the coherence-layer generalization remain). -
Depends-on: 0022, 0023, 0025, 0052, 0054, 0055, 0063, 0070
-
Relates-to: 0084 (networking = a small INSTANCE of #44 — this is its kernel gate), 0030 (STM = the transaction built-in this generalizes), Q39 (effects-as-typed-interfaces thesis), Q22 (labelling-vs-closure cap-rep — the multi-shot fork this DEFERS), Q27 (resumption grades — the type cost that later admits multi-shot), #44 (the issue)
-
Status: Proposed — design-first pass (2026-07-07); staged implementation begun.
- Stage 1 DONE (
d84aeae) —Handler.customrep + the ADDITIVE ripple; census 25→26, trusted-three axiom-clean, frozen statements untouched (the additive-ripple bet CONFIRMED while custom is inert+untyped). - Stage 2 — SEMANTICS + typed-soundness proven-IN-ISOLATION, but the full census gate is BLOCKED (a real finding, gh44s2; WIP on branch
origin/gh44s2). The dispatch+one-shot-resume semantics work (kernel#guards: customread 5⤳clause5+100⤳continuation+1= 106; zero-shot abort via a coexistingthrows= 42), and the TYPED trusted-three stay VACUOUS-CLEAN (new lemmaHasStack.concat_custom_absurd— a custom frame can't sit on a typed stack). BUT making dispatch real regresses the UNTYPED route-A CalcVM metatheory that the CLEAN headlinesrun_evalD/sim/compile_correctcarry:CapLabelCoh/FreshCfgare stated over ANY config and must BOUND a config's caps viacapsH : Handler → List (Nat × Label)— but the coexist rep's clause map is an opaqueOpId → Option Comp, whose caps CANNOT be collected into a finiteList(the domain isn't enumerable). SocapsHcan't bound clause caps → asorrythere would taint the currently-clean CalcVM headlines. The fix = acustom-clauses-are-VcapFreewell-formedness invariant threaded throughCapLabelCoh+FreshCfg+the route-A/B AbstractMachine proofs (it IS preserved —substFromidentity on custom + a VcapFree seed ⟹capsC clause = []closes the residuals — plus.maptraversal forrenameH/substFrom/shiftFrom+ a no-custom-in-machine lemma). This is multi-file, gated, and ENTANGLED WITH THE ROUTE-B METATHEORY TAIL (ADR-0052 core landed — see Amendment 2026-07-08) — i.e. the "riskiest single obligation" this ADR named for Stage 4 surfaces ALREADY at Stage 2. CONSEQUENCE: the running-user-effect milestone (Stages 1–3) is GATED on that metatheory tail (≈ Stage 4), not a quick 1→2→3.read-side (read-only param) works;put-like mutation needs the Stage-4 denotational machine / first-classk. - Stage 2 RESOLUTION (2026-07-09, superseding the block above — kept as the honest finding it was): LANDED census-clean at
0c6ba99+6413281. ADR-0087 dissolved the capsH wall by REPRESENTATION (finiteList (OpId × Comp)clauses — cap-enumeration structural, NO VcapFree-clause invariant threaded; the fix sketched above superseded), and the residual rung-2 wall (NoResume on the cleanrun_evalDpath) closed via theNoCustomFrame Kscaffolding premise (named expiry: Stage 4). The census gate that blockedgh44s2PASSES with dispatch real. Full verdict: ADR-0087 §Status. The running-user-effect milestone is UNGATED; the arc proceeds 3(typing)→4(calc-machine: derive the custom arm, dropCustomFree+NoCustomFrame)→5(LR)→6(soundness)→7(surface). - Stage 4 note (ADR-0086 interaction, 2026-07-09): the ◊5 headline
compile_forward_simnow carries a TEMPORARYCustomFree cpremise (ADR-0086) because Stage-1'sevalD custom = noneinverts for the completeness direction (the hypothesis is a kernel run, notevalD = some). When Stage 4 derives the custom machine arm (evalDdefined on custom), that premise is DROPPED fromcompile_forward_simand the internalevalD_complete_gen— a premise-drop is a consumer-safe strengthening, no second re-freeze ritual (cite ADR-0086 when doing so). TheCustomFree/CFComp/CFStore/CFHeapmachinery (Bang/Core/Freshness.lean+Bang/Backend/U5bComplete.lean) is the scaffolding this drop retires.
- Stage 1 DONE (
-
Date: 2026-07-07
-
Layer: K (kernel — the
Handlerprimitive + its metatheory). Tag: K-ADR (semantic; frozen-statement-adjacent but NON-changing — see §Soundness). -
Builds on: ADR-0022 (the
up/performrule + EffSig op-interface — already general), ADR-0023 (the CK machine + deep dispatch), ADR-0025 (resumptive state: keep Kᵢ, reinstall the frame, the closed-focus grade discipline — the mechanism the one-shot custom clause GENERALIZES), ADR-0030/0031 (STM as a transactional handler + its calc-machine store — the second resumptive instance), ADR-0052 (route-B: the calc-machine identity-keyed re-derivation this entangles with), ADR-0054/0055 (identity dispatch + global-fresh — preserved unchanged), ADR-0063 (escapedCap— the custom escape terminal, unchanged), ADR-0070/0072 (named-cap surface — thehandle … withlowering starting point). -
Reference: Plotkin–Pretnar / Bauer–Pretnar (algebraic effects + handlers — op-clause maps + reified continuation); Hillerström–Lindley (deep handlers). Both in
references/papers/effects-handlers/.
Context
The northstar — "a language whose paradigm and runtime are VALUES, not language features"
(CLAUDE.md) — LITERALLY means user-defined effects: a user declaring their own effect and
writing a handler for it. #44 names this "the moat." ADR-0084 (networking) gated its genuine
{Net} effect on #44 and verified from code that #44 is a hard KERNEL prerequisite.
The gating fact (verified from Bang/Core/IR.lean + Bang/Core/Semantics/Dispatch.lean):
the kernel Handler is a CLOSED set of three (state ℓ v / throws ℓ / transaction ℓ Θ);
their operations are HARDCODED strings in handlesOp/dispatchOn (get/put, raise,
newTVar/readTVar/writeTVar). Named capabilities (ADR-0070/0072) let a user NAME an
instance of the three built-ins; they do NOT let a user declare a fourth effect kind.
What is ALREADY general (the half-done insight): the performer side. Comp.perform c op v
carries no hardcoded op or label (IR.lean:123); HasCTy.perform (Typing.lean:207) types any
op via the EffSig.opArg/opRes typeclass — which is precisely "the interface a program's
effect declarations present to the type system" (IR.lean:327). So a user can already NAME
and TYPE operations of a new label; what is missing is a HANDLER that can SERVICE them. #44 is
the handler-side generalization, not a from-scratch effect system.
The three built-ins, by resumption shape
throws ℓ ZERO-SHOT. discard the captured continuation Kᵢ, abort to Kₒ with the payload.
state ℓ s ONE-SHOT. keep Kᵢ, reinstall handleF (state ℓ s'), resume Kᵢ with a value;
threads one closed Val s.
transaction ℓ Θ ONE-SHOT. as state, threading a list-heap; abort discards the write-delta.
A general algebraic-effect handler is a label + a per-op clause map, each clause a computation over the operation's argument + a carried parameter, deciding resumption. The three built-ins are semantic instances (throws = an abort clause; state/txn = one-shot resume clauses carrying a Val/Store parameter).
Decision
D1 — COEXIST: add Handler.custom as a FOURTH constructor (not collapse to instances)
| custom : Label → Val → (OpId → Option Comp) → Handler
-- label carried-param per-op clause (a Comp binding param@1, arg@0)
The three built-ins stay as OPTIMIZED constructors, their dispatch/typing/soundness/calc-machine
arms BYTE-IDENTICAL. custom gets NEW dispatch (a clause-map arm in handlesOp/dispatchOn),
a NEW typing rule (handleCustom + a customF HasStack frame), a NEW calc-machine arm
(DERIVED — D3), and NEW soundness cases. This is the project's stratification principle
(ADR-0026/0028): the verified core (the three, axiom-clean) + a being-verified superset
(custom) separated by an explicit, type-visible seam (the constructor).
D2 — v1 is ONE-SHOT (the load-bearing scope pin)
A genuinely general handler with an explicit, multiply-invocable resume requires FIRST-CLASS
continuation reification — the labelling→closure cap-rep change (Q22) the kernel has deliberately
AVOIDED (it is pure labelling: vcap + splitAtId + gensym, no closure value). So v1 restricts
custom clauses to one-shot tail-resumptive (a clause returns a value that resumes Kᵢ — the
ADR-0025 state mechanism, with USER logic where get/put's hardcode was) OR zero-shot abort
(the throws mechanism). This covers {Net} read/write, logging, reader, writer — most effects.
Multi-shot / 0-or-many / first-class k (nondeterminism, backtracking, schedulers) needs
closure cap-rep + resumption grades — DEFERRED to Q22/Q27. The resumption grade (#17/Q27) is the
type cost that later ADMITS multi-shot; v1 fixes it at one.
D3 — invariant #4: the calc-machine arm is DERIVED, not hand-patched
The machine (Bang/Backend/AbstractMachine.lean) threads two specialized resumption stores
(SStore for state, THeap for transaction). For custom, generalize these to a SINGLE param
store (identity → carried Val) — generality HELPS here: one store subsumes two, and state/txn
become param-carrying instances (the natural place for the eventual D5 collapse). The custom
evalD clause RUNS the op's clause recursively and threads the updated param; the machine
instruction must FALL OUT of the evalD RHS (the compile_correct exemplar discipline,
AbstractMachine.lean:296 "the machine — derived, not designed"). compile_correct
(exec∘compile ≡ evalD) + evalD_agrees_source are re-proven for the custom arm. This is
sequenced WITH the route-B metatheory TAIL (ADR-0052's core is LANDED: main's machine is
identity-keyed and run_evalD/sim/compile_correct/evalD_agrees_source are axiom-clean;
what remains is U5b-handler completeness + the binary-LR re-index — see Amendment 2026-07-08) —
#44's arm must be derived against that identity-keyed machine, generally.
D4 — the surface (frontend leaf)
effect Net { read : Int -> Str; write : Str -> Unit } → generates an EffSig instance (fresh
Label, opArg/opRes per op) + reserves the op names (elaboration-only, zero runtime fuel).
handle e with Net { read(x) => …, write(x) => resume(…) } → lowers to
handle (Handler.custom netLabel initParam clauses) body, building on the ADR-0070/0072 named-cap
machinery (as h binder + h.op perform + identity dispatch already exist). Census byte-identical.
D5 — Option-A (instances) is a LATER refactor, not v1
Once (a) the general-handler soundness is proven axiom-clean AND (b) the multi-shot grade
discipline (Q27) is settled, the three built-ins MAY be collapsed into custom instances — a
census-preserving simplification toward the minimal end-state (one handler form). Coexist-first is
the correctness-preserving path there; the collapse is not taken on taste while the general
soundness is unproven.
Soundness — the frozen statements do NOT change; the census risk is quarantined
No frozen Spec.lean statement changes. preservation/progress/type_safety/
no_accidental_handling/subst_value are all CONSTRUCTOR-AGNOSTIC — Handler is
existentially/universally quantified inside them, never destructured in the STATEMENT. So #44
needs NO frozen-statement change and NO re-freeze clause. The entire ripple (~424 sites, dominated
by AbstractMachine 218 / Soundness 91 / BinaryLR 80) is ADDITIVE proof cases — every
cases h gains one custom arm; the three existing arms are byte-identical (the coexist payoff).
Does #44 threaten the trusted-three's clean census? — quarantinable; one-shot v1 stays clean.
The one-shot custom soundness cases MIRROR the proven-clean state/transaction cases (the user
clause substitutes into the slot the hardcoded get/put logic occupied; the carried-param grade
discipline is the ADR-0025 D2 closed-focus argument reused). Residual risks, honestly named:
(a) MED — the custom clause can ITSELF perform effects (deep-handler recursion at the residual row
φ), which the effect-free hardcoded built-in clauses never did; preservation must track that
residual-effect threading; likely resolved by a clause-effect-row premise (a rule REFINEMENT, not
a frozen-statement change). (b) GAP — multi-shot resumption is a genuine gap needing a grade
restriction or a sorryAx; scoped OUT of v1 (D2). The coexist payoff is decisive: if a
custom case cannot close cleanly it carries a DOCUMENTED sorryAx in the custom arm WITHOUT
regressing the three built-ins' clean census — Option A could not offer that isolation. The
LR/calc-machine custom cases ride the EXISTING (already-sorried) state/txn LR frontier
(Spec.lean:212/243) and are gated no stricter than the built-ins already achieve.
Refute-first discipline (per team practice): if the one-shot custom soundness resists during
implementation, produce a machine-checked False-from-handleCustom witness before reshaping the
rule — do not weaken it on a say-so; distinguish genuinely-FALSE (needs a premise) from merely-HARD.
Considered options
- B — COEXIST (CHOSEN). Fourth
customctor alongside the three; one-shot v1. Additive ripple, quarantined census risk, built-ins stay clean. - A — INSTANCES (REJECTED for v1, kept as D5). One
customctor; state/throws/transaction derived from it. Truer to minimality (invariant "minimality over generality") BUT routes the currently-axiom-clean trusted-three THROUGH the unproven general-handler soundness — risks regressing the working verified core for elegance. Correctness steers: isolate the risk first (B), collapse later (D5) once the general soundness is proven. - C — a bespoke per-effect kernel constructor (e.g.
| net). Already REJECTED by ADR-0084 — spends #44's full ~424-site ripple to buy ONE hardcoded effect, violating invariant #5's "generalize the handler, don't special-case one more effect." #44 IS the general form C was rejected in favor of.
Invariant compliance
- #2 (rows-as-sets): a user effect = a new
Finsetatom label; union/join unchanged. - #4 (machine = output of calculation): HONORED BY OBLIGATION — D3 DERIVES the custom arm; the risk is doing it right (entangled with route-B), not a violation.
- #5 (five primitives): generalizes the EXISTING handler primitive — a fourth CONSTRUCTOR of
one of the five, NOT a sixth primitive. (ADR-0084 rejected a bespoke
| netctor on exactly this ground;customis the general form.) - #1 (proof rides the reference): stages gate against the oracle at each step — a
#guarduser-effect run (stage 2), theAgreediff-test +compile_correct(stage 4),just axioms(stage 6). No execution path ships without an oracle behind it.
Staged plan (each a gated unit; full detail in SCOPE.md §7)
1 REP +Handler.custom + total-fn arms LOW build + census unchanged
2 DISPATCH/EVAL one-shot custom dispatch MED #guard: a Net demo RUNS on Source.eval
3 TYPING handleCustom + customF + clause typing MED build + a typed user-effect example
4 CALC-MACHINE generalize stores; DERIVE custom arm ★ HIGH compile_correct + Agree; WITH route-B
5 LR custom compat / Krel case HIGH no worse than built-ins' sorry frontier
6 SOUNDNESS preservation/progress/type_safety + MED- just axioms — trusted-three census
no_accidental_handling custom cases HIGH
7 SURFACE effect decl + handle-with + EffSig-gen LOW end-to-end `bang eval`
Stages 1-3 unblock a RUNNING (if unproven) user-effect demo — the moat made visible — as an early
tracer bullet before the proof grind. Stage 4 sequences with route-B (ADR-0052). Stages 5-6 pair
with proof-engineer.
Stage-6 STATUS (2026-07-10, closing-slice landing): MET — and the MED-HIGH risk estimate is
superseded. The four named theorems (preservation/progress/type_safety/
no_accidental_handling) were discharged INCREMENTALLY: each of Stages 2–5 folded its custom
arms into the general theorems as it landed (the fold-in-as-you-land discipline), so the
just axioms gate this row assigns passed on main before Stage 6 was ever scheduled — and
non-vacuously (the Stage-3 typed rules make custom frames genuinely type; the resume chain is
discharged). The residual-effect-threading this ADR feared was dissolved by the ret-shape
(ADR-0092 §D3/D4). The closing slice (probe docs/notes/stage6-soundness-design.md + landing)
added the two INSTANTIATION lemmas that make the composition usable at a custom handler —
custom_handlesWithin, no_accidental_handling_custom [propext] — and the e2e HEADLINE
custom_program_safe (a fully-handled well-typed program never sticks, at user labels) ⊆
trusted-three. Census 16→18 clean / 7 flagged. effect_sound/Q14 confirmed OUT of #44
(orthogonal, predates the arc). The arc's remaining stage is 7 (surface).
Overall size: L / weeks, spine-touching (confirms ADR-0084) — the largest post-MVP direction. The no-frozen-statement-change + additive-ripple findings make it more tractable than feared (not a re-freeze), but stage 4 (+route-B entanglement) and the LR frontier keep it multi-session.
Riskiest single obligation: stage 4 — the invariant-#4 calc-machine re-derivation (genuine Bahr–Hutton calculation over garby §6's open handler-compiler frontier, entangled with pending route-B). Runner-up: multi-shot soundness IF v1 scope creeps past one-shot (mitigation: the D2 pin).
Revisit if
- The route-B metatheory tail closes (U5b-handler completeness + the binary-LR re-index +
the
VcapFree-clause coherence generalization) → stage 4 becomes concretely schedulable against the identity-keyed machine; finalize the custom arm's derivation then. - Q22 (closure cap-rep) / Q27 (resumption grades) resolve → v1's one-shot pin (D2) can lift to multi-shot, and D5 (the Option-A collapse) becomes a safe refactor.
- The general-handler soundness is proven axiom-clean AND multi-shot settles → collapse the three
built-ins into
custominstances (D5), moving to the minimal end-state.
Amendment (2026-07-08 — route-B status corrected; decision unchanged)
As authored (2026-07-07), D3 and the Summary called route-B (ADR-0052) "still pending" and claimed
"the machine already disagrees with the kernel on shadow programs." Both were stale at authoring
— carried over from ADR-0052's own text without re-checking main. Ground truth (code + gate, not
prose): the route-B re-derivation LANDED — Bang/Backend/AbstractMachine.lean documents evalD as
the big-step denotation of the IDENTITY kernel (dispatch by splitAtId-analog, mint+subst vcap at
handle), and the bridge headlines run_evalD / sim / compile_correct / evalD_agrees_source are
axiom-clean on main (Audit census @ f826dbc) — the shadow-program disagreement is resolved, not
open. What "pending" truthfully denotes is the route-B metatheory tail: (i) U5b-handler
completeness (compile_forward_sim's documented sorryAx — paths/archive/PATH-inc6-calcvm-route-b.md),
(ii) the binary-LR re-index (lr_sound cluster — paths/PATH-inc5-lr-reindex.md), (iii) THIS ADR's
Stage-2 finding: the coherence layer (CapLabelCoh/FreshCfg/capsH) generalized for custom via
the VcapFree-clause invariant. The staging, risk ranking, and D1–D5 are unchanged; only the
sequencing target is now precise. (Drift caught by the 2026-07-08 firsthand survey; the
check-context-claims.py fitness leg added the same day guards the CONTEXT-side recurrence.)
Evidence
Verified read of Bang/Core/IR.lean (closed Handler triple; already-general EffSig/perform),
Bang/Core/Typing.lean (constructor-agnostic frozen theorems; handleThrows/State/Transaction
- 3
HasStackframes; the already-generalperformrule),Bang/Core/Semantics/{Eval,Dispatch}.lean(hardcoded op dispatch; identity-keyedidDispatch/splitAtId; the state/throws/txn resume shapes),Bang/Backend/AbstractMachine.lean(SStore/THeap specialized stores; theevalD-derives-the-machine discipline),Bang/Spec.lean(the frozen statements — none destructureHandler), and the ripple grep (~424 constructor-match sites). ADR-0052's route-B pending status (inc-6 amendment). Design thread + full staging:SCOPE.md(same scratchpad dir).