ADR 0119: Class-Name↔Module-Name Resolution Registry

Status

Accepted (2026-09-05)

Context

"Given a Beamtalk class name, what compiled Erlang module does it live in?" has no single source of truth in the compiler. It is independently re-derived — or guessed — in at least seven places, each added ad hoc as a new codegen need arose:

  1. class_module_index (crates/beamtalk-cli/src/commands/build.rs:2149 build_class_module_index) — a HashMap<String, String> built from parsed ASTs during package-mode compilation's "Pass 1". The closest thing to a real source of truth today, but it is only consulted first, not exclusively, and only exists for package mode.
  2. is_known_stdlib_type (crates/beamtalk-codegen/src/core_erlang/value_type_codegen.rs:3638) over STDLIB_CLASS_NAMES (crates/beamtalk-codegen/build.rs:44) — a Rust-build-time constant generated by a raw recursive file-stem directory scan of stdlib/src/, not a parse. It includes protocol-only files that declare no class (Printable.bt), and it is the exact bug shape that broke BT-3432 (a pure rename silently changing resolution). Notably, a correct, already-parsed answer to "is this a known stdlib class" already exists elsewhere in the tree and is not consulted here: is_generated_builtin_class (beamtalk-core/src/semantic_analysis/class_hierarchy/generated_builtins.rs:19) is generated by the beamtalk build-stdlib CLI command from real parsed class names (build_stdlib.rs's meta.class_name, not file stems), has an Erlang twin (beamtalk_generated_builtins.hrl, BT-3085) generated from the same pass, and is kept fresh by just check-generated-builtins in CI. STDLIB_CLASS_NAMES is not that generation's output — it is an independent, cruder scan added later that duplicates a question the codebase already answers correctly.
  3. user_package_prefix (crates/beamtalk-codegen/src/core_erlang/util.rs:1278) — reverse-parses an already-computed module-name string to guess a package prefix. Its own doc comment admits it discards subdirectory segments (bt@sicp@scheme@evalbt@sicp@, dropping scheme@) — class_module_index exists largely to patch over this documented bug, not to replace it.
  4. module_matches_class (util.rs:1293) — the reverse direction (module → class), re-deriving all four candidate module-name shapes structurally. It never consults class_module_index, so it can disagree with the forward direction (compiled_module_name) for exactly the subdirectory case user_package_prefix gets wrong. Five call sites, all in gen_server self-dispatch/state routing (gen_server/state.rs:34,96; gen_server/callbacks.rs:102,851,1527).
  5. compiled_module_name_qualified (value_type_codegen.rs:3697, ADR 0070's explicit json@Parser syntax) — when a package qualifier is present it delegates to resolve_qualified_module_name (crates/beamtalk-core/src/ast/mod.rs:438), which composes bt@{pkg}@{snake} directly and, as its own doc comment states, bypasses class_module_index entirely. That is deliberate and documented, but it means an explicitly-qualified reference to a class in a package subdirectory resolves differently from the same class referenced unqualified — the index knows bt@sicp@scheme@env, the qualified path composes bt@sicp@scheme_env. The unified registry has to make an explicit choice here rather than inherit two answers. (resolve_qualified_module_name's None arm returning a bare unprefixed name is not a defect: it is documented and tested contract — the caller adds the context-appropriate prefix — and its only production caller passes Some.)
  6. module_name_from_path (crates/beamtalk-cli/src/commands/build_stdlib.rs:465) — a fourth independent closed-form derivation of the bt@stdlib@{snake} rule, alongside compiled_module_name's stdlib branch (value_type_codegen.rs:3680), PrimitiveBindingTable::runtime_module_for_class (primitive_bindings.rs:180), and beamtalk-compiler-port/src/main.rs:1504 — synced with the other three only by a prose comment, plus a fifth structural copy of the same shape inside module_matches_class (util.rs:1297).
  7. REPL / compiler-port duplicationbeamtalk-repl/src/codegen.rs and beamtalk-compiler-port/src/main.rs (derive_class_module_name) each assemble their own version of this map, because the construction logic (build_class_module_index) lives in beamtalk-cli, which neither crate may depend on (dependencies flow down only — see docs/development/architecture-principles.md §1).

The one genuinely shared primitive, to_module_name (beamtalk-core/src/ast/mod.rs:400, CamelCase → snake_case), is a building block, not a resolver — every one of the above re-composes it differently around prefixes, packages, and subdirectories.

Evidence this is not hypothetical — three real, shipped bugs in this exact pattern:

Constraints on the solution:

Decision

Introduce a single ClassModuleRegistry in beamtalk-core::semantic_analysis, next to ClassHierarchy, as the only answer to "what module is class X in." It wraps resolved names in a ModuleName newtype, extending ADR 0089's typed-leaf discipline to this leaf kind — with an honest scope for what that buys, laid out below.

// beamtalk-core/src/semantic_analysis/class_module_registry.rs

/// A validated, compiled module name — either a generated `bt@...` module or a
/// hand-written native backing module (ADR 0056), e.g. `beamtalk_future`.
/// Constructed by resolving a class through a `ClassModuleRegistry`, or
/// reconstructed at a trust boundary (the Pass-1 cache, the compiler-port
/// wire format) that is itself backed by a `ClassModuleRegistry` build.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ModuleName {
    Generated(String), // e.g. "bt@stdlib@ordered_collection"
    Native(String),    // e.g. "beamtalk_future"
}

/// Identifies which package a class was declared in, so two classes with the
/// same bare name (a user package's `Set` shadowing stdlib's) do not collide
/// in the registry. See Context: ADR 0026 currently forbids that case outright
/// (a compile error), but the key still carries package identity today so the
/// registry does not have to be re-keyed when that restriction is lifted.
pub enum PackageId {
    Stdlib,
    Package(String),
    SingleFile,
}

/// How this compilation unit assigns module names to source files (ADR 0016/0026).
pub enum ModuleNamingScheme {
    SingleFile,                 // bt@{stem}
    Package { name: String },   // bt@{package}@{relative@path}
    Stdlib,                     // bt@stdlib@{stem} — closed-form, validated not computed freely
}

pub struct ClassModuleRegistry {
    class_to_module: HashMap<(PackageId, String), ModuleName>,
    module_to_class: HashMap<ModuleName, (PackageId, String)>,
}

impl ClassModuleRegistry {
    /// Resolves a class declared in `pkg` to its compiled module, given the
    /// merge-precedence-ordered search (own package, then dependencies, then
    /// stdlib — see Consequences/Negative on shadowing).
    pub fn module_for_class(&self, pkg: &PackageId, class_name: &str) -> Option<&ModuleName> { .. }

    pub fn class_for_module(&self, module: &ModuleName) -> Option<(&PackageId, &str)> { .. }

    /// Assigns a fresh module name for a class the caller is defining right
    /// now with no `.bt` source file — a REPL `subclass:` sent interactively,
    /// or a hot-reloaded inline class definition arriving over the
    /// compiler-port. Not a lookup: this is how a class enters the registry
    /// in the first place outside batch compilation.
    pub fn assign(&mut self, pkg: &PackageId, class_name: &str, naming: &ModuleNamingScheme) -> ModuleName { .. }
}

Miss handling must preserve the open-world escape hatch, not tighten it. module_for_class returns Option, and a None is not automatically treated as a compiler bug: per the ADR 0100 constraint above, a class that today's check_unresolved_classes only warns about (rather than rejects) can legitimately reach codegen, and that must keep working. The registry does not change what compiles; it changes what a successful resolution is based on. Concretely:

ModuleName can represent a runtime-only builtin, but nothing seeds one today — and that's deliberate. Future is registered in ClassHierarchy (class_hierarchy/builtins.rs:84) with no stdlib/src/Future.bt source, and its real implementation is hand-written Erlang (runtime/apps/beamtalk_runtime/src/beamtalk_future.erl) in the beamtalk_* underscore namespace ADR 0016 reserves for native runtime code — not any generated bt@… module. That is exactly why ModuleName carries a Native variant rather than assuming every entry is generated. However, tracing every call site shows Future is not actually reachable through compiled_module_name today: compute_direct_call_eligible (core_erlang/mod.rs:3500-3529), the only place that iterates every hierarchy class unconditionally, excludes it at its class-methods-empty gate — Future's hardcoded ClassInfo always has class_methods: vec![] — and every other call site only resolves a class actually referenced, subclassed, or spawned by name, which nothing in stdlib does for Future. So it correctly falls through to the fallback-convention path today with zero observable effect, exactly as it does before this ADR. Seeding it now, before that path is live, would be speculative code for a scenario that can't currently happen. The registry therefore seeds nothing for Future; a comment at the compute_direct_call_eligible gate notes that if BT-507 ever gives a no-.bt-source builtin real class methods, its registry resolution needs revisiting then.

beamtalk-codegen's resolution collapses from a four-tier guess to one lookup plus the single documented fallback above — never a panic. CLAUDE.md forbids panicking on user input, and NoSuchClass new reaching codegen under the open-world policy is exactly that case.

Deleted outright, with no replacement (their job is now the registry's, or — for #2 — already done correctly elsewhere and merely uncalled): STDLIB_CLASS_NAMES and crates/beamtalk-codegen/build.rs's file-stem scan (delegate is_known_stdlib_type to the existing is_generated_builtin_class, widened to pub(crate) visibility — no new build-dependency required, since generated_builtins.rs is already generated ahead of time by the beamtalk build-stdlib CLI command, not a build.rs script), user_package_prefix, and the four independent bt@stdlib@ derivations, unified behind the registry's stdlib arm (which, per the runtime constraint above, still computes bt@stdlib@{snake} — it does not become a free lookup — but now validates that computation against the real parsed class name instead of trusting the file stem, closing the BT-3432 shape).

module_matches_class is replaced with something smaller than a full registry round-trip: all five of its call sites (gen_server/state.rs:34,96; gen_server/callbacks.rs:102,851,1527) ask the same question — "is this AST class the one I am generating right now for self.module_name?" — which, under ADR 0040's one-class-per-file rule, doesn't need module-name comparison at all. It becomes a direct check against the class the generator is already emitting (a self.current_class reference, populated once per module), not a registry lookup. This is deliberately not a drop-in replacement: the deleted function's last arm matched any package whose final @ segment agreed (so bt@other_pkg@util@math spuriously matched class Math) and its unprefixed arm exists only for hand-built test fixtures (util.rs:1288-1291) that never go through registry construction — both of those need updating at their call sites, not preserving.

resolve_qualified_module_name is kept — its None-arm contract is deliberate and tested — but compiled_module_name_qualified is routed through the registry so that pkg@Class and a bare Class resolve identically for a class in a package subdirectory, closing the divergence noted in Context item 5.

REPL/debugging illustration — no user-facing syntax changes; the observable effect is a class of dispatch bug becoming impossible rather than a new feature:

» "Before: a class in a package subdirectory couldn't be dispatched to by name"
» "bt@sicp_example@scheme@env" module for class SchemeEnv
  -- user_package_prefix stripped `scheme@`, silently trying bt@sicp_example@env

» "After: the registry recorded the real path-derived module at Pass 1"
SchemeEnv new.
an instance of SchemeEnv   "dispatches correctly regardless of nesting depth"

Misuse illustration — this ADR does not change what happens for a genuinely undeclared class; ADR 0100's open-world policy means that stays a warning, not an error, on purpose (the class may be defined at runtime). What changes is that the warning path and the resolved path can no longer be confused with each other:

» NoSuchClass new.
warning: Unresolved class `NoSuchClass`
  (unchanged by this ADRADR 0100: this is not proof of a bug, so
   compilation proceeds; the module name used if this reaches codegen is
   still a best-effort `bt@no_such_class` guess, exactly as today)

» "But: a REAL class in a package subdirectory, which used to silently
   resolve to the WRONG module (user_package_prefix's bug) now resolves
   correctly instead of failing at runtime with a cryptic undef:"
SchemeEnv new.
an instance of SchemeEnv

Prior Art

Three distinct strategies exist for this class of problem (full research in the issue's linked session; summarized here):

We adopt rustc's "resolve once" shape (a registry built during a dedicated phase, not a runtime dictionary, not a distributed heuristic), wearing Gleam's newtype pattern for the rendering half (ModuleName, not String) — but, unlike Gleam, apply it uniformly to every production site (codegen, stdlib build, CLI, REPL, compiler-port), not just the primary codegen path. Gleam's own module_erlang_name (compiler-core/src/build.rs) and two raw .replace("/", "@") calls in its filesystem/cache layer are a live example of exactly this ADR's failure mode, still unfixed upstream — a caution against declaring victory once the "main" call site is typed.

We explicitly reject full rustc-style opaque-ID interning (a ClassId threaded through the AST, replacing string class names everywhere) as disproportionate to this problem — see Alternatives Considered.

User Impact

Steelman Analysis

CohortOption A (registry in beamtalk-core)Option B (minimal consolidation in place)
🧑‍💻 NewcomerIndifferent either way — both are invisible below the surface language.Same.
🎩 Smalltalk purist"One authority you can query for 'what module is this class in' is exactly the Smalltalk globals shape, minus the runtime-mutability hazard — this is the compile-time analogue done right.""Still one authority in practice, just not moved — the purist cares that dispatch is correct, not which crate the map lives in."
⚙️ BEAM veteran"Centralizing this in beamtalk-core means REPL, hot-reload (compiler-port), and CLI all get identical resolution — no more risk of REPL and beamtalk build disagreeing about a class's module.""Smaller diff, less risk of a botched crate-boundary move breaking hot-reload during the transition."
🏭 Operator"Fixes the root cause once, permanently, ahead of the next three variants of this bug — worth the one-time XL migration cost.""Ships the acceptance criteria faster with less blast radius — an XL architectural move is itself a risk to a stable release cadence."
🎨 Language designer"A typed ModuleName gives every in-process caller one function to trust instead of five to independently get right — and cheap wins like delegating to is_generated_builtin_class fall out of doing this properly at all, for less cost than expected.""Discipline plus a completeness check gets you most of the correctness for a fraction of the cost — and that's not a caricature: is_generated_builtin_class shows this codebase already has at least one place where the completeness check is real, CI-enforced, and untyped."

Tension point: operators and BEAM veterans are genuinely split — the veteran cares about eliminating a class of latent cross-surface bugs (REPL vs CLI vs hot-reload disagreeing), which only Option A fixes, while the operator's instinct is to minimize risk to a single release. We resolve this in favor of the veteran's argument because the issue itself frames this as the third recurrence of the same root cause — an operator who has already shipped three point-fixes for the same bug should weigh the compounding cost of a fourth recurrence, not just this migration's size. We also concede the language-designer steelman for Option B more ground than the first draft of this ADR did: ModuleName is not a hard type-system wall. leaf::atom (beamtalk-cerl-doc/src/leaf.rs:57) accepts any impl Into<String>, beamtalk-cerl-doc sits below beamtalk-core with zero dependencies of its own, and two real boundaries — the Pass-1 build cache and the compiler-port's JSON wire format — necessarily reconstruct a ModuleName from a plain string at a trust boundary. The newtype's actual benefit is narrower than "structurally impossible to bypass": it means the handful of functions that resolve a class to a module return one typed value instead of five ad hoc Strings, which is real but is an in-process discipline win, not a compiler-enforced wall against every future call site. Option A remains the right call because its real justification — one authority reachable from CLI, REPL, LSP, and the compiler-port alike — doesn't depend on that overstated claim.

Option C (full opaque ClassId interning, rustc-style) gets a genuine steelman only from the language designer: "this is the only version that also fixes adjacent bugs like BEAMError/Beamerror case-folding collisions, not just the module-name question." No other cohort's argument survives contact with its cost — see Alternatives Considered.

Alternatives Considered

Option B: Minimal consolidation in place

Keep class_module_index where it is (beamtalk-cli), mandate that it always be fully populated (merge in stdlib and all dependencies unconditionally), and delete only the fallback tiers in compiled_module_name — no crate move, no ModuleName newtype.

Rejected primarily because it doesn't fix the REPL/compiler-port duplication — neither may depend on beamtalk-cli, so they would keep independently assembling an equivalent map (and, per this ADR's research, the REPL/workspace side's actual index-builder today is beamtalk_repl_ops_load.erl's regex-based scan, which Option B leaves fully untouched). The newtype is a secondary reason, not the primary one: as the Steelman above concedes, ModuleName is a real but partial discipline win, not an unbypassable wall, so it alone would not be sufficient grounds to prefer Option A. What tips the balance is the cross-surface authority Option B cannot provide without also relocating the construction logic — at which point it stops being "minimal."

Option C: Full opaque ClassId interning (rustc-style)

Introduce an interned ClassId resolved once during semantic analysis; thread it through the AST/IR in place of string class names everywhere; render module names via one memoized query keyed on ClassId.

Rejected as disproportionate: the blast radius extends well past the issue's own file list into the parser, AST representation, every codegen function currently pattern-matching on &str class names, and language-service queries (hover/completion) that key off strings today. This solves a more general problem (identifier interning) than the one at hand (module-name derivation), at a cost closer to a multi-quarter rewrite than the "genuine architectural change... not a quick fix" the issue itself scoped.

Status quo: continue point-fixing as new instances appear

Explicitly rejected by the issue itself — this is the third real bug from the same root cause (BT-3081, BT-3431, BT-3432), each independently patched without addressing the pattern that produces them.

Consequences

Positive

Negative

Neutral

Implementation

  1. Independent quick win, shippable ahead of the rest: widen is_generated_builtin_class to pub(crate)/pub, make is_known_stdlib_type delegate to it, and delete crates/beamtalk-codegen/build.rs's file-stem scan and STDLIB_CLASS_NAMES entirely. No new dependency, no registry required. This alone closes the BT-3432 bug shape and the Printable.bt false positive for the one call site that had them.
  2. Add ClassModuleRegistry + ModuleName (with its PackageId-qualified key) to beamtalk-core::semantic_analysis, generalizing today's build_class_module_index ("Pass 1") to cover single-file and package mode uniformly. The build API must support the existing per-file incremental cache (.beamtalk-pass1-cache.json, CACHE_VERSION) — a per-file RegistryEntry plus a merge function, not a monolithic re-parse-everything call — or this ADR silently regresses incremental build times; bump CACHE_VERSION when the cache's serialized shape changes.
  3. Route the stdlib's registry construction through build_stdlib.rs's existing parse pass (it already produces real class metadata for generated_builtins.rs/beamtalk_generated_builtins.hrl) rather than adding any new parsing step. The stdlib arm of the registry validates the closed-form bt@stdlib@{snake} name against the real parsed class name (catching a BT-3432-shaped mismatch) rather than freely computing a path-based name — the Erlang runtime's closed-form derivation is a hard constraint (see Context), not a stepping stone to a lookup. Merge stdlib into every project's registry through the same dependency-merge path used for path dependencies, with explicit, documented precedence (own package wins over a dependency, which wins over stdlib) so shadowing a protected builtin (is_runtime_protected_class, a warning today, not an error) has one defined outcome instead of an accident of tier ordering.
  4. Point beamtalk-codegen's compiled_module_name/superclass_module_name at the registry, falling back to the existing best-effort convention only on a genuine registry miss (see Decision); replace module_matches_class's five call sites with a direct check against the class currently being generated rather than a module-name comparison.
  5. Route compiled_module_name_qualified (ADR 0070's pkg@Class) through the registry so it agrees with unqualified resolution for subdirectory classes, covered by a regression test. resolve_qualified_module_name itself stays as-is.
  6. Give the registry an assign operation (see Decision) and relocate the compiler-port's derive_class_module_name (beamtalk-compiler-port/src/main.rs:1494-1508, which mints a name for an inline class definition with no source file — not a lookup) and REPL's independent map assembly onto it.
  7. Regression tests reproducing each of BT-3081/BT-3431/BT-3432's specific scenarios against the unified registry (per the issue's acceptance criteria), plus a subdirectory-dispatch test. (No Future-specific regression test — nothing resolves its module on a live path today; see Decision.)
  8. Verify byte-identical compiled output for the existing corpus via the test-package-compiler insta snapshot suite (test-package-compiler/tests/snapshots/*.snap) — not just build-corpus, which regenerates the unrelated MCP search-tool corpus (ADR 0062).

Open questions for the author, not resolved by this ADR:

Affected components: beamtalk-core (new registry in semantic_analysis), beamtalk-codegen (build.rs deleted, value_type_codegen.rs/util.rs/primitive_bindings.rs consolidated), beamtalk-cli (build.rs/build_cache.rs Pass 1 relocated and generalized), beamtalk-repl (codegen.rs simplified), beamtalk-compiler-port (derive_class_module_name replaced with assign).

Estimated size: XL, matching the issue's own label — likely a floor rather than a ceiling if the workspace-mode open question above is pulled into scope.

Implementation Tracking

Epic: BT-3434 Issues: BT-3435 (foundation — ClassModuleRegistry), BT-3436 (consumers — codegen/REPL/compiler-port wiring), BT-3437 (validation — regression tests + corpus verification) Status: Complete — all three issues are Done. BT-3437 added regression tests reproducing BT-3081/BT-3431/BT-3432's specific scenarios against the unified registry, a package-compiler/e2e subdirectory-dispatch test, and a Future/native-backing-module resolution test, and confirmed test-package-compiler's insta snapshot suite is byte-identical (zero diffs) — the "no behavior change" constraint holds. The two open questions in Implementation (the Erlang-side beamtalk_repl_ops_load.erl regex scanner, and ADR 0114 live-rename invalidation) were both explicitly deferred, not resolved, at the time.

BT-3441 (follow-up, Done): Resolved the first open question above. beamtalk_repl_ops_load:build_source_class_module_index/1's regex-based extract_all_bt_classes/1 and hand-rolled source_module_name/3 (snake-casing via beamtalk_repl_loader:to_snake_case/1) are gone; the compiler port gained a build_class_module_index_in_source command (pinned to the shared compiler_port_command_vocabulary_corpus.json conformance fixture, ADR-cross-referenced BT-3095) that parses each src/**/*.bt file with the real grammar and derives its module name via the same relative_module_segments leaf compute_relative_module/ClassModuleRegistry use — so the workspace/REPL cold-load index can no longer diverge from a beamtalk build of the same project. class_module_index's wire shape (ADR 0050) is unchanged — an internal-producer swap only. The ADR 0114 live-rename invalidation open question remains open.

References