ADR-0092 · #44 Stage 3: typing user-defined effects — program-derived EffSig + the typed custom-handle rule
- Status: Accepted
- Summary: Stage 2 landed custom dispatch census-clean (
6413281), but custom is still UNTYPED: noHasCTyrule mentions it, and the typed trusted-three stay clean only via vacuity (HasStack.concat_custom_absurd— a custom frame cannot sit on a typed stack). Stage 3 makes user effects TYPEABLE. The grounding fact that shapes everything: the kernel metatheory is already parametric in[EffSig Eff Mult]and the performer side (perform/upviaEffSig.opArg/opRes) is already fully general (ADR-0085's own finding) — so op typing needs NO new kernel mechanism. Decision: (D1) usereffectdeclarations allocate labels deterministically in the elaborator (ℓ ≥ 4, decl order — the four built-ins keep 0–3; the kernel is label-agnostic,Label = Nat, zero kernel change); (D2) the elaborator CONSTRUCTS the concreteEffSiginstance from the program'seffectdecls (finite-mapopArg/opResover the declared ops) — the typed judgment runs at the program-derived instance, so the parametric soundness theorems apply to user effects BY INSTANTIATION, not by new proof; (D3) one new typed rule,handle-custom, mirroring the three built-in handle rules: clauses typed pointwise over the finite list (body : opRes ! φunderparam+argbindings, one-shot tail-resumptive), the B-occ anti-escape premise (¬ LabelOccurs ℓ A) carried verbatim; v1 types the READ-ONLY-param form only; (D4) the vacuous custom arms inpreservation/progressbecome REAL additive arms (the ADR-0085 additive-ripple pattern; frozen statements untouched), probe-first per the ADR-0087 rung discipline — the preservation-of-dispatch slice is the bet. Deferred, named: (D5) the param-UPDATE protocol (put-like ops mutating the carried param — ADR-0087 §Open-questions; semantics not yet landed either, deferred with ADR-0085's Stage-4/first-class-knote; read-like user effects (Net/read, ADR-0084's motivating case) do not need it). Rejected: a universal openEffSiginstance keyed by runtime maps (loses by-construction totality of op signatures; the program-derived instance is total over declared ops by construction), and typing custom via a NEW judgment separate fromHasCTy(two judgments for one problem — the coexist seam already quarantines risk at the constructor, not the judgment). - Depends-on: 0085, 0087, 0022 (the general
up/EffSig performer rule), 0046 (deterministic elaboration), 0054/0055 (identity dispatch — unchanged) - Relates-to: #44 (Stage 3 of the arc), Q39 (effects-as-typed-interfaces — this ADR is its typing half), #56 (single-ρ row-poly — user labels ride the same set machinery and INHERIT the mixing limit; noted, not solved here), Q22/Q27 (multi-shot — out of scope, one-shot v1), ADR-0084 (Net instance — the first consumer)
Status
Accepted (2026-07-09, operator ruling same day — drafted and ruled while the s4/diag59/ml90 lanes ran).
D1/D2 EXECUTED (landed on main 844931f+88e0f55, gated clean-clone): effect decls parse
(Pratt sibling of data/trait), labels allocate ℓ:=4+effectIndex deterministically, the
program-derived EffSig types performs at user labels (the performer-side generality confirmed
live), user labels render in rows (showRow extended — they were invisible), and TWO
implementation findings are recorded: (i) label-first dispatch in .dotPerform typing (the
elaborator-side twin of rung-2's machine op-priority fork — a user op sharing a builtin name
would have silently mis-typed via capOpSig); (ii) v1 RESTRICTION: builtin op names are
RESERVED in effect declarations (loud error, capOpSig as the single source of truth) —
closing the elaborator/machine collision class BY CONSTRUCTION; the real fix (namespacing ops
by label end-to-end) defers to the Q34/Q38 module-interface work. D3/D4 (kernel lane) landing
will add the two probe-caught D3 refinements (F-returner clause typing; the (B) ret-shape
clause ruling from the answer-GRADE polymorphism wall).
Implementation sequencing: D1/D2 are elaborator work
(TypeCheck.lean — queue behind the #50 lane); D3/D4 are kernel typing + soundness work
(Bang/Core/Typing.lean + Soundness.lean — a proof-engineer unit, probe-first). Stage 4
(the s4 lane, in flight) is INDEPENDENT — the machine correspondence is untyped; neither unit
blocks the other.
- Layer: K (typing judgment + soundness arms) + F (label allocation, EffSig construction).
Frozen
Spec.leanstatements untouched — D4 is additive arms under constructor-agnostic statements, the same shape Stage 1 proved landable.
Context
What exists: perform/up types at ANY label/op through EffSig.opArg/opRes (ADR-0022 —
"EffSig already IS the user-effect interface"); rows are label sets over Label = Nat (user
labels compose by the same join, no row-machinery change); the three built-in handle rules
carry the B-occ premise (¬ LabelOccurs ℓ A, Typing.lean:50-55) — the anti-escape device
that keeps a capability from outliving its handler inside the answer type; custom frames are
UNTYPEABLE today (concat_custom_absurd, Soundness.lean:1958), which is exactly what D3
retires and D4 pays for.
What Stage 3 buys: a user writes (Stage-7 surface, sketched in ADR-0085 D4)
effect Net { read : Int -> Int } / handle e with Net { read(x) => … } and the program
TYPE-CHECKS with Net's label in the row, the handler discharging it, and the whole thing
riding preservation/progress — the moat's "paradigm is which effects are in your row"
made real for effects the language authors never named.
Decision detail
- D1 — label allocation.
effectdecls getℓ := 4 + declIndex(deterministic, ADR-0046; duplicate effect names = LOUD error). Built-ins keep exn=0/state=1/stm=2/Div=3. Kernel never learns names; the elaborator owns the name↔label map (same pattern asdatactor tags). - D2 — program-derived EffSig. The elaborator builds
opArg/opResas total functions: finite lookup over declared(ℓ, op)pairs, defaulting to the existing built-in signatures below 4. Totality by construction (every declared op has a declared signature; undeclared ops at a user label are an ELABORATION error, never a kernel stuck). The metatheory's[EffSig]parametricity meanspreservation/progress/type_safetyhold at this instance with NO new op-side proof. - D3 — the typed custom-handle rule — AS LANDED (two probe-caught refinements, 2026-07-09;
evidence = three committed probes: scratch/CustomResumeProbe.lean, CustomRetGradeProbe.lean,
CustomGradeForkProbe.lean): (i) the clause obligation is an F-RETURNER — the captured
continuation expects the original perform's returner type; (ii) 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) forces v1 clause bodies to the RETURN shape
ret w(HasVTypremise) — mirroring the built-ins' identity return-clauses and recoveringret's grade-freedom. Row consequence:ret wis effect-free ⟹ NO⊔ φ'term; the conclusion is the ℓ-discharged residual. The flagship compute-then-return clause stays untyped-fragment-only in v1 (it doesn't type anyway — no binop HasCTy rule, ADR-0065). Compound entry gate for the general-body form: binop typing (ADR-0065) + grade surfacing (Q27). The original sketch below stands as the design intent this refined FROM. - D3 — the original sketch. Shape (mirroring
handleThrows/handleState): givenM : A ! φunder the bound cap, paramp : P, and for each clause(op, body):body : opRes ℓ op ! φ'underparam@1 : P, arg@0 : opArg ℓ op(the landed binder discipline), withφ' ⊆the handle's residual row; conclusionhandle … : A ! (φ \ ℓ) ⊔ φ'matching the built-ins' row algebra; premise¬ LabelOccurs ℓ Averbatim. One-shot tail-resumptive only (the landed semantics); read-only param (D5 defers update). - D4 — soundness arms.
concat_custom_absurd's two call sites become real cases: typed custom frames CAN now sit on stacks, so preservation-of-dispatch (the resume step: clause body's type meets the continuation's expectation) and progress (a typed custom handle never sticks —dispatchOn_isSomefrom rung-2 is the semantic half) get additive arms. Probe rung: the preservation-of-dispatch slice in isolation FIRST; census-gate before the full transplant (the exact ADR-0087 D4 discipline).
Revisit if
- D4's probe finds the one-shot clause typing needs answer-type polymorphism the mono
elaborator can't express → surface the obligation; candidate fallback is restricting v1
clause bodies to
φ' = ⊥(pure clauses), named here so it's a shrink not a scramble. - Param-update (D5) lands semantically → extend D3 with the pair-return protocol (ADR-0087 §Open-questions' candidate) as its own slice.
- #56 gets subeffecting → the D3 row algebra inherits it mechanically (same
⊆site).
Evidence
Bang/Core/IR.lean:360-394 (EffSig parametricity + built-in labels), Typing.lean:50-55,204+
(B-occ + the built-in handle rules), Soundness.lean:1958,2252,2596 (the vacuity this
retires), ADR-0085 §Summary (performer-side-already-general finding + D4 surface sketch),
ADR-0087 §Status (rung-2 verdict + the open param-update question), 6413281 (the landed
Stage-2 semantics D3 types).