Wado

WEP: Elaborator God-Object Dismantlement — Decl Signatures, Scope, and the Body Walker

Companion to wep-2026-05-26-elaborator-rearchitecture.md. That WEP's Phase 1 (data decomposition: TypeSystem / ModuleSemantics / Reify) and most of Phase 2 (query migration onto TypeSystem) are done. This WEP owns the rest: the design that removes the remaining God-Object couplings from Elaborator and fixes its end state. It supersedes the old WEP's "Remaining" list; the design here was produced from a fresh survey of the code (2026-07), not by extrapolating the old plan.

Context

Elaborator is down to 19 fields, each with a documented home. Reify, InferCtx, CtrlFlowCtx, TypeLookup, and the impl TypeSystem query clusters already stand alone, so the decomposition pattern is proven. What still makes Elaborator a God Object is no longer data. It is four couplings.

One receiver, three roles

About 47k lines under elaborator/ extend the same struct. Walker code (expr / stmt / operators / item / module / handlers / closure / assert / the resolve_call / resolve_method_call trunks — the resolve_* recursion that writes sem and emits diagnostics) and query code (method_lookup / trait_query residue plus the callee-signature lookups in call.rs / method_call.rs) share one &mut self. Nothing but review discipline stops a query from mutating walk state or a walker arm from open-coding a query.

Queries re-resolve foreign declaration ASTs on demand

The coupling this WEP set out to remove was roughly 40 loaded_modules reads outside reify, each fetching a declaration's AST to re-resolve its signature at the use site — see Progress metric for what remains:

Category Sites Consumed
Free-function signatures call.rs ×8, expr.rs ×2 type params, param types, return, is_mut
Impl headers + impl-method signatures method_lookup.rs, method_call.rs, call.rs, handlers.rs, expr.rs (~24 via get_impl_block and whole-module scans) impl ty / type params / assoc types / trait ref; method signatures
Trait-decl methods trait_query.rs ×3 signatures + has_body; bodies only for default-method synthesis
Effect ops / resource statics call.rs, method_call.rs ×3 op signatures, #[cm] attrs
Globals / data section / assoc-type bounds / type-decl collection / import scopes expr.rs, module.rs, type_resolution.rs, orchestration.rs, elaborator.rs decl types, bounds, module metadata
Param-default expressions call.rs, method_call.rs ×2 ast::Expr clones (irreducibly AST)

No site outside reify ever reads a method body except trait default-method synthesis. Signatures are the whole coupling.

This on-demand re-resolution is also the root of three secondary structures:

It is quadratic in places: resolve_module's preamble rescans every loaded module for associated constants per module (O(modules²), with ast::Expr clones), and the driver recomputes function_return_types / imported_functions per module from ASTs.

Hand-rolled scope save/restore

The TypeParamScope RAII guard exists (~35 sites), but three clusters still save/restore by hand: the Item::Impl arm of resolve_module (manual trait_ctx clone with three restore exits), the self_type triple in method_lookup.rs (manual save around with_module_perspective_for closures), and the current_effect_params / current_effect_param_decls mem::take pairs. Each is a panic-unsafe restore path the guard was built to eliminate.

Side channels and mode flags as struct fields

pending_method_dispatch and pending_operator_ast_id carry what should be return values / parameters. capture_tuple_overlays is constructor-constant true (dead flag). suppress_reference_recording is the query-suppression gate above. Each is per-call-frame data living at struct scope.

Decision

Elaborate's end state is four components. The boundary between them is enforced by types, not review:

TypeSystem (+ Signatures)  — pipeline-wide queries; no AST, no sem writes, no logging
ModuleSemantics            — per-module facts (unchanged)
Annotator (today: Elaborator) — the per-module walker: AST in, facts out
Reify                      — facts in, TIR out (unchanged)

The one new load-bearing piece is Signatures.

Signatures — every declaration signature becomes a decl-pass fact

Rule: after annotate_decls, no phase re-resolves a declaration signature from AST. Each signature is resolved exactly once, by the decl pass, in its own declaration frame, and stored as TypeId-level facts:

The canonical frame: a signature is resolved under its declaring module's import scope, with Self bound to the impl target, impl and method type params as TypeParam slots, and associated types as the impl's own bindings. Use sites substitute into that frame — the same substitution MethodInfo consumers already perform — and never re-resolve. A signature whose meaning would depend on the use site cannot exist under this rule; if migration finds one, that is a design bug to surface fail-loud, not a licence to re-resolve.

AST inside the digest is allowed only where the value is irreducibly AST and the consumer is the walker or reify, never a query: param-default exprs (resolved per call site under the callee's scope, per WEP 2026-04-11), associated-const value exprs (already digested this way on ModuleDecls), trait default-method bodies.

What this deletes, structurally:

Placement: Signatures is one struct (elaborator/sig.rs, next to the DeclSig / MethodSig shapes it stores), a field on TypeSystem (Rc, assembled once from the per-module ModuleDecls digests between the decl and body passes), keyed by the declaring node's globally-unique AstId with name-keyed indices layered on top.

Membership rule: one entry per source declaration, holding what that declaration says — its signature, or the declaration-level datum it is. Nothing computed from a use site, and nothing a later phase recomputes. AST survives inside an entry only where the value is irreducibly AST and the consumer is the walker or reify, never a query.

Signatures deliberately does not extend TraitEnv. The two are built in different phases over different alphabets: TraitEnv::build runs before any decl pass and indexes names ("which impls exist, on what receiver, for what trait"), then freezes behind Arc; signatures are TypeId-level and can only exist after the decl pass has interned types. Hanging signatures off ImplHeader would make TraitEnv a two-phase build-then-backfill structure and cost it the immutability its consumers rely on. Two maps under the same (ModuleSource, AstId) key compose just as well at the use site.

One canonical frame implies one way to leave it

A signature's canonical frame is only enforceable if there is exactly one operation that instantiates it. The TypeId-level primitive already exists and is canonical (TypeTable::substitute_type_params, keyed by slot index), but no type pairs a signature with the slots it is abstract over, so each consumer open-codes "clone the param types, substitute each, substitute the return". Migrating consumers onto the digest without first naming that operation would mint one more copy of it per converted site.

So a signature is a [DeclSig]: the slots plus the parameter and return types resolved against them. DeclSig::instantiate fills the slots positionally and is how a use site reads one; inference, which solves for the arguments, is the one consumer that reads the canonical types directly. MethodInfo stops being independently computed and becomes exactly instantiate(impl_method_sig, receiver_args).

The two genuinely AST-level helpers are method_lookup::resolve_type_with_param_mapping and the trait_query::build_type_param_mapping that exists only to feed it. Their count is the sharper completion metric: loaded_modules measures what was unplugged, AST-level substitution measures what was actually lowered.

Of their nine call sites only one resolved a method's parameter type; the other eight resolved an impl block's associated-type bindings (type Item = …) and the type arguments of its trait reference. Those are declaration facts too, so they became the impl's own digest entry ([ImplSig], S5c) rather than method signatures, and both helpers are gone.

A frame is abstract over its projections too, and only a use site can fill them

Slots are not the whole of what a declaration frame leaves open. A signature written against Self::Item is abstract over what Self::Item means, and that cannot be a declaration fact: I: IntoIterator<Item = u8> is written at the caller. Filling the slots without it yields a projection the use site cannot resolve — which is exactly why the trait-bound path re-resolved its callee's AST under a doctored scope instead of instantiating.

So the substitution carries both: SlotProjections maps a slot to what the projections rooted at it stand for, and TypeTable::substitute_type_params_with is the one implementation, with the slot-only substitute_type_params as its empty case.

The use site answers for every associated type the trait declares, not only the ones its where clause names. Rebuilding the recorded projection over the substituted base recovers its owning trait and bounds but not its bindings, and those bindings are use-site data — I::Iter knowing Item = u8 comes from the caller. Leaving the rest to the rebuild produces a projection that differs from the one the same name resolves to when written in source, and two spellings of one type that do not intern together are a type error at the use site.

A projection's own assoc_type_bindings are types resolved in the same frame, so they carry its slots and are substituted with everything else — the rule every other arm follows.

What a bound's right-hand side denotes is deliberately not resolved to fill a gap. Self there names the bounded type, so answering would mean rebinding Self for the duration — but a frame's assoc_type_bindings shadow it, so an unrelated impl's type Item = … answers for a type parameter's, and recursion through the right-hand side has no fixpoint. An unanswered name stays abstract.

Name-keyed facts belong to TraitEnv, TypeId-level facts to Signatures

Both are declaration facts, and the phase that asks decides which structure can answer. TraitEnv::build runs before any decl pass; Signatures is assembled after all of them. So a fact the decl pass needs about itself — which trait declares Self::X, asked while resolving that trait's own method signatures — can only live on TraitEnv, alongside assoc_type_bound_index. Filing it in the digest type-checks and silently answers None.

One place per question

The digest only holds if each question it answers has a single implementation. Every convergence below was forced by a defect where two of them disagreed:

Scope — transient walk state with RAII-only mutation

One Scope struct (elaborator/scope.rs) absorbs annotate_ctx and default_scope_module. Effect parameters move into TraitContext itself: they are declared in a signature's type_params list, so they are generic-scope state and the TypeParamScope guard restores them with the rest of the context. All mutation goes through guards — TypeParamScope, with_self_type / with_self_type_if_known, with_default_scope_module (one shared field-restore guard behind the with_* helpers) — and every manual save/restore is deleted. Enforceable by inspection: no mem::replace / manual clone-restore of scope fields outside scope.rs.

TypeSystem — completed query surface, and the no-logging rule

The remaining queries move: the lookup_method_info cluster, trait-method-for-type, arithmetic / indexing / static-method lookups, and the callee-signature lookups in call.rs. Signature shape: fn query(&self, ctx: &Scope, scope: &TypeLookup, …) -> ….

Three rules define the boundary: TypeSystem never sees AST, never mutates ModuleSemantics, never logs. Queries return data — including reason chains (WEP 2026-06-02) — and the walker turns them into diagnostics. This is already the pattern for the migrated trait_query half; it becomes the rule for all of it.

Annotator — the walker Elaborator honestly is

End-state shape (6 fields, from 19):

pub struct Annotator<'a, H: CompilerHost> {
    env: ElabEnv<'a, H>,   // symbols, logger, interner, invocations, entry module
    tysys: TypeSystem,     // shared handle (+ Signatures)
    sem: ModuleSemantics,  // owned; driver swaps per module
    module: ModuleCx<'a>,  // current module source + items, set at entry
    scope: Scope,          // guard-managed transient state
    infer_holes: InferHoleTable,
}

Driver

annotate_decls   — types → TraitEnv + Signatures → per-module decl facts
annotate_bodies  — ×N, the walker
liveness         — unchanged
reify            — ×N, unchanged

AnnotateState dissolves (its own doc predicts this): tysys and module_semantics land on Semantics, the rest are driver locals. The per-module construction site collapses from 19 fields (two of them placeholders) to Annotator::new(&env, tysys.clone(), sem).

Rejected alternative

Passing a narrow "resolution context" (type_table + reference sink + logger

Implementation

Slices land independently, each keeping mise run test, the WIR goldens, and the LSP query tests green. Converted consumers read the digest via .expect(…) — a missing entry is a loud panic, never a fallback to AST re-resolution (the reify Stage-7 precedent). The impl-method digest needs no separate completeness test: the body walk visits every impl block in every module and .expects the entry, so the suite already fails deterministically at the declaration rather than at whichever use site reaches it first.

How the trait-bound path reads the digest

find_method_in_trait_bounds was the last signature re-resolution outside reify. It instantiates the recorded MethodSig instead, in three parts:

The query stops writing walk state entirely: no scope to enter, no self_type to set, no assoc_type_bindings to seed and restore.

Ordering: S7 converts one query at a time rather than as a single cut. S8–S9 are last and depend on neither.

Progress metric:

Metric Now Target
loaded_modules reads outside reify / decl pass 3 0
Whole-module AST scans 0 0
Name-keyed AST predicates 0 0
AST-level type-param substitution helpers 0 0
with_module_perspective call sites 9 1
suppress_reference_recording call sites 3 0
Manual scope save/restore clusters 0 0
Elaborator fields 13 6

Every surviving loaded_modules read is an indexed fetch of one declaration, not a scan: all three are in method_call.rs, reached through impl_index / all_impl_index. S7 owns them along with the two scope-swapping counts; one perspective swap is the walker's own — typing an imported global in its declaring module, which is the callee-scope use the target of 1 reserves.

Consequences

Benefits

Trade-offs

Risks and mitigations

See Also