ADR 0074: Deferred Metaprogramming

Status

Accepted (2026-04-01)

Context

Beamtalk takes inspiration from Smalltalk-80's metaprogramming model, where classes are objects, methods are objects, and the runtime is fully reflective. However, the BEAM VM has fundamentally different architecture from a Smalltalk image — process isolation, immutable terms, no shared heap, no stack frame reification.

During early development (BT-151), a comprehensive metaprogramming design was drafted covering eight feature areas. Some have since shipped; this ADR documents what remains deliberately deferred and why.

What Has Shipped

FeatureShipped viaNotes
Metaclass towerADR 0036Counter class, Counter class class, Metaclass stdlib class
Runtime-embedded docsADR 0033CompiledMethod with doc, source, selector; Class doc: setters
Class protocol (Behaviour/Class)ADR 0032superclass, methods, allSuperclasses, respondsTo:
Unified method dispatchADR 0006Hierarchy-walking dispatch via beamtalk_message_dispatch:send/3
Class objects as valuesADR 0013cls := Beamtalk classNamed: #Counter; cls spawn works
self in class methodsADR 0013Class methods receive a #beamtalk_object{} as self
Class method inheritanceADR 0032Class-side dispatch walks the superclass chain
Method hot-patchingbeamtalk_object_class:put_method/3 updates method dictionaries at runtime
System reflectionBeamtalk allClasses, Beamtalk classNamed:, Beamtalk help:
Field-based reflectionADR 0035fieldNames, fieldAt:, fieldAt:put:

What Remains Deferred

Three categories of Smalltalk-80 metaprogramming are deliberately deferred:

  1. thisContext (stack frame reification) — Smalltalk-80 exposes the execution stack as first-class objects: thisContext sender, thisContext restart, thisContext method. This enables debugger integration, continuations, and coroutines.

  2. become: (identity swap) — Smalltalk-80 allows atomically swapping all references to two objects: obj1 become: obj2. This enables schema migration, proxy replacement, and transparent forwarding.

  3. Classes as actors — The original design doc envisions each class as a full actor process: supervised, with mutable method dictionary state, participating in OTP lifecycle. Currently, class objects are backed by beamtalk_object_class gen_server processes that hold metadata and support dispatch, but these are runtime infrastructure — not user-visible actors with state: declarations, supervision trees, or actor lifecycle semantics.

Decision

Defer these three features indefinitely. Each is either impossible on BEAM (thisContext, become:) or has a poor cost/benefit ratio at the current stage (class-as-actor). Document the reasoning so future developers don't re-derive these conclusions.

1. thisContext — Not Implementable on BEAM

What Smalltalk provides:

thisContext              "Current stack frame"
thisContext sender       "Caller's frame"
thisContext method       "Current method"
thisContext restart      "Re-enter current frame"

Why BEAM can't do this:

What Beamtalk provides instead:

Impact: The main Smalltalk use cases for thisContext are debuggers and continuations. BEAM debuggers use int module tracing instead of stack reification. Continuations are better served by BEAM processes and message passing.

2. become: — Not Implementable on BEAM

What Smalltalk provides:

obj1 become: obj2   "All references to obj1 now point to obj2"

Why BEAM can't do this:

Workarounds documented in the design doc:

Impact: The main Smalltalk use cases for become: are schema migration and transparent proxies:

3. Classes as Full Actors — Deferred by Choice

What the design doc envisions:

Current state:

Why defer:

Expected resolution path: The practical gap closes incrementally through infrastructure needed for other reasons:

  1. BT-1768 — crash detection + auto-restart from compiled state (immediate)
  2. Dirty marking + disk flush — needed for workspace persistence; once class state flushes to disk, hot-patches and class vars survive restarts
  3. Supervisor link — trivial add-on once 1+2 exist: init/1 reads flushed state instead of compiled defaults

This makes "supervised class processes" an emergent outcome rather than a designed feature. The remaining "class-as-actor" gap — user-defined lifecycle hooks and state: on the class side — would only be needed if a framework requires metaclass-level doesNotUnderstand: for dynamic routing, which is speculative.

Prior Art

Smalltalk-80 / Pharo

Full metaprogramming: thisContext, become:, classes as first-class objects with metaclasses. All enabled by a single shared heap with mutable object graph. Pharo's debugger relies heavily on thisContext for stack manipulation.

Newspeak

Classes are first-class values and can be nested, but Newspeak doesn't rely on thisContext or become: — its module system achieves dynamism through class parameterization instead. Closer to Beamtalk's pragmatic approach.

Erlang/OTP

No object model — modules are static, processes are the unit of identity. Hot code loading replaces entire modules atomically. code:purge/1 and code:load/2 are the metaprogramming primitives. Beamtalk's method-level hot-patching goes beyond this.

Elixir

Compile-time metaprogramming via macros and __using__. No runtime become: or thisContext. Protocols provide dynamic dispatch without method dictionaries. Runtime reflection is limited to module attributes and __info__/1.

Pony

No become: or thisContext. Reference capabilities ensure data-race safety through the type system rather than runtime reflection. Classes are not first-class values. Shows that a modern actor language can succeed without Smalltalk-80 metaprogramming.

User Impact

Newcomer: No impact — these features are advanced and not expected by developers coming from Python/JS/Ruby.

Smalltalk developer: Will notice the absence of thisContext (debugger integration) and become: (transparent proxies). The workarounds (StackFrame, doesNotUnderstand: delegation) are adequate for most use cases but feel less elegant. Class objects being values-not-actors is unlikely to matter in practice.

Erlang/BEAM developer: Will find this natural — BEAM developers don't expect stack reification or identity swapping. The current reflection API (class, methods, respondsTo:) goes well beyond what Erlang offers.

Production operator: Benefits from simpler supervision trees and straightforward Observer debugging. Class process crashes are a latent risk (BT-1768 addresses detection and recovery), but existing actor instances are unaffected since they dispatch through compiled modules, not the class process.

Steelman Analysis

For implementing thisContext now:

For implementing become: now:

For implementing class-as-actor now:

Tension Points

Alternatives Considered

Alternative: Partial thisContext via Process Dictionary

Inject {current_method, {Module, Selector, Arity}} into the process dictionary at each method entry. Provides thisMethod but not thisContext sender or thisContext restart.

Rejected: The overhead of process dictionary writes on every method call is measurable, and the feature this enables (knowing the current method name) has limited utility compared to full thisContext. StackFrame already provides post-exception method identity.

Alternative: become: via Global Registry

Implement a global name registry where all object references are indirected through a lookup table. become: updates the table entry.

Rejected: Requires all object references to be indirected through the registry (global overhead), unlike the proxy pattern which only adds indirection to objects that need it (local overhead). Both share the single-node limitation. The registry approach also breaks the BEAM convention that pids are stable, direct identifiers — a property that tools like Observer and recon depend on.

Alternative: Opt-In Supervised Class Processes

Allow specific class processes to be supervised via a modifier (e.g. supervised class Foo), rather than requiring all class processes to be full actors.

Superseded by the expected resolution path: BT-1768 + dirty marking makes all class processes recoverable without per-class opt-in. No compiler changes needed — supervision is added at the runtime level for all class processes uniformly.

Alternative: Implement Class-as-Actor for v0.1

Promote all class processes to full supervised actors with state: declarations and OTP lifecycle.

Rejected: The value doesn't justify the complexity of defining crash/restart semantics for class processes. Bootstrap ordering is manageable (Supervisor is shallow in the hierarchy), and dispatch overhead is not a factor (class methods already go through gen_server:call). But if a supervised class process restarts, its hot-patched methods and class variable state are lost — defining safe recovery for dependent actors is the unsolved problem. The incremental path means this can be added later without breaking changes.

Consequences

Positive

Negative

Neutral

Implementation

No implementation work — this is a documentation decision. Completed alongside this ADR:

  1. Updated docs/known-limitations.md to reflect that classes are first-class values (not actors).
  2. Removed docs/internal/design-metaprogramming.md — this ADR supersedes it.

References