ADR 0057: Authoritative Superclass Source for Abstract Stdlib Classes

Status

Accepted (2026-03-07)

Summary

Phase 1 (this ADR): Fix apply_class_info/2 to update the superclass field from __beamtalk_meta/0 when the compiled module loads. One field, targeted fix, restores correctness immediately.

Phase 5 (follow-up): Migrate static structural metadata out of the class gen_server entirely, making __beamtalk_meta/0 the sole source for immutable facts and the gen_server responsible only for mutable runtime state (live method table, class variables, hot-patch state). superclass happens to be the only field that is currently wrong — but the architectural direction is to stop duplicating static metadata in process state at all.

Context

The Problem

Every Beamtalk class gen_server holds a superclass field set at init/1 time and never updated by apply_class_info/2. For user-defined classes (those compiled from .bt source) this is fine: the compiled module's register_class/0 on-load hook calls update_class which calls apply_class_info, and the superclass passed by the compiler in ClassInfo is always correct.

For the abstract stdlib classes (ProtoObject, Object, Behaviour, Class, Metaclass) the picture is different:

  1. Bootstrap stubs (beamtalk_protoobject_bt.erl, beamtalk_class_bt.erl, beamtalk_behaviour_bt.erl, etc.) register these classes early — before compiled stdlib BEAM modules are loaded — so they use placeholder superclass values to satisfy the class registry before anything else starts.

  2. beamtalk_class_bt registers 'Class' with superclass => 'Object'. beamtalk_behaviour_bt registers 'Behaviour' with superclass => 'Object'. The actual hierarchy (Class → Behaviour → Object) is only declared in the compiled stdlib sources.

  3. When the compiled stdlib modules load (e.g. bt@stdlib@class.beam), their on-load hook calls update_class → apply_class_info. apply_class_info updates fields, methods, and flags — but not the gen_server superclass field.

  4. The gen_server therefore permanently holds the bootstrap stub's placeholder superclass, even after the compiled module is loaded and running.

  5. Any hierarchy traversal that relies on gen_server:call(Pid, superclass) — most importantly walk_hierarchy/3 in beamtalk_behaviour_intrinsics.erl — produces incorrect results for these classes. walk_hierarchy('Class', ...) follows Class → Object → ProtoObject instead of the correct Class → Behaviour → Object → ProtoObject, silently skipping Behaviour and all the protocol methods it defines (reload, superclass, allMethods, etc.).

Symptom Trail

BT-1169 (Counter class allMethods returning wrong results) and the parallel fix to metaclassSuperclass/1 are both direct consequences. Both required a superclass_name_from_meta_or_state/1 workaround that prefers __beamtalk_meta/0 over the stale gen_server field. Without a root-cause fix, every new intrinsic that traverses the hierarchy will need the same workaround.

The Two Sources of Truth

After the compiled stdlib loads, every abstract stdlib class has two representations of its superclass:

SourceValue for 'Class'When setReliability
gen_server #class_state.superclass'Object' (stale)Bootstrap stub init/1Wrong for abstract stdlib classes
bt@stdlib@class:'__beamtalk_meta'()'Behaviour' (correct)Compiled into the BEAM moduleAlways correct

__beamtalk_meta/0 is the canonical metadata source established by ADR 0050. The gen_server field was never intended to diverge from it.

Why apply_class_info Skips superclass

The omission was deliberate at the time (ADR 0032): the superclass of a class is immutable post-definition, so there was no reason to update it on hot reload. What was not anticipated was the bootstrap ordering gap — bootstrap stubs registering with incorrect placeholder superclasses that the compiled stdlib would later correct.

Constraints

Decision

Patch apply_class_info/2 in beamtalk_object_class.erl to update the superclass field when Meta explicitly provides a superclass entry (i.e. via __beamtalk_meta/0 in ClassInfo). Simultaneously update the ETS hierarchy table entry.

The logic:

%% In apply_class_info/2 — derive updated superclass from Meta:
NewSuperclass =
    case maps:find(superclass, Meta) of
        error      -> State#class_state.superclass; %% key absent — keep existing
        {ok, nil}  -> none;                         %% root class (codegen emits 'nil')
        {ok, S}    -> S                             %% corrected value from compiled module
    end,
%% Keep ETS hierarchy table in sync:
beamtalk_class_hierarchy_table:insert(State#class_state.name, NewSuperclass),

And in the returned #class_state{}:

State#class_state{
    ...
    superclass = NewSuperclass,
    ...
}

This ensures that when bt@stdlib@class.beam loads and its on-load hook calls update_class('Class', ClassInfo), apply_class_info overwrites the bootstrap stub's 'Object' with the correct 'Behaviour'.

After this change:

What Changes

ComponentChangePhase
beamtalk_object_class.erlapply_class_info/2 — update superclass from Meta; update ETS table1
beamtalk_object_class_tests.erlRegression test: update_class corrects stale superclass3
beamtalk_behaviour_intrinsics.erlRemove superclass_name_from_meta_or_state/1; revert metaclassSuperclass/1 to direct gen_server call2
beamtalk_behaviour_intrinsics.erlReplace collect_instance_methods_via_meta/3 with walk_hierarchy call4
beamtalk_behaviour_intrinsics_tests.erlUpdate tests for removed workaround helpers2, 4

REPL Verification

After the change, the following must hold:

Counter class allMethods includes: #reload     // => true
Counter class allMethods includes: #superclass // => true
Counter class allMethods includes: #new        // => true

Counter class class superclass == Actor class class        // => true
Actor class class superclass == Object class class         // => true
Object class class superclass == ProtoObject class class   // => true

And in Erlang (observable via sys:get_state/1):

Pid = beamtalk_class_registry:whereis_class('Class'),
State = sys:get_state(Pid),
'Behaviour' = State#class_state.superclass.   %% was 'Object' before this fix

Prior Art

Smalltalk / Pharo

In Pharo, the metaclass tower is fully self-describing — class objects introspect their own hierarchy via live Smalltalk message sends rather than reading from a separate process state. There is no equivalent of a "bootstrap stub superclass" — the image loads all class definitions simultaneously. The problem does not exist in Pharo.

Erlang OTP Hot Reload

Erlang's standard pattern for hot code reloading (code_change/3) explicitly updates gen_server state to match the new module version. The principle: process state must match the module it runs. The current Beamtalk bootstrap approach violates this by allowing process state to diverge after a module upgrade (bootstrap stub → compiled stdlib transition). This ADR restores OTP alignment: when the compiled module loads and calls update_class, the resulting state is fully in sync with the module.

Elixir Module Attributes

Elixir stores structural metadata (module attributes, type specs, behaviour declarations) directly in the compiled .beam module via __info__/1. There is no separate process holding a copy of this data. When a module is recompiled and hot-reloaded, the new __info__/1 is immediately authoritative. This is the same pattern as __beamtalk_meta/0 — the compiled module is the source of structural truth. Beamtalk diverges by also caching this in a gen_server, which creates the dual-source problem. Phase 5 (Option C) would align Beamtalk with Elixir's model: compiled module for static facts, process state only for mutable runtime data.

Erlang ETS as Authoritative Store

A common Erlang pattern is to treat ETS as the authoritative store and gen_server state as a write-through cache. The beamtalk_class_hierarchy_table already plays this role for class lookups. Keeping the ETS table and gen_server in sync follows that pattern.

User Impact

Newcomer: Transparent — they observe that Counter class allMethods returns the expected Behaviour protocol methods and do not need to know why it previously failed.

Smalltalk developer: Correct hierarchy traversal matches Smalltalk expectations. Counter class allMethods including reload and superclass matches Pharo's behaviour. Removes a surprising gap.

Erlang/BEAM developer: sys:get_state/1 on a class process now shows the correct superclass. Standard BEAM observability tools (observer, sys) tell the truth. The OTP code_change pattern is honoured.

Production operator: No user-visible change. The fix is internal to the runtime bootstrap sequence and completes before any user code runs.

Tooling developer: beamtalk_object_class:superclass/1 is now reliable for all classes. The LSP and compiler server can trust gen_server hierarchy data without cross-referencing __beamtalk_meta/0.

Steelman Analysis

Option A (Chosen): Patch apply_class_info to update superclass

Option B (Rejected): Meta-aware walk_hierarchy

Change walk_hierarchy/3 to use superclass_name_from_meta_or_state per hop.

Rejected because it perpetuates stale state and requires all future callers to defend against it independently. The BEAM veteran's risk concern is real but addressed by the error guard in Option A's maps:find/2 pattern: apply_class_info only overwrites superclass when Meta contains the key. If Meta is absent or the key is not present, the existing value is preserved unchanged — the init path is not affected.

Option C (Phase 5): __beamtalk_meta/0 as sole source for static metadata

Remove static structural fields from #class_state{} entirely. The gen_server becomes responsible only for mutable runtime state: live method table, class variables, hot-patch state. All structural queries (superclass, is_sealed, fields, etc.) read directly from __beamtalk_meta/0. Dynamic classes (no compiled module) use a separate lightweight in-memory store populated at register_class time.

Phase 5: superclass is the only field that is currently wrong, so Option A fixes all active bugs. Option C is the follow-up architectural migration once the gen_server's role as mutable-state-only process is fully defined. The two phases are independent — Option C can proceed whenever the codebase is ready, without blocking on any other work.

Tension Points

Alternatives Considered

Option B: Meta-aware walk_hierarchy

See Steelman Analysis above. Rejected because it perpetuates stale state and does not scale: every new hierarchy-traversal intrinsic must independently defend against the inconsistency.

Option C: Remove static metadata from gen_server (Phase 5)

See Steelman Analysis above. Not rejected — planned as a follow-up architectural migration once Option A restores correctness. superclass is the only field currently wrong; Option A fixes the active bug. Option C then cleans up the remaining ~9 static fields that are duplicated between #class_state{} and __beamtalk_meta/0. The two phases are independent and sequenced deliberately: correctness first, architectural cleanliness second.

Do Nothing (per-site workarounds)

Continue adding superclass_name_from_meta_or_state call sites as new intrinsics need hierarchy traversal. Already rejected in BT-1169 — two sites existed and a third was anticipated before the issue closed.

Consequences

Positive

Negative

Neutral

Implementation

Phase 1 — Fix apply_class_info (beamtalk_object_class.erl)

  1. After computing Meta in apply_class_info/2, derive NewSuperclass:
    • maps:find(superclass, Meta) returns error (key absent) → keep State#class_state.superclass
    • Returns {ok, nil} → root class (codegen emits nil); normalise to none
    • Returns {ok, S} → use S (overrides the bootstrap stub value)
  2. Call beamtalk_class_hierarchy_table:insert(State#class_state.name, NewSuperclass).
  3. Include superclass = NewSuperclass in the returned #class_state{}.

Phase 2 — Remove Workarounds (beamtalk_behaviour_intrinsics.erl)

  1. Remove superclass_name_from_meta_or_state/1.
  2. Replace its two call sites in metaclassSuperclass/1 with direct gen_server:call(Pid, superclass).
  3. Update the EUnit test that exercises the workaround path.

Phase 3 — Add Regression Test (beamtalk_object_class_tests.erl)

Add a test that:

Run just test and just test-stdlib to verify the metaclass tower tests pass without the workaround.

Phase 4 — Simplify collect_instance_methods_via_meta

With walk_hierarchy/3 now producing correct results for all classes, collect_instance_methods_via_meta/3 in beamtalk_behaviour_intrinsics.erl can be replaced by a walk_hierarchy call. This helper was introduced specifically to bypass the stale gen_server superclass; after Phase 1, its raison d'etre is gone. The method-collection logic can use the same walk_hierarchy + gen_server:call pattern as metaclassClassMethods and other intrinsics.

Phase 5 — Migrate Static Metadata out of gen_server

Design accepted here. No separate ADR — the reasoning is captured in this document. BT-1272 implements the actor/value-object __methods__ removal described below; the broader #class_state{} cleanup remains planned Phase 5 follow-up work.

Accepted design — class gen_server (#class_state{}) (planned): Remove ~9 static fields (superclass, is_sealed, is_abstract, fields, method_return_types, class_method_return_types, method_signatures, class_method_signatures, doc). The gen_server becomes responsible only for mutable runtime state: live method table (with hot-patch deltas), class variables, runtime-added docs. All structural queries read from __beamtalk_meta/0 directly. Dynamic classes (no compiled module) populate a thin ETS cache at register_class time from ClassInfo.

Implemented in BT-1272 — compiled actor/value-object instance state: Remove $beamtalk_class and __methods__ from every compiled actor/value-object state map. __class_mod__ is retained as the sole identity key.

ETS was considered for the method table but rejected for the compiled-actor path: method_table/0 is a constant module function (no allocation, no locking, automatically correct after hot-reload). ETS is idiomatic for shared mutable state, not for immutable module-level constants.

Phase 5 does not block Phases 1-4.

References