ADR 0084: Class-Side Runtime Method Installation and Fun Dispatch

Status

Accepted (in progress, 2026-05-24) — implementation epic BT-2259

Context

Problem

Beamtalk can install and dispatch instance methods at runtime — this is how >> live patching (ADR 0066), Behaviour compile:source: (ADR 0082), and the programmatic ClassBuilder (methods:, ADR 0038) all work. It cannot do the same for class-side methods. There is no way to:

This blocks the "first-class ClassBuilder" epic (BT-2259) and the class-side half of the live-editing story that ADR 0066 / ADR 0082 already sanctioned at the syntax level (ClassName class >> sel => body parses today with an is_class_method flag, but has no runtime dispatch path).

Current state

Instance side (works). Instance dispatch checks a runtime method source before the compiled class chain and invokes it as a fun that threads state:

Class side (does not work). Class-method dispatch only ever calls a compiled function:

The BT-873 precedent (the reason for caution)

BT-873 removed "Path 2" — closure-based dynamic class dispatch — because it was fundamentally broken: state mutation was silently dropped, super calls did not work, and self-sends were broken (ADR 0038, "Single Path to Class Creation"). Any proposal to install class methods as runtime funs must explain why it will not reproduce those failures.

Constraints

  1. No BT-873 repeat. Runtime class-method funs must thread class-variable state, resolve super up the metaclass chain, and make self sends dispatch as class messages — correctly, not "mostly".
  2. Class variables, not instance variables. Class methods mutate class variables via a distinct protocol: a compiled class method returns {class_var_result, Result, NewClassVars} (or a plain value), and the class gen_server commits NewClassVars (beamtalk_class_dispatch.erl:99,116,421). This is not the instance {Result, NewState} protocol.
  3. self is the class. Inside a class method, self new / self otherCM must dispatch as class messages against ClassSelf = #beamtalk_object{class = Tag, class_mod = DefiningModule, pid = self()} (beamtalk_class_dispatch.erl:465-469).
  4. One registry entry. An edited or builder-defined class stays a single beamtalk_class_registry entry; instances keep working; the class stays navigable (method_source / SystemNavigation).
  5. Document/codegen rule. All Core Erlang stays in the Document / docvec! API — no format! for codegen (CLAUDE.md, BT-875).

Decision

Add a class-side runtime method path that mirrors the compiled class-method calling convention exactly, fed only by compiler-generated funs.

1. Calling convention — identical to compiled class methods

A runtime-installed class-method fun has the same signature and return protocol as a compiled class_<Selector> function:

%% Compiled (today):   DefiningModule:class_<sel>(ClassSelf, ClassVars, A1, ..., An)
%% Runtime fun (new):  fun(ClassSelf, ClassVars, A1, ..., An) -> Result
%%                                                            |  {class_var_result, Result, NewClassVars}

arity = n + 2. Class-variable mutation is threaded through the existing {class_var_result, Result, NewClassVars} return that every caller already handles. No new state protocol is introduced.

2. Storage — the class gen_server class_methods map

Runtime class methods are stored as #{block => Fun, arity => Arity} in the class gen_server's class_methods map, mirroring how instance methods are stored in instance_methods (beamtalk_object_class.erl:642-644). Two changes:

3. Dispatch — check the map first, then fall back to compiled

beamtalk_class_dispatch:apply_class_method_in_context/6 runs after the chain walk has resolved the DefiningClass/DefiningModule for the selector. It does a cheap ETS read on the retrieval store (§2) for a runtime fun before the compiled apply — both keyed by the defining class, so this works identically for own and inherited methods:

%% DefiningClass already resolved by find_class_method_in_chain/2.
case beamtalk_class_metadata:lookup_class_method_fun(DefiningClass, Selector) of
    {ok, #{block := Fun}} ->
        apply(Fun, [ClassSelf, ClassVars | Args]);     %% runtime fun (ETS read, no hop)
    error ->
        FunName = class_method_fun_name(Selector),
        erlang:apply(DefiningModule, FunName, [ClassSelf, ClassVars | Args])  %% compiled (today)
end

Same arguments, same {class_var_result, …} handling, same error classification. A runtime override of a compiled selector shadows it (last writer wins), matching instance-side live-patch semantics.

This adds one ETS branch before the compiled apply; it does not violate ADR 0006 (unified dispatch) — it is the same "runtime source shadows compiled" pattern the instance side already uses for extensions, with a single resolution path. To honour BT-2008 (no per-send gen_server hops on the hot path), the retrieval is an ETS read keyed by the already-resolved DefiningClass — never a gen_server call — and is gated so compile-time-only classes (no runtime class methods) skip it entirely, e.g. a per-class "has runtime class methods" flag in the metadata row, checked before the ETS read.

ClassSelf.class_mod for funs. For a compiled method, class_mod is the defining module, and super/self-helpers are co-resident named exports in that module. An anonymous fun has no class_<sel> export and may not live in the defining BEAM module at all, so class_mod = DefiningModule cannot be relied on to locate the fun's helpers. The fun must be self-contained: all of its super/self/helper calls are captured in the closure at compile time (see §4), so dispatch only needs ClassSelf to carry the correct class identity (tag + defining class name) for further self-sends — not a module it can erlang:apply into.

4. Funs are compiler-generated — the BT-873 guard

The runtime funs are produced by the compiler, reusing the lowering it already applies to named class_<sel> functions, emitting it into an anonymous fun instead of a module export. The safety of this ADR rests on this lowering being concretely specified — "the compiler will do it right" is a plan, not an argument — so the implementation (Phase 2) must establish the following contract, each independently tested for the fun path before Phase 2 is considered done:

  1. Class-variable threading. Writes to class variables lower to the existing {class_var_result, Result, NewClassVars} return; the dispatch wrapper commits NewClassVars. Test: a builder/patched class method that mutates a class variable, called twice, accumulates (the BT-873 "dropped state" regression).
  2. super resolution without module identity. super sel inside a class-method fun lowers to an explicit, compile-time class-name-keyed superclass dispatch (e.g. beamtalk_class_dispatch:class_self_dispatch( SuperclassName, Sel, ClassVars, Args)), with SuperclassName resolved from the defining class at compile time and captured in the closure. It must not rely on erlang:apply(DefiningModule, …) or on ClassSelf.class_mod, because an anonymous fun has no such export (see §3). Test: a builder class method whose body calls super, resolving to the superclass's class method.
  3. self-sends. self new / self otherCM lower to explicit class-message dispatch against the ClassSelf the wrapper passes in. Test: a class method that calls another class method and self new.

They are never naive user closures. This is precisely the property BT-873's Path 2 lacked — Path 2 wrapped raw blocks with no lowering, so it dropped state and broke super/self. With the contract above, the runtime fun is behaviourally equivalent to a compiled class method — verified by parity tests, not assumed — differing only in location (an anonymous fun in the gen_server map vs a named export). The one thing a named export can do that an anonymous fun cannot is call module-local helper functions by name; the lowering must therefore either inline such helpers into the closure or route them through a stable module (the stdlib runtime), never through the (possibly absent) defining module.

5. Scope boundary — own methods vs cross-class extensions

This ADR covers a class installing/redefining its own class methods (builder creation + live edit of a class you own). Cross-class class-side extensions (SomeForeignClass class >> sel from another package) belong to ADR 0066's extension-registry model and would use a symmetric class-side extension registry; that is explicitly out of scope here and noted as future work.

REPL session

>> c := Object classBuilder
     name: #Tally;
     superclass: Object;
     classVars: #{ #total => 0 };
     classMethods: #{ #bump => [:self | self.total := self.total + 1. self.total] };
     register
=> Tally
>> Tally bump
=> 1
>> Tally bump
=> 2                                  // class variable threaded — not dropped

>> Counter class >> reset => self.count := 0   // live class-method patch
=> a CompiledMethod (#reset in Counter class)
>> Counter reset
=> 0

Error example

>> Object classBuilder name: #Bad; superclass: Object;
     classMethods: #{ #oops => [:self | super nope] }; register
>> Bad oops
=> error: Bad class does not understand #nope
         (super resolved up the metaclass chain — no silent drop)

Prior Art

Pharo / Squeak Smalltalk

A class method is just a method in the metaclass's method dictionary; the System Browser installs a CompiledMethod into Counter class exactly as it installs one into Counter. There is no second mechanism — instance side and class side are symmetric. Adopted: the symmetry — class methods get the same runtime-install path as instance methods. Adapted: Pharo compiles to bytecode in the image; we compile block bodies to BEAM funs and store them in the class gen_server, but the calling convention matches the compiled class method so there is one behavioural contract, not two.

Newspeak

Class-side state and methods live on the class object, mutated through ordinary message sends; the IDE edits live class objects. Adopted: class methods as first-class, runtime-editable members of the class object. Diverged: we keep files as source of truth (ADR 0004); runtime install is memory-only until flushed (ADR 0082).

Erlang / BEAM

No class concept; code:load_binary/3 hot-swaps a whole module. Our class gen_server map is the finer-grained analogue — one selector at a time — without recompiling the module. Adopted: memory-only runtime install, file remains authoritative.

Ruby

A class method is a method on the object's singleton class (metaclass); Ruby installs them at runtime with define_singleton_method / def self.x / class << self, which mutate the singleton class's method table exactly as define_method mutates the instance method table. State accessed via class instance variables is read/written through the same self (the class object). Adopted: the symmetry — runtime-defined class methods are first-class and mutate the class object's own method table, not a side registry. Diverged: Ruby has no compile step and no super-lowering concern — its dynamic dispatch resolves super at call time via the ancestor chain; Beamtalk lowers super ahead of time so the runtime fun carries explicit chain dispatch (the property that prevents BT-873's super breakage).

Beamtalk instance side (the local precedent)

The instance extension path (invoke_extension, ADR 0066) already proved that a compiler-generated fun with proper state threading dispatches correctly at runtime. This ADR is its class-side mirror, differing only where the domain genuinely differs (class-variable threading; self-is-the-class).

User Impact

Steelman Analysis

Alternative A — Class-side extension ETS registry (mirror ADR 0066 exactly)

Alternative B — A distinct closure convention fun(Args, ClassSelf, ClassVars)

Alternative C — Status quo: class methods are compile-only

Alternative D — Generic closure dispatch (revive BT-873 Path 2)

Tension points

Alternatives Considered

See Steelman. A (class-side ETS registry) — deferred to the cross-class case. B (distinct convention) — rejected, gratuitous divergence from the compiled shape. C (compile-only) — rejected, blocks the epic. D (generic closures) — rejected, reproduces BT-873.

Consequences

Positive

Negative

Neutral

Implementation

LayerChange
beamtalk_object_class.erlAdd put_class_method/4 (mirror put_method/4); store #{block, arity} in the class_methods map (source of truth); clear stale class-side signature/return-type; update beamtalk_class_metadata discoverability for the new selector (init/apply_class_info already do this for register-time methods, :419,1078); and write the fun into the retrieval store.
beamtalk_class_metadataAdd a retrieval store keyed by {DefiningClass, Selector} -> #{block, arity} plus lookup_class_method_fun/2 and a per-class "has runtime class methods" flag for gating. Populated by register-time builder methods and put_class_method/4; invalidated on update_class / remove.
beamtalk_class_builder.erlRun classMethods: through build_method_map/1 in build_compiled_class_info/8, and seed the retrieval store + metadata discoverability for the funs (register-time).
beamtalk_class_dispatch.erlIn apply_class_method_in_context/6, look up a #{block, arity} entry and apply(Fun, [ClassSelf, ClassVars | Args]) before the compiled erlang:apply fallback. Gate the lookup so compile-time-only classes pay no extra cost and no per-send gen_server hop is added (BT-2008).
crates/beamtalk-core/src/codegen/Lower class-method block bodies (builder classMethods:, ClassName class >> sel) to self-contained funs: class-var threading via {class_var_result, …}, ClassSelf-based self-sends, and super lowered to compile-time class-name-keyed superclass dispatch (no reliance on the defining module — see Decision §4).
stdlib/src/ClassBuilder.btclassMethods: and classVars: setters (classVars: rather than the reserved classState: declaration keyword; it writes the classState field / runtime key already read by register/1).
testsstdlib/test/ + runtime EUnit — the three §4 contract tests (class-var threading, super, self) for the fun path; subclass inheritance of a runtime-installed class method; update_class/reload precedence; live class >> patch round-trip.

Phasing: (1) runtime fun-path + put_class_method (incl. metadata update) + builder wrapping — delivers callable builder class methods, the bulk of BT-2259's value; (2) compiler lowering for classMethods: block literals with the §4 contract + parity tests; (3) class-side >> / compile:source: wiring. Phase 3 is gated on ADR 0082 being accepted (it owns the compile:source: / ChangeLog model); if 0082 stalls, Phase 3 ships as its own follow-on rather than blocking Phases 1–2. Each phase independently testable.

Migration Path

None — additive. Existing compiled class methods dispatch exactly as before (the runtime fun-path is checked first but is empty for file-defined classes that supply no classMethods: block funs). No source, codegen output, or on-disk format changes for current classes. The only behavioural change is that the previously-inert classMethods: builder key and the parsed-but-unwired ClassName class >> sel syntax start working.

Implementation Tracking

Epic: BT-2259 (Programmatic ClassBuilder parity) Status: In progress

PhaseIssueScope
1 — foundationBT-2258register returns a usable class object
1 — foundationBT-2266Runtime: class-side fun dispatch path + retrieval store (§1–3)
2 — coreBT-2267Callable class methods end-to-end: classMethods:/classVars: + compiler lowering (§4)
3 — parityBT-2268Metadata setters (signatures, return types, docs, meta, isConstructible)
3 — parityBT-2269Incremental class-piece API (addClassMethod:body:, addMethod:body:, addClassState:default:, remove*)
3 — parityBT-2270Compiler: auto-derive class-side classMethodSource (extends BT-2246)
4 — validationBT-2271Capstone: first-class builder + live class-edit e2e, docs, surface parity

References