0106 — trait-op name-call dispatch (#78 operator ruling)
- Status: Accepted
- Summary: #78 (trait ops were operator-dispatch-ONLY —
env.instsconsulted exclusively at the.binopSarm, a bare name-call likeshow(x)wasunbound variable) is ruled: option (a), extend dispatch to name-calls, user-binding-wins (LEXICAL shadowing). An application whose head names a trait op consultsenv.inststhe same way.binopSalready does (the#118one-candidate-pins-the-hole precedent, reused verbatim);cresolves through the ORDINARY environment (Γ) FIRST, so a user's ownlet/let rec/lambda-param binding of the same name wins unconditionally — no implicit capture (ADR-0005/0006's own rule, generalized to trait-op names). Unlocksderiving (Show)for its ONE fully-working shape (an all-nullary carrier) and every future non-operator trait op (Hash, ADR-0082's Functor/Monad auto-derive). A SEPARATE, PRE-EXISTING#112-vintage knot-scoping wall (found, not fixed, this session) still blocksShow/any hand-written impl whose op body calls ANOTHER top-level binding — see §5. - Depends-on: #74 (the diagnosis that exposed the fork), #112 (the knot-dispatch mechanism this generalizes), #118 (the one-candidate-pins-the-hole precedent), ADR-0005/0006 (no implicit capture — the lexical-shadowing rule this ruling extends)
- Date: 2026-07-12
- Deciders: operator (Frontend lane, #78 ruling on the issue)
- Ties: docs/notes/traits-prelude-survey.md (the census that flagged Show/Hash as "GATED #78"),
#128 (the type-qualified-ctor-name derive lesson
showFoldBodyreuses)
Context
#78 found trait ops dispatch EXCLUSIVELY through the .binopS overload arm — env.insts (the
elaborator's instance table) was never consulted anywhere else. A trait op with a genuine NAME
(not an operator symbol — show, hash, a future Functor.fmap) had no execution path at all:
calling it by name (show(x)) was a bare unbound variable, confirmed live and reported by the
stdlib-tier-2 lane, which stopped deriving (Show) honestly on this exact wall rather than ship a
derive that can't be called.
#78's own issue text named the fork precisely: (A) keep operator-only (document it); (B) extend dispatch to name-calls (a convention decision: tupled vs curried args, lexical-shadowing vs trait-wins); (C) park until Stage-7/Q38 forces the same convention question for effect ops. Park was the 2026-07-11 default; this ADR records the follow-up ruling once Stage-7 landed without actually resolving #78 (a gap the carrier-inference lane's own report surfaced).
The decision
Option (a): extend dispatch, user-binding-wins. elabS's .app (.var c) a arm — the SAME arm
that already resolves ctor-intro (Cons(h,t)) — gains a THIRD case, after ctor resolution fails:
- Lexical shadow check first.
Γ.any (fun (n,_) => n == c)— ifcis bound in the ordinary (local + folded-in-top-level-let) context, dispatch is skipped entirely;cresolves as an ordinary application, exactly as if trait dispatch never existed. This is the ADR-0005/0006 "no implicit capture" rule applied to a NEW name-resolution site — a user's ownshowItalways wins over a same-named trait op, matching every other scoping rule in the language. - Candidate collection.
env.insts.filter (opName == c && params.length == 1)— SINGLE-ARG only (the surface shape.app (.var c) areaches; a multi-arg name-call likeeq(a, b)is out of this arm's scope, already covered by.binopS's own==overload). - Type-directed resolution. The argument is A-normalized (#41) and its type synthesized;
candidates are filtered by
target == τ(mirroring.binopS'sdispatchInst τ). Zero candidates ⟹cnames no trait op at all, DEFER to the checker's ordinary "unbound variable" (never a guess). One candidate ⟹ dispatch. Two+ candidates targeting the SAMEτ⟹ refuse loud (a genuine duplicate-impl program, never silently pick one).
The #112 knot generalization this needed. .binopS only EVER dispatched exactly 2 params, so
the #112 self-/backward-reference fix (PendingOpKnot, wrapPendingKnots) only ever built an
arity-2 tupled knot. Name-call dispatch reaches arity 1 too (show(x)), and a 1-ary op recursing on
its own carrier's tail (show's recursive-field case, total's self-recursive fold) hits the
IDENTICAL splice-vs-knot wall #112 closed for arity 2 — reachable for the first time because
dispatch can now REACH that arity. PendingOpKnot/wrapPendingKnots are generalized from a
hardcoded p1/p2 pair to params : List String (any arity ≥1): a 1-ary knot binds its param
directly (no tupling, T -> RetTy); 2+-ary right-nests via prodOfTys/bindPayload's own N-ary
convention (#144's own generalization, reused verbatim). Arity 0 stays on the pre-#112 splice
path (no Self-typed param to knot-bind).
Witnessed (compiled corpus, Bang/Frontend/TypeCheck.lean Validation ⑨q/⑨r)
- A hand-written 1-ary
impl ShowT for Box { fn showIt(x) = … }, called bare (showIt(B(42))) →42. RED before this ADR (confirmed live pre-fix). - Lexical shadowing: a user's own
let rec showIt : Int -> Int = …wins; the SAME bare call now resolves to the user's binding, not the trait impl. - Self-recursion on a recursive carrier through the SAME name-call + generalized knot (
total, a 1-ary op recursing viatotal(t)onMyList's own tail) →6for a 3-element list. deriving (Show)on an all-nullary carrier (data Color = Red | Green | Blue) → works end to end (show(Red)renders"Color_Red", the #128 type-qualified-ctor-name convention).
§5 — the binder-nesting wall (CLOSED, issue #139)
deriving (Show) on ANY carrier with a non-nullary ctor failed to ELABORATE AT ALL — not just at
the non-nullary call site. show's ONE generated knot covers every ctor arm in a single matchD;
a $concat/intToStr reference ANYWHERE in that fold (needed to render a field or join a ctor's
name with its payload) poisoned the WHOLE knot, even for a call that only ever hit an unrelated
nullary arm (confirmed live at the time: data Wrap = W(Wrap) | End deriving (Show), calling ONLY
show(End), still failed unbound variable 'concat').
Root cause (confirmed on a HAND-WRITTEN impl with zero derive/Show involvement):
impl Eq for Box { fn eq(a, b) = $eq "x" "x" } (calling the PRELUDE's own eq, nothing to do with
Show) failed unbound variable 'eq'. wrapPendingKnots's knot was letRecS-wrapped OUTSIDE the
program's own top-level let-chain (foldedBody, built by elabProg's foldLetDecls) — so a
knot's OWN body was elaborated in a scope that had NOT yet descended into foldedBody's nested
lets, meaning a knot's op body could NEVER see another top-level binding, prelude alias or user
function, regardless of arity. This was a #112-vintage gap (the ORIGINAL 2-arg knot mechanism had
always had it) that stayed invisible until this session: Eq/Ord's generated fold bodies only
ever call the binop itself (==/<), never a prelude function; Show's field-rendering was the
FIRST derive whose generated body genuinely needed to.
Fix (#139, landed this session). foldLetDecls now knot-wraps EVERY letD/letRecD's OWN
right-hand side (not just the trailing tail), index-scoped so a decl only sees knots whose
originating impl came textually BEFORE it (PendingOpKnot.declIdx, mirroring the forward-only
visibility ordinary sibling let/let rec decls already have). Two load-bearing companion fixes
were needed for the index-scoping to be SOUND, not just plausible: (1) buildEnv is now called on
prelude ++ p.decls (not the pre-filtered prelude ++ nonLetDecls) so PendingOpKnot.declIdx
indexes the SAME list foldLetDecls folds — the two functions previously read TWO DIFFERENT,
silently-misaligned index spaces; (2) expandDerives now splices each derived impl right AFTER
its OWN data decl (was: appended at the very end of p.decls regardless of where deriving(…)
textually sat), so a derived knot's declIdx reflects where the carrier is DECLARED, not where
the LAST deriving clause in the file happens to land — both PREPENDING wholesale (breaks
buildEnv's own "impls resolve after their target data" ordering) and APPENDING wholesale (put
the derived knot's declIdx after every ordinary top-level let, including ones that dispatch to
it) were tried and refuted live before the per-data-decl splice. Witnessed: the hand-written
Eq/abs repro above now runs to 42; data Pair = Mk(Int,Int) deriving (Eq) + a LATER let main = if Mk(1,2) == Mk(1,2) then 1 else 0 (a library-mode program — exactly the shape that
regressed under an earlier, less-careful cut of this fix) now runs to 1; the ADR's own worked
Wrap/show(End) repro above now runs to "Wrap_End", and the RECURSIVE arm
show(W(End)) self-recurses through the knot to "Wrap_W(Wrap_End)" (structOK correctly
certifies the self-call). A separate concat2-shape bug in showArmBody (calling curried
concat : Str -> Str -> Str as if it took ONE paired argument) was ALSO fixed in the same landing
— unrelated to the binder-nesting wall, but blocking the exact same corpus program.
§6 — the row-admission wall this fix does NOT close (STOP-and-SHOW, item 2's scope)
deriving (Show) on a carrier with an Int FIELD (e.g. data Box = B(Int) deriving (Show)) still
fails — now with thunk body performs {Div}, exceeding its declared bound {}, a DIFFERENT error at
a DIFFERENT layer than §5's unbound variable. Widening Prelude.bang's trait Show { fn show(a) -> Str … } to -> Str ! {Div} has ZERO effect on this (confirmed live) — a knot's admitted row is
governed ENTIRELY by letRecRow's structOK self-recursion certifier (ADR-0091), never by the
trait signature's own declared row. showIt's generated body calls intToStr/concat (both
themselves Div-rowed via THEIR OWN self-recursion) but structOK's check on #opknotN's OWN
body sees no SELF-recursive call for a leaf Int field, so letRecRow returns ∅ regardless of
what the trait declares — the row-admission machinery and the surface row-annotation are two
DISJOINT systems today. Fixing this needs either (a) structOK/letRecRow widened to admit Div
when the body calls an EXTERNALLY Div-rowed function (not just on self-recursion), or (b) the
trait's declared row genuinely threaded into the knot's admitted bound at buildLetRec — either is
a typing-rule-adjacent change to ADR-0091's own mechanism, STOP-and-SHOW territory, not attempted
here. The RECURSIVE-carrier case (§5's Wrap/show(W(End))) does NOT hit this wall — a genuine
self-call through show certifies fine under structOK as-is; only a call to an UNRELATED
Div-rowed prelude function (never itself the knot's own name) is blocked.
Rejected / staged
- Multi-arg name-call dispatch (
eq(a, b)-shaped, tupled args) — out of THIS arm's scope;.binopSalready covers the 2-arg case via==. A future name-callable multi-arg op (not yet named by any corpus need) would need its own surface-parsing decision (tupled call syntax at a bare name-call site is not what.app (.var c) aparses today). - Trait-wins shadowing (a trait op always dispatches, a same-named user binding is shadowed) — REJECTED: violates ADR-0005/0006's "no implicit capture" invariant, which this ruling extends rather than carves an exception into.
- Deferring
#78further (option C, re-park) — REJECTED: Stage-7 landed without forcing the same convention question #78 needed, so parking further had no natural resolution point left.
Consequences
Bang/Frontend/DiagCodes.leangains no NEW code for the dispatch extension itself (it reuses.binopS's existing multi-candidate refusal SHAPE, inline, not registry-tracked) — a follow-up if agent-facingbang explaincoverage is wanted for the ambiguous-name-call message.PendingOpKnot'sp1/p2fields are GONE (replaced byparams : List String) — an internal, non-public-API shape change; no external caller depended on the old field names.- Every existing
Eq/Ordderive + hand-written 2-param impl is UNCHANGED (the arity-2 knot path is byte-identical in behavior, only its internal representation generalized) — confirmed via the full example battery + existing corpus#guards, zero regressions.
Revisit if
The §5 wall is taken up (a wrapPendingKnots/elabProg binder-nesting redesign — kernel-engineer
or operator territory); OR multi-arg name-call dispatch is genuinely needed (a real program wants
eq(a, b) bare, not just a == b); OR #78's Hash/Functor-derive follow-ons surface a convention
question this ruling didn't anticipate.