ADR 0019: Singleton Access via Class Variables

Status

Implemented (2026-02-15)

Context

Problem

Transcript and Beamtalk are workspace singletons (ADR 0010) that currently rely on special codegen magic:

  1. A hardcoded WORKSPACE_BINDING_NAMES list in the compiler recognizes these names
  2. The compiler generates persistent_term:get({beamtalk_binding, 'Name'}) lookups instead of normal dispatch
  3. This only works in workspace_mode — compiled code cannot access these singletons at all, erroring with WorkspaceBindingInBatchMode

This creates two problems:

Current State

ADR 0010 established the workspace-injected bindings model:

Workspace supervisor starts
  → spawns TranscriptStream singleton actor
  → spawns SystemDictionary singleton actor
  → each calls persistent_term:put({beamtalk_binding, 'Name'}, BeamtalkObject)
  → codegen generates persistent_term:get lookups for these names

With ADR 0013 (BT-319) now complete, we have class variables and class-side methods. The singleton pattern can be expressed naturally in the language itself:

Actor subclass: SystemDictionary
  classVar: current = nil
  class current => self.current

Constraints

Decision

Replace workspace binding magic with class variable singletons. Remove the special codegen path for Transcript, Beamtalk, and Workspace. Instead, access singletons through class-side methods backed by class variables.

Naming

The three singleton classes use their full descriptive names. The workspace bootstrap sets short convenience bindings:

Class (explicit)Convenience bindingRationale
TranscriptStreamTranscriptFamiliar Smalltalk name
SystemDictionaryBeamtalkSystem introspection entry point
WorkspaceEnvironmentWorkspaceFollows Squeak 5.2+ Environment pattern — scoped binding container

WorkspaceEnvironment models Squeak's Environment class: a scoped dictionary of name→object bindings with parent fallback. It holds actor introspection, user variables, and convenience bindings. The name Workspace as a binding mirrors how Pharo uses Smalltalk as the convenience name for SystemDictionary.

Class Definitions

All three workspace singletons get the same pattern:

Actor subclass: TranscriptStream
  classVar: current = nil

  class current => self.current
  class current: instance => self.current := instance

  show: value => @primitive 'show:'
  cr => @primitive 'cr'
  // ... other instance methods
Actor subclass: SystemDictionary
  classVar: current = nil

  class current => self.current
  class current: instance => self.current := instance

  allClasses => @primitive 'allClasses'
  classNamed: className => @primitive 'classNamed:'
  // ... other instance methods
Actor subclass: WorkspaceEnvironment
  classVar: current = nil

  class current => self.current
  class current: instance => self.current := instance

  actors => @primitive 'actors'
  actorAt: pidString => @primitive 'actorAt:'
  actorsOf: className => @primitive 'actorsOf:'

Usage — Everywhere

// Explicit form — works everywhere, including outside a workspace:
TranscriptStream current show: 'Hello, world!'
SystemDictionary current allClasses
WorkspaceEnvironment current actors

// Short form — workspace bootstrap binds these convenience names:
Transcript show: 'Hello, world!'
Beamtalk allClasses
Workspace actors

Workspace Convenience Bindings

The workspace bootstrap sets convenience variables after wiring class variables:

// workspace_bootstrap.bt — run after singleton class variables are set
Transcript := TranscriptStream current
Beamtalk := SystemDictionary current
Workspace := WorkspaceEnvironment current

These are ordinary workspace-scoped variable assignments — no compiler magic. Any code running inside a workspace (REPL sessions, compiled code loaded into the workspace, scripts) sees them. Code running without a workspace (pure batch compilation) uses the explicit TranscriptStream current form.

This preserves the familiar short names from ADR 0010 while eliminating codegen magic. The short names are sugar, not language features.

REPL Session

> Transcript show: 'Hello from the REPL'
nil
> Beamtalk version
'0.1.0'
> Beamtalk allClasses
[Integer, Float, String, ...]
> Workspace actors
[#Actor<Counter,0.132.0>]

// Explicit form also works:
> TranscriptStream current show: 'Explicit path'
nil
> WorkspaceEnvironment current actors
[#Actor<Counter,0.132.0>]

Error on Misuse

> TranscriptStream current
// Before workspace startup:
// => nil

> TranscriptStream show: 'oops'
// => ERROR: does_not_understand — TranscriptStream does not understand 'show:'
//    Hint: TranscriptStream is a class. Did you mean: TranscriptStream current show: 'hello'

What Gets Removed

Workspace Bootstrap Changes

The workspace supervisor currently spawns singletons in Erlang and registers them via persistent_term. With this ADR, the supervisor still spawns and supervises the singleton processes (OTP supervision is non-negotiable for crash recovery), but after startup it runs a bootstrap step that sets the class variables to point to the supervised processes.

The bootstrap step is Beamtalk code — similar to Pharo's package postload initialization:

// workspace_bootstrap.bt — run by workspace supervisor after child processes start
// The supervisor has already spawned the singletons; bootstrap wires them into
// class variables so Beamtalk code can find them.
TranscriptStream current: TranscriptStream spawn
SystemDictionary current: SystemDictionary spawn
WorkspaceEnvironment current: WorkspaceEnvironment spawn

Note on supervision: The spawn calls here go through the class gen_server which calls start_link, linking the new process to the class. The workspace supervisor's role is to ensure the bootstrap runs and to restart singletons if they crash. The exact supervision strategy (supervisor starts processes directly vs. bootstrap starts them) is an implementation detail to be resolved in Phase 2. Options include:

  1. Supervisor spawns, bootstrap wires: Supervisor starts children via start_link as today, bootstrap reads PIDs and sets class variables (safest OTP pattern)
  2. Bootstrap spawns, supervisor monitors: Bootstrap code creates the actors, supervisor adopts them via monitoring (more Beamtalk-native but less standard OTP)

Option 1 is recommended. The bootstrap then becomes:

%% In workspace supervisor, after children start:
%% Phase 1: Wire class variables to singleton instances
TranscriptPid = whereis('Transcript'),
TranscriptObj = {beamtalk_object, 'TranscriptStream', beamtalk_transcript_stream, TranscriptPid},
beamtalk_object_class:set_class_var('TranscriptStream', current, TranscriptObj),
%% ... same for SystemDictionary and WorkspaceEnvironment

%% Phase 2: Set workspace convenience bindings
%% These are workspace-scoped variables — no compiler magic
beamtalk_workspace:set_binding('Transcript', TranscriptObj),
beamtalk_workspace:set_binding('Beamtalk', SystemDictionaryObj),
beamtalk_workspace:set_binding('Workspace', WorkspaceObj)

This is Erlang code in the supervisor initially. Moving to Beamtalk bootstrap code is a future enhancement once the language has better OTP integration primitives. The convenience bindings (Transcript, Beamtalk) are set alongside the class variables so both the short and explicit forms are available immediately.

This parallels Pharo's approach where:

Prior Art

Pharo/Squeak Smalltalk

Smalltalk and Transcript are global variables in the system dictionary — entries in Smalltalk globals. They're not class variables; they're namespace bindings. Access is Smalltalk globals at: #Transcript.

In practice, most code just uses Transcript as a bare name because the compiler resolves globals from the system dictionary. This is more magic than our approach — we're making the indirection explicit.

Squeak 5.2+ Environments

Squeak 5.2 introduced Environment — scoped dictionaries of name→object bindings. Multiple environments can coexist, each with different bindings, and lookup walks parent environments as fallback. This models exactly what Beamtalk's WorkspaceEnvironment needs: a scoped container for workspace-level bindings (Transcript, Beamtalk, user variables) with isolation between workspaces on the same node (future work, see ADR 0004).

Newspeak Platform Object

Newspeak has no globals at all. System services are accessed through a Platform object passed explicitly to the top-level module: platform console show: 'Hello'. This is the purist extreme — capability-based DI. Our TranscriptStream current is a middle ground: explicit access through the class, but without full module parameterization.

Erlang/OTP

Erlang singletons use registered processes (whereis/1) or persistent_term. Our class variable approach maps naturally to the OTP pattern — the class gen_server owns the singleton reference, and class_send provides the lookup.

Elixir

Elixir uses Application.get_env/3 for configuration singletons and GenServer.call({:via, Registry, name}) for process singletons. The class variable pattern is analogous to a module attribute holding a registered process reference.

Ruby

Ruby's singleton pattern uses MyClass.instance (via the Singleton mixin) or class-level instance variables (@@current). The current accessor pattern is widespread — Thread.current, Fiber.current, Process.current. Our TranscriptStream current follows this convention directly.

User Impact

Newcomer (from Python/JS): TranscriptStream current show: 'hello' reads naturally — "get the current transcript stream, show hello on it." The current pattern is familiar from many languages' singleton accessors.

Smalltalk developer: This is more explicit than Pharo's bare Transcript global, but follows the standard current class-side accessor pattern used for singletons in Smalltalk. The departure from implicit globals is deliberate — Beamtalk doesn't have a system dictionary for name resolution.

Erlang/BEAM developer: Clean mapping to OTP — the class gen_server owns the singleton reference. No hidden persistent_term lookups in generated code. Standard gen_server:call path for access.

Production operator: Observable via observer — the singleton is a named process under the workspace supervisor. Class variable state visible through standard BEAM introspection tools.

Tooling developer: Simpler codegen — no special dispatch path for workspace bindings. The LSP can provide completions for TranscriptStream current through normal class-side method analysis.

Steelman Analysis

Alternative A: Keep codegen aliases + add class variables

Alternative C: Class variables + REPL auto-binds short names

Tension Points

Alternatives Considered

Alternative: Do Nothing (Status Quo)

Keep the current persistent_term + codegen magic approach from ADR 0010.

Rejected because: Compiled code cannot access singletons — the WorkspaceBindingInBatchMode error blocks metaprogramming. The codegen has a special dispatch path that duplicates logic already handled by normal class/actor dispatch. Now that ADR 0013 provides class variables, the infrastructure exists to do this properly.

Alternative: Registered Process Names (whereis/1)

Back TranscriptStream current with whereis('Transcript') instead of a class variable. The current method would call whereis/1 directly.

%% TranscriptStream class >> current
handle_call({current, []}, _From, State) ->
    Pid = whereis('Transcript'),
    Obj = {beamtalk_object, 'TranscriptStream', beamtalk_transcript_stream, Pid},
    {reply, Obj, State}.

Rejected because: This still requires the class gen_server call (to dispatch the current message), so the performance benefit over a class variable is negligible. It also couples the singleton pattern to Erlang's process registry, which has a flat namespace. Class variables are the Beamtalk-native mechanism and compose better with future features (workspace-scoped class variables).

Alternative A: Class Variable Singletons + Keep Codegen Aliases

Keep Beamtalk and Transcript as compiler-recognized names that resolve to SystemDictionary current / TranscriptStream current under the hood.

// REPL (alias):
Beamtalk allClasses
// Compiled code:
SystemDictionary current allClasses

Rejected because: Two ways to do the same thing creates confusion about which is canonical. The codegen magic (WORKSPACE_BINDING_NAMES) is the exact thing we want to eliminate. Having aliases that "look like class names but aren't" is a learnability trap.

Alternative C: Class Variables + Workspace Binds Short Names (Adopted)

The workspace bootstrap sets Transcript := TranscriptStream current and Beamtalk := SystemDictionary current as workspace-scoped variable bindings after wiring class variables. No codegen magic — just ordinary variable assignments performed during workspace startup.

// In workspace (REPL or compiled code loaded into workspace):
Transcript show: 'hello'          // convenience binding
Beamtalk allClasses               // convenience binding

// Explicit form (works everywhere, including without a workspace):
TranscriptStream current show: 'hello'
SystemDictionary current allClasses

Adopted because: Provides the short names users expect without any compiler magic. The bindings are workspace-scoped variables — the same mechanism users employ for their own variables. Compiled code running in a workspace sees them; code running without a workspace uses the explicit form. One mechanism (workspace variable binding), two levels of verbosity.

Consequences

Positive

Negative

Neutral

Implementation

Phase 1: Add class variables and setters to stdlib classes

Phase 2: Supervisor bootstrap wires class variables

Phase 3: Remove codegen magic

Phase 4: Remove persistent_term binding infrastructure

Phase 5: Update all references

Affected Components

ComponentChange
stdlib/src/SystemDictionary.btAdd classVar: current, class current, class current:
stdlib/src/TranscriptStream.btAdd classVar: current, class current, class current:
stdlib/src/WorkspaceEnvironment.btNew file — stdlib class for workspace introspection (modeled on Squeak's Environment)
dispatch_codegen.rsRemove workspace binding codegen path
beamtalk_workspace_sup.erlSet class variables after child startup (replace persistent_term)
beamtalk_system_dictionary.erlRemove persistent_term singleton logic
beamtalk_transcript_stream.erlRemove persistent_term singleton logic
beamtalk_workspace_environment.erlRemove persistent_term singleton logic; renamed from beamtalk_workspace_actor.erl (BT-492)
beamtalk_workspace_binding_tests.erlRewrite for class variable pattern
E2E tests, examples, docsMinimal changes — Transcript and Beamtalk still work via workspace bindings

Migration Path

Code Changes

No user-facing breaking changes in workspace contexts. The workspace bootstrap sets convenience bindings, so existing code continues to work:

SyntaxStatus
Transcript show: 'hello'✅ Still works (workspace convenience binding)
Beamtalk allClasses✅ Still works (workspace convenience binding)
Workspace actors✅ Still works (workspace convenience binding)
TranscriptStream current show: 'hello'✅ New explicit form (works everywhere)
SystemDictionary current allClasses✅ New explicit form (works everywhere)
WorkspaceEnvironment current actors✅ New explicit form (works everywhere)

Internal changes (codegen, runtime):

Before (ADR 0010)After (ADR 0019)
WORKSPACE_BINDING_NAMES codegen magicRemoved — normal dispatch
persistent_term:get({beamtalk_binding, ...})beamtalk_object_class:get_class_var(...)
WorkspaceBindingInBatchMode errorRemoved — class variables work in all modes

Diagnostic

Outside a workspace (pure batch mode), Transcript resolves as the class TranscriptStream itself, which doesn't have instance methods:

TranscriptStream does not understand 'show:'
  Hint: 'show:' is an instance method. Did you mean: TranscriptStream current show: 'hello'

Implementation Tracking

Epic: BT-487 — Singleton Access via Class Variables (ADR 0019) Issues: BT-488, BT-489, BT-490, BT-491, BT-492 Status: Planned

PhaseIssueTitleSize
1BT-488Add classVar and current accessors to singleton stdlib classesS
2BT-489Bootstrap singleton class variables in workspace supervisorM
3BT-490Remove workspace binding codegen pathM
4BT-491Remove persistent_term singleton infrastructure from runtimeS
5BT-492Rename workspace_actor to workspace_environment + docs ✅S

References