ADR 0016: Universal Module Naming with @ Separator

Status

Accepted (2026-02-10), Implemented (BT-454)

Amendment 2026-02-10: Changed from underscore-based naming (bt_stdlib_*) to at-sign separator (bt@stdlib@*) for clearer namespacing and following proven BEAM patterns (Gleam). Extends scope from stdlib-only to all compiled Beamtalk modules.

Filename note: This ADR was originally drafted for "unified stdlib module naming" and later expanded to universal module naming for all compiled Beamtalk modules. The file name 0016-unified-stdlib-module-naming.html is retained for historical continuity and to avoid breaking existing links.

Context

The Problem

The Beamtalk standard library packaging has evolved through multiple phases (ADR 0007, ADR 0009, BT-340, BT-411) and accumulated inconsistencies. This ADR consolidates the full stdlib packaging architecture — module naming, app metadata, class registration, and runtime dispatch — into a coherent design.

Current Architecture: How Stdlib Packaging Works

The stdlib packaging pipeline has five interacting pieces:

1. Build-time: build_stdlib.rs compiles stdlib/src/*.bt.beam

Each .bt file in stdlib/src/ is compiled to a BEAM module. The module name is determined by module_name_from_path() using a two-prefix scheme:

CategoryCountPrefixExampleReason
Primitive types12beamtalk_Integerbeamtalk_integerDrop-in replacement for old hand-written Erlang dispatch modules
Non-primitive types7bt_stdlib_Numberbt_stdlib_numberAvoid shadowing Erlang built-in modules on case-insensitive filesystems
Bootstrap classes3bt_stdlib_Objectbt_stdlib_objectCompiled but skipped during loading (bootstrap registers these with runtime modules instead)

Note: This two-prefix scheme is the current implementation being replaced by this ADR.

2. Build-time: .app.src env metadata

build_stdlib.rs generates class hierarchy metadata in beamtalk_stdlib.app.src:

{env, [
    {classes, [{'beamtalk_integer', 'Integer', 'Number'},
               {'bt_stdlib_number', 'Number', 'Object'},
               {'beamtalk_set', 'Set', 'Object'},
               {'bt_stdlib_association', 'Association', 'Object'},
               ...]}
]}

Note: This mixed-prefix metadata is the current implementation being replaced.

This embeds {Module, ClassName, SuperclassName} tuples so the runtime can load modules in dependency order without filesystem discovery.

3. Boot-time: beamtalk_bootstrap.erl registers foundation classes

Before stdlib loads, bootstrap creates class processes for the three irreducible foundation classes — ProtoObject, Object, Actor — with beamtalk_object as their dispatch module. These are not replaced by compiled stdlib.

4. Boot-time: beamtalk_stdlib.erl loads compiled modules

Reads the {classes, [...]} env from the .app.src, topologically sorts by superclass dependency, and calls code:ensure_loaded/1 on each module. Loading triggers each module's on_load hook → register_class/0, which starts a class process via beamtalk_object_class:start/2.

Bootstrap classes (ProtoObject, Object, Actor) are explicitly skipped via is_bootstrap_class/1 to avoid overwriting the runtime registrations.

5. Runtime: beamtalk_primitive.erl dispatches to modules

send/3 pattern-matches on Erlang type guards and dispatches to the compiled module:

send(X, Selector, Args) when is_integer(X) ->
    beamtalk_integer:dispatch(X, Selector, Args);    % beamtalk_ prefix
send(X, Selector, Args) when is_binary(X) ->
    beamtalk_string:dispatch(X, Selector, Args);     % beamtalk_ prefix
...
%% Tagged maps — examine $beamtalk_class tag:
    bt_stdlib_association:dispatch(X, Selector, Args);  % bt_stdlib_ prefix (!)
    beamtalk_set:dispatch(X, Selector, Args);           % beamtalk_ prefix

Note: This inconsistent dispatch is the current implementation being replaced.

For user-defined value types, class_name_to_module/1 converts CamelCase → snake_case at runtime without any prefix, falling back to the module name as compiled by the user.

What's Wrong

1. The two-prefix naming convention is vestigial.

The beamtalk_ prefix was introduced so compiled .bt modules could be drop-in replacements for hand-written Erlang dispatch modules (beamtalk_integer.erl, beamtalk_string.erl, etc.). Those hand-written modules are all gone now — deleted during ADR 0007 Phase 4. The naming split has no remaining justification.

2. Three-way naming logic in codegen, each annotated "must stay in sync".

The compiler maintains three separate class lists across two crates:

// build_stdlib.rs
fn is_primitive_type(class_name: &str) -> bool { ... }          // 12 classes

// value_type_codegen.rs  
fn is_primitive_type(class_name: &str) -> bool { ... }          // Same 12 (must match!)
fn is_stdlib_nonprimitive_type(class_name: &str) -> bool { ... } // 9 classes (must match!)
fn superclass_module_name(superclass: &str) -> Option<String> {
    // Three-way branch: primitive? non-primitive stdlib? user-defined?
}

Each annotated with // NOTE: Must stay in sync with ... — a textbook code smell signaling the design has outgrown its implementation.

3. Already inconsistent in runtime dispatch.

beamtalk_primitive.erl dispatches Association with the bt_stdlib_ prefix but Set with the beamtalk_ prefix — both are tagged-map value types compiled from stdlib/src/*.bt:

bt_stdlib_association:dispatch(Selector, Args, X);  % bt_stdlib_ prefix
beamtalk_set:dispatch(Selector, Args, X);           % beamtalk_ prefix

4. Ambiguous naming convention.

A developer seeing beamtalk_set.beam in ebin cannot tell whether it's:

5. "Primitive" conflates two orthogonal concepts.

is_primitive_type() mixes up:

These are independent. Renaming beamtalk_integer to bt_stdlib_integer changes zero dispatch logic — send/3 still matches is_integer(X) and calls SomeModule:dispatch/3.

Decision

1. Use bt@ separator for all compiled Beamtalk modules

Following Gleam's proven pattern of using @ as a namespace separator, all .bt files compile to modules with bt@ prefix:

Stdlib: bt@stdlib@{snake_case} User code: bt@{snake_case}

The @ separator provides clear visual distinction from hand-written Erlang runtime modules which use beamtalk_ with underscore.

Class/ModuleBeforeAfter
Stdlib classes:
Integerbeamtalk_integerbt@stdlib@integer
Floatbeamtalk_floatbt@stdlib@float
Stringbeamtalk_stringbt@stdlib@string
Truebeamtalk_truebt@stdlib@true
Falsebeamtalk_falsebt@stdlib@false
UndefinedObjectbeamtalk_undefined_objectbt@stdlib@undefined_object
Blockbeamtalk_blockbt@stdlib@block
Symbolbeamtalk_symbolbt@stdlib@symbol
Tuplebeamtalk_tuplebt@stdlib@tuple
Listbeamtalk_listbt@stdlib@list
Dictionarybeamtalk_dictionarybt@stdlib@dictionary
Setbeamtalk_setbt@stdlib@set
Objectbt_stdlib_objectbt@stdlib@object
Numberbt_stdlib_numberbt@stdlib@number
Actorbt_stdlib_actorbt@stdlib@actor
Associationbt_stdlib_associationbt@stdlib@association
...bt_stdlib_*bt@stdlib@*
User code:
Counter (example)counterbt@counter
Point (example)pointbt@point
Validator (user)validatorbt@validator

Resulting naming convention:

PatternMeaningLocation
beamtalk_*_ops.erlLow-level Erlang FFI primitivesruntime/apps/beamtalk_runtime/src/
beamtalk_*.erlOther hand-written Erlang runtime coderuntime/apps/beamtalk_runtime/src/
bt@stdlib@*.beamStdlib compiled from stdlib/src/*.btruntime/apps/beamtalk_stdlib/ebin/
bt@*.beamUser code compiled from *.btUser's project ebin/

This makes the two-layer architecture explicit: bt@stdlib@list (Beamtalk stdlib API compiled from stdlib/src/List.bt) wraps beamtalk_list_ops (Erlang FFI in runtime) via @primitive pragmas. The runtime provides the bare-metal operations; the stdlib provides the Beamtalk-level class interface.

Why @ separator?

  1. Visual distinction - bt@stdlib@integer vs beamtalk_actor makes compiled vs hand-written instantly clear
  2. Unlikely in user identifiers - Users rarely choose @ in names (unlike _ which is common: user_service, data_store)
  3. Proven by Gleam - Gleam successfully uses gleam@list, gleam@string, gleam@dict for its stdlib
  4. Package-ready - Natural extension to third-party packages: bt@json@parser, bt@web@handler
  5. Namespace clarity - stdlib@ segment provides explicit collision protection

2. Simplify module_name_from_path() to a single rule

// build_stdlib.rs — AFTER
fn module_name_from_path(path: &Utf8Path) -> Result<String> {
    let stem = path.file_stem().ok_or_else(|| ...)?;
    let snake = to_module_name(stem);
    Ok(format!("bt@stdlib@{snake}"))  // ✅ Using @ separator
}
// is_primitive_type() deleted — no longer needed

3. Collapse three-way codegen lists into one

The stdlib class list is auto-derived from stdlib/src/*.bt at Rust compile time via beamtalk-core/build.rs (BT-472). Adding a new .bt file to stdlib/src/ automatically makes it a known stdlib type — no manual list maintenance needed.

// beamtalk-core/build.rs generates STDLIB_CLASS_NAMES from stdlib/src/*.bt
include!(concat!(env!("OUT_DIR"), "/stdlib_types.rs"));

// value_type_codegen.rs — AFTER
fn is_known_stdlib_type(class_name: &str) -> bool {
    STDLIB_CLASS_NAMES.contains(&class_name)
}

fn superclass_module_name(superclass: &str) -> Option<String> {
    if superclass == "ProtoObject" {
        return None;
    }
    let snake = to_module_name(superclass);
    if Self::is_known_stdlib_type(superclass) {
        Some(format!("bt@stdlib@{snake}"))  // ✅ Using @ separator
    } else {
        Some(format!("bt@{snake}"))  // ✅ User code also gets bt@ prefix
    }
}
// is_primitive_type() and is_stdlib_nonprimitive_type() deleted

4. Update .app.src env metadata (automatic)

build-stdlib regenerates the env metadata. After the rename all entries use bt@stdlib@ prefix:

{env, [
    {classes, [{bt@stdlib@integer, 'Integer', 'Number'},
               {bt@stdlib@number, 'Number', 'Object'},
               {bt@stdlib@set, 'Set', 'Object'},
               {bt@stdlib@association, 'Association', 'Object'},
               ...]}
]}

No code changes in beamtalk_stdlib.erl — it reads {Module, ClassName, SuperclassName} tuples generically. The topo-sort, is_bootstrap_class/1 skip, and ensure_class_registered/2 all work on class names, not module names.

5. Update beamtalk_primitive.erl dispatch atoms

Mechanical rename of module atoms in send/3 and responds_to/2 (which in turn calls the per-class has_method/1 implementations):

%% Before:
send(X, Selector, Args) when is_integer(X) ->
    beamtalk_integer:dispatch(X, Selector, Args);

%% After:
send(X, Selector, Args) when is_integer(X) ->
    bt@stdlib@integer:dispatch(X, Selector, Args);  % Unquoted — @ is legal in Erlang atoms

The dispatch logic (type guard matching, tagged-map detection, fallback to beamtalk_object) is unchanged. Only the module atoms that appear after the -> change.

6. class_name_to_module/1 stays as-is for user-defined types

The runtime fallback for user-defined value types (class_name_to_module/1 in beamtalk_primitive.erl) currently converts CamelCase → snake_case without any prefix. This needs updating — user .bt files now compile to bt@{snake_case} modules (e.g., Pointbt@point, Validatorbt@validator). The fallback must add the bt@ prefix.

Prior Art

Erlang/OTP

OTP uses application-prefixed module names (e.g., crypto_ec, ssl_cipher) to avoid collisions between applications. The bt@stdlib@ naming follows a similar namespace isolation principle — all compiled stdlib modules are clearly distinguished from runtime modules and third-party code.

Gleam

Gleam compiles modules to Erlang using the package name as @ separator prefix: gleam@list, gleam@string, gleam@dict.

Why Gleam chose @:

Gleam's experience: Successfully used in production since 2019. No reported issues with tooling (rebar3, dialyzer, observer). The community has accepted the convention and it appears throughout the ecosystem (gleam-lang/stdlib, gleam-lang/httpc, etc.).

Elixir

Elixir uses Elixir.ModuleName as the Erlang module atom for all compiled modules (using . as separator), providing a uniform prefix that distinguishes Elixir modules from Erlang ones.

Why Elixir chose .:

Trade-off: More quoting required than @, but arguably more familiar to developers from other ecosystems (Java, Python, etc.).

Our choice: We use @ instead of . because:

  1. Gleam proves @ works well in the BEAM ecosystem (5+ years of production use)
  2. Less visual noise than dots: bt@stdlib@integer vs 'bt.stdlib.integer' (dots require quoting, @ does not)
  3. Clearer separation from Erlang's : operator (Module:function calls)

User Impact

For Beamtalk Users (No Impact)

This change is entirely internal. Users write 42 + 3, Set new, 'hello' size — module names are never visible in Beamtalk code. Error messages display class names (Integer, Set), not module names.

For Runtime Developers

Clear rule: if a module starts with beamtalk_, it's hand-written Erlang you can edit directly. If it starts with bt@, it's compiled from a .bt file — edit the .bt source instead.

For Tooling/CI

No change to build commands or test commands. just build-stdlib produces bt@stdlib@*.beam files in the same location.

Steelman Analysis

Alternative: Keep the Split Naming (Status Quo)

CohortBest argumentAssessment
⚙️ BEAM veteranRenaming 12 modules risks stale .beam files or dialyzer PLT corruptionWeak. Stdlib is always rebuilt as a unit via just build-stdlib. No incremental module builds exist. just clean handles stale artifacts. Standard OTP practice.
🏭 OperatorZero risk. Ship features, not renames. ~2 hours spent here is ~2 hours not spent on metaclasses or the test frameworkModerate, but time-decaying. Every new stdlib class added pays the "which prefix?" tax. The rename gets more expensive the longer we wait as more code accumulates referencing the old names. Pre-1.0 is the cheapest time to do this.
🎨 Language designerThe naming split documents the real distinction between primitive types (native Erlang values) and non-primitive types (tagged maps)Weak. The distinction is real, but module names are the wrong place to encode it. beamtalk_primitive:send/3 already encodes it precisely via type guards — that's the source of truth. Worse, the boundary can shift: if Set moves from ordsets to ETS-backed processes, the split naming forces a module rename for a pure implementation change.

Arguments We Considered But Found No Strong Steelman For

Additional Steelman Arguments

🆕 Newcomer / Developer Experience perspective:

CohortBest argumentAssessment
🐍 Python/Ruby developerThe @ separator looks unfamiliar: bt@stdlib@integer:dispatch(). Why not just bt_stdlib_integer?Weak. Module names rarely appear in user-facing errors (we show class names, not module names). Runtime developers see this daily, but they benefit from the clear compiled-vs-handwritten distinction. The @ separator is unfamiliar at first but becomes recognizable quickly (Gleam developers report no confusion after initial exposure). No quoting needed — @ is legal in unquoted Erlang atoms.
📦 Package authorI'm publishing beamtalk-json. Do I use bt@json@parser or beamtalk_json_parser? If every package uses bt@, how do we avoid collisions?Weak. Package authors would follow the pattern: bt@json@parser, bt@web@handler. Collisions are prevented by the middle namespace segment (package name). This is exactly how Gleam works: gleam@json, gleam@http. The pattern is proven and scales. Documentation should make this clear with examples.
🔧 Erlang FFI authorI'm writing Erlang code that calls Beamtalk modules: bt@stdlib@list:foldl/3. The @ looks unusual in Erlang code.Weak. Since @ is legal in unquoted Erlang atoms, no quoting is needed — bt@stdlib@list:foldl(Fun, Acc, List) works directly. FFI is rare (most users write pure Beamtalk). The unfamiliarity fades quickly (Gleam FFI authors have no issues). Trade-off: FFI ergonomics vs namespace clarity. We choose clarity because FFI is the exception, not the rule.

Verdict

No steelman survives scrutiny. The operator timing argument has real weight but argues for "do it soon" not "don't do it." Every other argument either collapses on inspection or actively favours the rename.

Alternatives Considered

Alternative A: Move Primitives to beamtalk_runtime App

Move the 12 primitive BEAM files into the beamtalk_runtime application since the runtime dispatches to them.

Rejected because:

Alternative B: Unify Everything to beamtalk@*

Use beamtalk@ prefix for all compiled code (stdlib and user), without the stdlib@ namespace segment.

Rejected because:

Alternative C: Use Different Separators (. like Elixir, or -)

Use . (Elixir-style bt.stdlib.integer) or - (hyphen-separated bt-stdlib-integer).

Rejected because:

Alternative D: Only Rename Stdlib, Keep User Code as Plain Modules

Use bt@stdlib@* for stdlib but keep user code as plain counter, point, etc.

Rejected because:

Counter-argument: Could defer user code renaming until 0.2.0 or 0.3.0 to observe Gleam ecosystem evolution and reduce breaking change surface area now.

Rebuttal:

Consequences

Positive

Negative

Neutral

Implementation

Affected Components

ComponentFile(s)Change
Build stdlibbuild_stdlib.rsSimplify module_name_from_path() — always bt@stdlib@. Delete is_primitive_type(). Emit bt@stdlib@list etc. as unquoted atoms (no quoting needed — @ is legal in Erlang atoms).
User code compilermain.rs / compile.rsUpdate module name generation to bt@{snake_case} for user .bt files
Codegenvalue_type_codegen.rs, module_codegen.rsMerge is_primitive_type() + is_stdlib_nonprimitive_type()is_known_stdlib_type(). Update superclass_module_name() to emit bt@stdlib@ and bt@ prefixes. No quoting needed — @ is legal in unquoted Erlang atoms.
Runtime dispatchbeamtalk_primitive.erlUpdate ~24 module atoms in send/3 and has_method/1 to use @ separator (unquoted — @ is legal in Erlang atoms)
class_name_to_module/1beamtalk_primitive.erlUpdate fallback to add bt@ prefix for user-defined types
App metadatabeamtalk_stdlib.app.srcRegenerated by build-stdlib (automatic — no manual change)
Stdlib loadingbeamtalk_stdlib.erlNo change — reads module names from env generically
Bootstrapbeamtalk_bootstrap.erlNo change — registers ProtoObject/Object/Actor with beamtalk_object, unrelated to stdlib module names
Snapshot teststests/snapshots/*.snapUpdate expected module names in codegen snapshots (unquoted bt@stdlib@* atoms)
Codegen simulation testsbeamtalk_codegen_simulation_tests.erlUpdate module references if hardcoded

Phases

Single phase — this is a mechanical rename, not a behavioral change. All changes can be made atomically:

  1. Update build_stdlib.rs: delete is_primitive_type(), simplify module_name_from_path() to emit bt@stdlib@{snake}
  2. Update user code compiler to emit bt@{snake} module names
  3. Update value_type_codegen.rs: merge type lists, update superclass_module_name() to emit bt@stdlib@ and bt@ prefixes
  4. Update Core Erlang codegen to emit bt@ prefixed module names
  5. Run just build-stdlib to regenerate BEAM files and .app.src with new names
  6. Update beamtalk_primitive.erl module atoms in send/3, has_method/1, and class_name_to_module/1 to use @ separator
  7. Verify tooling compatibility: Test with rebar3 shell, observer, dialyzer, cover to confirm @ atoms work correctly
  8. Run just ci — fix snapshot tests as needed
  9. Clean dialyzer PLT and rebuild: cd runtime && rebar3 clean && rebar3 dialyzer
  10. Documentation sweep: Update all examples, tutorials, and docs to use new module names

Estimated size: L (mechanical but wider scope than stdlib-only, ~10+ files, ~120+ lines changed, affects user code compilation, requires documentation updates)

Migration Path

Breaking change level: Medium — affects module naming for all user code, but no source code changes required.

For Beamtalk User Code

What breaks:

Migration steps:

  1. Recompile all .bt files with the updated compiler
  2. Clean all ebin/ directories: rm -rf ebin/*.beam
  3. Rebuild: beamtalk build .

Source code: No changes needed — .bt source files are unaffected

For Runtime/Stdlib Developers

What breaks:

Migration steps:

  1. Run just clean to remove all old BEAM files
  2. Run just build-stdlib to regenerate with new names
  3. Run just ci to identify and fix snapshot tests
  4. Update runtime/apps/beamtalk_runtime/src/beamtalk_primitive.erl dispatch clauses
  5. Test tooling: Verify observer, rebar3 shell, dialyzer work correctly with @ atoms
  6. Rebuild dialyzer PLT: cd runtime && rebar3 clean && rebar3 dialyzer
  7. Update documentation: Sweep all docs, examples, tutorials for old module names

Backwards Compatibility

None. This is a breaking change that requires recompiling all Beamtalk code. Acceptable because:

Rollout Strategy

Single atomic commit. All changes can be made together since this is internal module naming. No gradual migration needed.

Future Considerations

Hot Code Reload

Erlang's hot code reload mechanism uses the module atom as the identity for code replacement. Since @ is legal in unquoted atoms, bt@stdlib@integer is a plain atom that works without quoting. However, bt@stdlib@integer and bt_stdlib_integer are different atoms — this ADR deliberately changes the module atom, so the VM treats them as two distinct modules. Code must be recompiled and loaded under the new name; hot reload will not transparently map old modules to the new naming scheme.

Class-Side Methods (ADR 0013)

When class-side methods are implemented, they will use the same module names as instance methods. Class-side dispatch will route through the same bt@stdlib@integer module, using different function names or metadata to distinguish class-side from instance-side methods. No module naming conflicts expected.

Namespace Versioning

If breaking stdlib changes require multiple versions to coexist (e.g., for gradual migration), we could use:

This is speculative—not a current requirement. The @ separator allows flexible extension if needed.

Package Ecosystem Examples

When third-party packages emerge, the pattern extends naturally:

Package collision prevention: middle namespace segment is the package name, providing clear ownership (same as Gleam's gleam@json, gleam@http pattern).

References