ADR 0010: Global Objects and Singleton Dispatch

Status

Implemented (2026-02-15). Phase 1 gen_server deprecation in progress (BT-1093).

Migration Status (BT-1093)

The ADR describes Phases 1–4 of the singleton dispatch implementation. All four phases are complete. A follow-on migration (tracked under BT-1093) is deprecating the legacy gen_server implementations (beamtalk_interface.erl, beamtalk_workspace_interface.erl) in favour of the compiled Beamtalk dispatch path (beamtalk_interface_primitives.erl, beamtalk_workspace_interface_primitives.erl).

Terminology note: In the context of BT-1093, "Phase 1" refers to the gen_server implementations and "Phase 2" refers to the compiled dispatch (primitives) implementations. These are different from the ADR's own Phase 1–4 implementation steps.

Current state (2026-03-04)

ModuleTypeLinesStatus
beamtalk_interface.erlPhase 1 gen_server~800Legacy — scheduled for removal (BT-1110)
beamtalk_interface_primitives.erlPhase 2 compiled dispatch~527Active — used by compiled Beamtalk class
beamtalk_workspace_interface.erlPhase 1 gen_server~923Legacy — scheduled for removal (BT-1111)
beamtalk_workspace_interface_primitives.erlPhase 2 compiled dispatch~460Active — used by compiled Beamtalk class

The workspace supervisor (beamtalk_workspace_sup) already starts the compiled Beamtalk classes (bt@stdlib@beamtalk_interface, bt@stdlib@workspace_interface) as the Beamtalk and Workspace singletons. The gen_server Phase 1 modules are no longer used at runtime for these singletons.

Remaining callers of Phase 1 gen_server modules

beamtalk_workspace_interface.erl (Phase 1):

beamtalk_interface.erl (Phase 1):

Primitive function coverage: Phase 1 vs Phase 2

Both interface pairs have full primitive parity as of 2026-03-04:

BeamtalkInterface primitives (allClasses, classNamed:, globals, help:, help:selector:, version) — identical in both phases.

WorkspaceInterface primitives (actors, actorAt:, classes, load:, globals, bind:as:, unbind:) — identical in both phases. The selectors actorsOf:, testClasses, test, test: that exist only in Phase 1 are now implemented as Beamtalk-level methods in stdlib/src/WorkspaceInterface.bt (not native primitives).

Migration sequence

  1. BT-1108 (S, unblocked): Switch REPL callers (repl_shell, repl_ops_dev, repl_eval) to call beamtalk_workspace_interface_primitives directly.
  2. BT-1109 (M, unblocked): Add unit tests for beamtalk_interface_primitives and beamtalk_workspace_interface_primitives.
  3. BT-1110 (M, blocked by BT-1109): Delete beamtalk_interface.erl and its Phase 1 tests.
  4. BT-1111 (M, blocked by BT-1108 + BT-1109): Delete beamtalk_workspace_interface.erl and its Phase 1 tests.

Completion criteria

Migration is complete when:

Context

Beamtalk currently has two "global" objects — Transcript and Beamtalk — that behave unlike any other object in the system. They are accessed by class name but have no instances. This creates several problems:

Current Implementation (Ad-hoc)

Transcript is a bare Erlang module (transcript.erl) with exported functions:

Transcript show: 'Hello'   // codegen → call 'transcript':'show:'(<<"Hello">>)
Transcript cr               // codegen → call 'transcript':'cr'()

Beamtalk is a global workspace binding backed by SystemDictionary (defined in stdlib/src/SystemDictionary.bt) and implemented by beamtalk_system_dictionary.erl:

Beamtalk allClasses         // dispatched via workspace binding → beamtalk_system_dictionary
Beamtalk classNamed: #Counter

Both are singleton actors registered as workspace bindings via persistent_term.

Problems

  1. Bypass dispatch entirely — Class-level method calls compile to direct module function calls (call 'module':'method'()), skipping beamtalk_dispatch:lookup/5. This means:

    • No doesNotUnderstand: — unknown methods produce raw Erlang undef errors instead of #beamtalk_error{}
    • No hierarchy walking — can't inherit Object methods like respondsTo:, class, describe
    • No extension methods — can't add methods to Transcript at runtime
    • Would bypass method combinations (before/after) once implemented
  2. Module naming collision — Transcript's module must be named transcript (matching to_module_name("Transcript")) rather than beamtalk_transcript, breaking the beamtalk_* naming convention. Any global whose class name collides with an Erlang stdlib module would shadow it.

  3. Not real objects — In Smalltalk, Transcript is an instance of TranscriptStream and Smalltalk is an instance of SystemDictionary. They respond to class, respondsTo:, inspect, etc. In Beamtalk, they're pseudo-objects that don't participate in the object model.

  4. Violates design principlesdocs/beamtalk-principles.md states "Newspeak-style: no global namespace, all access through message chains." Yet Transcript and Beamtalk ARE globals accessed by bare name.

Constraints

Decision

Adopt a workspace-injected bindings model: well-known objects (Transcript, Beamtalk) are singleton actor instances provided by the workspace as pre-bound variables — not language-level globals. This is a stepping stone toward explicit module-level imports.

Design

SmalltalkBeamtalk (Phase 1: interim)Beamtalk (Phase 2: modules)
Transcript — global in SystemDictionaryVariable bound by workspaceDeclared via module import:
Smalltalk — global in SystemDictionaryVariable bound by workspaceDeclared via module import:

User-facing syntax is unchanged:

Transcript show: 'Hello'        // works exactly as before
Transcript cr
Beamtalk allClasses
Beamtalk classNamed: #Counter

But now these are real objects, not bare Erlang modules:

Transcript class                // => TranscriptStream
Transcript respondsTo: #show:   // => true
Transcript inspect              // => "a TranscriptStream"

Beamtalk class                  // => SystemDictionary

Runtime Model

Each well-known object is a singleton actor owned by the workspace. The workspace injects them as pre-bound variables into the evaluation environment:

%% Workspace startup:
%% 1. Spawn singleton actors
{ok, TranscriptPid} = beamtalk_transcript_stream:spawn(),
{ok, BeamtalkPid} = beamtalk_system_dictionary:spawn(),

%% 2. Inject as workspace bindings (available to REPL and :load'd code)
WorkspaceBindings = #{
    'Transcript' => TranscriptPid,
    'Beamtalk'   => BeamtalkPid
}.

This is NOT a global registry — it's the workspace's environment. Code outside a workspace (e.g., beamtalk build without a workspace) does not have these bindings. This is deliberate: Transcript is a workspace service, not a language primitive.

Evolution: Module-Level Imports

The interim model (workspace-injected variables via persistent_term) is a stepping stone to explicit module-level imports, where classes declare their workspace dependencies:

// Phase 2: module declares dependencies, workspace resolves at load time
Object subclass: MyApp
  import: Transcript, Beamtalk

  run =>
    Transcript show: 'Starting'   // resolved from imports, not persistent_term
    Beamtalk classNamed: #Counter

Benefits over interim model:

Prerequisites: Import syntax, workspace-aware module loader.

Full Newspeak-style lexical scoping (nested classes accessing enclosing scope) is a possible future evolution but is not planned. A separate ADR should evaluate whether it's warranted given the module import model.

Sync vs Async Dispatch

In Pharo, all message sends (including Transcript show:) are synchronous — the caller blocks until the method completes. Pharo's ThreadSafeTranscript achieves thread safety via internal locking, not async messaging.

On BEAM, actors use async sends (gen_server:cast) by default — the caller gets a Future back and doesn't wait. This creates a tension for well-known instances:

MethodNeeds return value?Natural fit
Transcript show: 'Hello'No (I/O side effect)Async (cast)
Transcript crNoAsync (cast)
Beamtalk allClassesYes (returns list)Sync (call)
Beamtalk classNamed: #CounterYes (returns class)Sync (call)
Transcript classYes (returns TranscriptStream)Sync (call)

Decision: Use standard actor dispatch — async by default, await when needed.

Well-known instances are actors, so they follow the same rules as any actor:

// Async (fire-and-forget) — returns a Future, but usually ignored
Transcript show: 'Hello'

// Sync (needs the value) — caller awaits the Future
classes := Beamtalk allClasses     // implicit await on assignment
Transcript class                    // implicit await (result used)

This means workspace binding dispatch is uniform — always async via gen_server:cast + Future. The caller decides whether to await. No special sync/async annotation needed on methods.

%% Codegen dispatch for workspace-bound names:
dispatch_binding(Pid, Selector, Args) ->
    Future = beamtalk_future:new(),
    gen_server:cast(Pid, {Selector, Args, Future}),
    Future.

%% Class method sends remain direct calls (no binding lookup):
%% Counter spawn  →  call 'counter':'spawn'()

Transcript as Shared Workspace Log

Context: In Pharo, Transcript is a dev-time convenience — production Pharo apps use proper logging frameworks (e.g., Beacon). On BEAM, Beamtalk already uses OTP logger for structured runtime logging. Transcript's role in Beamtalk is therefore limited to interactive development:

Use caseTool
Learning / tutorialsTranscript show: (Beamtalk)
Quick REPL debuggingTranscript show: (Beamtalk)
Production loggingOTP logger (Erlang — structured, leveled)

Design: Transcript is a shared log actor, separate from the REPL — following Pharo's model.

In Pharo, Transcript is a separate window from the Playground (REPL). You write code in the Playground, output appears in the Transcript window. They are distinct UI surfaces. Beamtalk follows the same separation:

┌──────────────────┐  ┌──────────────────┐
│ REPL             │  │ Transcript       │
│                  │  │                  │
│ > 2 + 2          │  │ Hello            │
│ => 4             │  │ Got request /foo │
│ > Transcript     │  │ tick             │
│     show: 'Hi'   │  │ Hi               │
│ => nil           │  │                  │
└──────────────────┘  └──────────────────┘

Why separate?

  1. Library/app code — An actor doing Transcript show: 'Got request' should work regardless of whether a REPL is attached. There's no "caller's REPL" to route to.
  2. Shared workspace — Multiple REPL sessions share the same workspace (ADR 0004). Transcript output from any source is relevant to all viewers.
  3. No confusion — Newcomers see => nil in the REPL and Transcript output elsewhere. No interleaved output, no "where did that come from?" surprises.
  4. Simplicity — No group_leader tricks, no caller context in messages. Just a log sink with subscribers.

Transcript is a pub/sub actor:

                    ┌─────────────────────┐
  REPL eval    ──→  │                     │ ──→ `beamtalk workspace transcript` (CLI viewer)
  MyHttpServer ──→  │  Transcript Actor   │ ──→ REPL-2 (opted in via subscribe)
  background   ──→  │  (shared log sink)  │ ──→ IDE Transcript panel (future)
                    └─────────────────────┘

The actor maintains a ring buffer of recent output and a list of subscriber pids. Subscribers receive {transcript_output, Text} messages. Dead subscribers are cleaned up via process monitors.

Accessing Transcript output:

# Separate CLI viewer (like `tail -f` on the workspace log)
beamtalk workspace transcript
// In the REPL — just send messages to the object:
Transcript subscribe              // subscribe this session — output streams in
Transcript unsubscribe            // unsubscribe — REPL goes quiet again
Transcript recent                 // returns buffer contents (last N entries)
Transcript clear                  // clear the buffer

No special REPL commands needed — Transcript is a real object, so subscription is just message sends. This follows the principle that behavior lives on objects, not in REPL magic.

REPLs do NOT subscribe by default. The REPL is for eval results. Transcript is a separate concern — you subscribe when you want it, like opening Pharo's Transcript window.

When no subscribers exist (batch compile, headless app):

Cascade Semantics

Cascades send multiple messages to the same receiver, returning the result of the last message. Since Transcript is a real actor (bound via workspace), cascades work naturally:

Transcript show: 'Hello'; cr; show: 'World'

Compiles to (conceptually):

%% 1. Resolve Transcript from workspace bindings — it's a pid
Pid = lookup_binding('Transcript'),

%% 2. Send all messages to same pid (async, discard intermediate Futures)
gen_server:cast(Pid, {'show:', [<<"Hello">>], _F1}),
gen_server:cast(Pid, {'cr', [], _F2}),

%% 3. Last message — return its Future
Future3 = beamtalk_future:new(),
gen_server:cast(Pid, {'show:', [<<"World">>], Future3}),
Future3

Because gen_server processes messages sequentially from its mailbox, the three messages execute in order — show: 'Hello', then cr, then show: 'World' — even though the sends are async. This gives us ordered execution without blocking the caller.

Message dispatch uses the standard actor path. This means:

Codegen Change

The codegen currently special-cases ClassReference receivers, generating direct Erlang function calls. This ADR changes the codegen to check workspace bindings first.

How It Works

When the compiler encounters an uppercase identifier as a receiver, it checks a compile-time known set of workspace binding names (Transcript, Beamtalk). This is a static decision, not a runtime check:

  1. Name is a known workspace binding → generate persistent_term lookup + actor send
  2. Name is anything else → generate direct module function call (existing behavior, unchanged)
%% Current (all ClassReference → direct function call):
call 'transcript':'show:'(<<"Hello">>)    %% Transcript show: 'Hello'
call 'counter':'spawn'()                   %% Counter spawn

%% Proposed (compiler knows Transcript is a workspace binding):
%% Transcript → persistent_term lookup + actor send
let Pid = call 'persistent_term':'get'({beamtalk_binding, 'Transcript'}) in
gen_server:cast(Pid, {'show:', [<<"Hello">>], Future})

%% Counter → not a workspace binding, direct call (UNCHANGED, no lookup)
call 'counter':'spawn'()

Binding Resolution

The workspace provides bindings via persistent_term (internal implementation detail):

%% Workspace startup — populate bindings
persistent_term:put({beamtalk_binding, 'Transcript'}, TranscriptPid).
persistent_term:put({beamtalk_binding, 'Beamtalk'}, BeamtalkPid).

%% Codegen lookup — ~13ns, O(1), lock-free
lookup_binding(Name) ->
    persistent_term:get({beamtalk_binding, Name}, undefined).

Key difference from the global registry approach: The compiler statically knows which names are workspace bindings. Classes (Counter, Point, Array) are NOT — they generate direct module function calls with no lookup. This means:

Class Definitions (Future — not yet in stdlib/src/)

// stdlib/src/TranscriptStream.bt (future)
Actor subclass: TranscriptStream
  show: value => @primitive 'show:'
  cr => @primitive 'cr'
  subscribe => @primitive 'subscribe'
  unsubscribe => @primitive 'unsubscribe'
  recent => @primitive 'recent'
  clear => @primitive 'clear'

// stdlib/src/SystemDictionary.bt (renamed from stdlib/src/Beamtalk.bt)
Actor subclass: SystemDictionary
  allClasses => @primitive 'allClasses'
  classNamed: className => @primitive 'classNamed:'
  globals => @primitive 'globals'
  version => @primitive 'version'

Workspace Binding Names

Following Pharo's model, Transcript and Beamtalk are not class names — they are binding names for singleton instances. The classes are TranscriptStream and SystemDictionary respectively.

Transcript class              // => TranscriptStream  (not Transcript)
Beamtalk class                // => SystemDictionary   (not Beamtalk)

TranscriptStream              // => the class object itself
SystemDictionary              // => the class object itself

This is exactly how Pharo works: Smalltalk class returns SystemDictionary, not Smalltalk. The name Beamtalk is an alias for the singleton instance, not a class.

In the workspace, bindings and classes coexist in separate namespaces:

NameNamespaceValue
'Transcript'workspace bindingpid of the TranscriptStream singleton
'Beamtalk'workspace bindingpid of the SystemDictionary singleton
'TranscriptStream'classclass process
'SystemDictionary'classclass process
'Counter'classclass process

Workspace Startup

The workspace spawns and injects well-known objects during initialization:

%% In beamtalk_workspace.erl:
init_workspace_bindings() ->
    %% 1. Spawn singleton actors
    {ok, TranscriptPid} = beamtalk_transcript_stream:spawn(),
    {ok, BeamtalkPid} = beamtalk_system_dictionary:spawn(),
    
    %% 2. Inject as workspace bindings (persistent_term for fast codegen lookup)
    persistent_term:put({beamtalk_binding, 'Transcript'}, TranscriptPid),
    persistent_term:put({beamtalk_binding, 'Beamtalk'}, BeamtalkPid),
    
    %% 3. Register as named processes (for supervision and observer)
    register('Transcript', TranscriptPid),
    register('Beamtalk', BeamtalkPid).

Ordering constraint: Classes must be registered before their instances can be spawned. Bootstrap sequence:

  1. Register core classes (Object, Integer, String, etc.)
  2. Register TranscriptStream and SystemDictionary classes
  3. Workspace starts → spawns and binds singleton instances

Prior Art

Smalltalk (Squeak/Pharo)

Newspeak

Erlang/OTP

Gleam

User Impact

Newcomer: No change — Transcript show: 'Hello' works the same. Better error messages when methods are misspelled.

Smalltalk developer: Familiar model — globals are instances of real classes. Transcript class returns TranscriptStream as expected.

Erlang/BEAM developer: Natural mapping to registered processes. Can interact with globals from Erlang code via gen_server:call(Transcript, ...).

Operator: Globals are visible in observer as named processes. Can inspect state, restart if crashed (via supervisor).

Steelman Analysis

1. Performance Purist: "You're adding overhead to workspace binding dispatch"

Workspace binding dispatch adds a persistent_term:get/1 (~13ns) lookup before sending — and then an actor message send (~1-5μs) where the old transcript.erl was a direct function call (~10ns). That's a 100x slowdown for Transcript show:. And the codegen now has a branch: "is this a workspace binding? If so, lookup + actor send. Otherwise, direct call." Two dispatch paths means two sets of bugs.

Counter: The 13ns lookup is unmeasurable against actual work. Transcript show: is a dev debugging tool, not a hot loop — nobody profiles println. Class sends (Counter spawn, Point new) have ZERO overhead — they skip the binding check entirely and use direct module calls as before. The two-path codegen is simple: one persistent_term:get/2 with a default fallback. The hot path (class sends) is unchanged.

2. BEAM Purist: "This isn't idiomatic Erlang"

Erlang solves "global named service" with register/2 and whereis/1. You're layering persistent_term bindings, a workspace supervisor, and a pub/sub system on top. OTP already has logger for logging, pg for process groups, and sys.config for configuration. This is reinventing standard OTP infrastructure behind a Smalltalk-flavored API.

Counter: Workspace injection IS standard OTP — persistent_term for configuration, gen_server for actors, supervisors for crash recovery. The pub/sub for Transcript is ~20 lines of standard gen_server. And logger serves a different purpose: structured production logging vs. dev-time visible output. They coexist. The workspace model aligns with OTP's application environment pattern.

3. Simplicity Advocate: "This is a lot of machinery for two bindings"

The entire ADR — workspace injection, persistent_term bindings, supervision tree, pub/sub, bootstrap ordering, crash recovery — exists to make Transcript show: 'Hello' and Beamtalk allClasses "real objects." That's two bindings. The current 50-line transcript.erl works. You're proposing workspace-owned actors with supervision, Transcript pub/sub, a separate viewer CLI, and codegen changes. YAGNI — when you need a third binding, build the infrastructure then.

Counter: The machinery IS minimal — workspace spawns two actors, stores two persistent_term entries, one supervisor. That's it. No registry module, no framework. The pub/sub on Transcript is ~20 lines of gen_server. And the codegen change is a single persistent_term:get/2 fallback — 5 lines of Rust. The real value is making these first-class objects with proper doesNotUnderstand:, class, and respondsTo: — something bare module functions can't provide.

4. Smalltalk Purist: "Pharo's Transcript is synchronous and simple"

In Pharo, Transcript show: 'Hello' is a synchronous method call on a shared instance — write to the stream, done. Beamtalk's version involves: async cast to an actor, Future allocation, pub/sub dispatch to subscribers, ring buffer management, and asynchronous delivery to REPL sessions. The ordering guarantee comes from gen_server mailbox semantics, not from the language itself. A newcomer reading Transcript show: 'Hello' has no idea this machinery exists, and when output appears asynchronously between prompts instead of inline, it will confuse them.

Counter: Pharo is single-image, single-threaded UI. Beamtalk runs on BEAM — concurrent, distributed, multi-session. The async model is the honest one: output from a background actor genuinely IS asynchronous. Hiding that behind a synchronous facade would be misleading on BEAM. And the REPL can present Transcript output cleanly — Pharo's own Transcript window doesn't update until the UI thread yields, so Pharo users already experience "delayed" Transcript output in practice.

5. Capability/Security Advocate: "Workspace bindings are still globals in disguise"

docs/beamtalk-principles.md says "Newspeak-style: no global namespace, all access through message chains." Workspace bindings are better than a true global registry, but persistent_term IS globally readable — any code on the node can call persistent_term:get({beamtalk_binding, 'Transcript'}) to bypass the workspace abstraction. A compromised module can write to Transcript (information leak) or call Beamtalk allClasses (reconnaissance). And code compiled outside a workspace context — what happens when it references Transcript?

Counter: This is explicitly an interim step. The ADR documents the evolution to module-level imports, where dependencies are declared explicitly and resolved by the workspace at load time — making them mockable and verifiable. The interim uses persistent_term because it's the simplest OTP mechanism, not because it's the security model. Code outside a workspace gets a compile error for unresolved Transcript — that's the whole point of workspace-scoped bindings. And in practice, BEAM applications already have globally accessible process registrations. The workspace model is strictly more contained than Erlang's default.

6. Newcomer/DX Advocate: "Where did my output go?"

A newcomer types Transcript show: 'Hello' in their REPL and sees => nil. Where's "Hello"? It went to the Transcript channel, which they haven't subscribed to. In Python, print('Hello') shows output immediately. Beamtalk requires knowing that Transcript is a separate output channel and that you need to subscribe or open a viewer. That's a steeper learning curve for the most basic debugging tool.

Counter: The mental model is clear and consistent: Transcript is a shared log, like a separate window in Pharo's IDE. The REPL tutorial can explain this in one line: "Type Transcript subscribe to see output here, or run beamtalk workspace transcript in another terminal." And since Transcript is a real object, the newcomer learns the object model by interacting with it — Transcript subscribe, Transcript recent, Transcript class. Every interaction reinforces "everything is a message send." The alternative — inline output that sometimes interleaves with unrelated background actor output — is more confusing, not less.

Alternatives Considered

Alternative A: Fix Class-Level Dispatch Only

Add error handling to class-level method calls without changing the object model:

%% Wrap class method calls in try/catch
try transcript:'show:'(Value) catch error:undef -> ... end

Rejected: This is a band-aid. Globals still wouldn't be real objects, cascades still wouldn't work, and every new global would need a custom module with a naming collision risk.

Alternative B: Newspeak Pure Module Parameters

Pass globals explicitly to every module via lexical scoping:

Object subclass: MyApp platform: platform
  run =>
    platform transcript show: 'Hello'

Not planned: Requires nested class scoping — a major compiler effort. The module-level import: model (see "Evolution" section) provides explicit dependencies and mockability without the full Newspeak machinery. A separate ADR can evaluate full lexical scoping if the module import model proves insufficient.

Alternative C: Value Type Singletons (Not Actors)

Make globals value types (maps) rather than actors:

%% Transcript is just a tagged map
Transcript = #{'__class__' => 'TranscriptStream'}

Rejected: Value types are copied and have no shared identity. Transcript needs to be a single entity that all code sends messages to, especially for I/O coordination. Actors are the natural fit.

Consequences

Positive

Negative

Neutral

Implementation

OTP Application Placement (ADR 0009)

ADR 0009 splits the runtime into beamtalk_runtime (core language) and beamtalk_workspace (interactive development).

ComponentOTP AppRationale
SystemDictionary (Beamtalk)beamtalk_runtimeIntrospection of classes is a core language feature
TranscriptStream class (.bt + Erlang modules)beamtalk_runtimeThe class definition is a core language entity; available in batch compile
TranscriptStream singleton instance (Transcript binding)beamtalk_workspaceSpawned and bound at workspace startup; not available in batch compile
Workspace bindings (persistent_term)beamtalk_workspacePopulated at workspace startup

This means:

Supervision Strategy

Singleton actors are permanent workers under their owning supervisor:

beamtalk_runtime_sup (one_for_one)            [beamtalk_runtime app]
├── beamtalk_bootstrap
├── beamtalk_stdlib                            ← includes TranscriptStream class definition
├── beamtalk_object_instances
└── beamtalk_system_dictionary                 ← Beamtalk singleton

beamtalk_workspace_sup (one_for_one)          [beamtalk_workspace app]
├── beamtalk_workspace_meta
├── beamtalk_transcript_stream                 ← Transcript singleton instance (workspace only)
├── beamtalk_repl_server
├── beamtalk_actor_sup
└── ...

Crash recovery:

ScenarioBehavior
Transcript crashesSupervisor restarts. New process self-registers, updates persistent_term binding
Beamtalk (SystemDictionary) crashesSupervisor restarts. Class metadata reconstructed from beamtalk_object_class processes

Restarted processes update their own persistent_term binding in init/1 — no external monitoring needed.

Phase 1: Singleton Actor Classes

Phase 2: Workspace Binding Injection

Phase 3: Codegen Update

Phase 4: Migration (Completed)

Affected Components

Migration Path

Existing code using Transcript show: and Beamtalk allClasses continues to work unchanged in a workspace context. The syntax is identical; only the dispatch path changes internally.

Outside a workspace (beamtalk build), references to Transcript produce a clear compile-time error: "Transcript is a workspace binding, not available in batch compilation." This is a deliberate design choice — Transcript is a dev tool, not a production dependency.

The current transcript.erl module (from BT-328) serves as the initial implementation and will be refactored into beamtalk_transcript_stream.erl with actor dispatch.

Future Considerations

Repl as a First-Class Object

A Repl workspace binding representing the current REPL session would allow:

Transcript subscribe: Repl   // explicit subscriber (no implicit CallerPid)
Repl history                  // session history as messages, not :history command
Repl bindings                 // inspect current variable bindings

This aligns with the principle that behavior lives on objects, not REPL commands. Unlike Transcript and Beamtalk (workspace singletons), Repl would be per-session — each REPL connection gets its own binding. The codegen is unchanged (persistent_term lookup), but the binding is set per-eval-context rather than at workspace startup.

Workspace / Beamtalk Consolidation

As module-level imports mature, the Beamtalk object (SystemDictionary) and the Workspace concept may merge. The workspace already provides the bindings — it's a small step for it to also provide class lookup directly:

Object subclass: MyApp
  import: Transcript, Beamtalk    // today: two separate objects

// future: workspace IS the system dictionary
Object subclass: MyApp
  import: Workspace               // provides transcript, classNamed:, etc.

This suggests a future where:

A follow-up ADR should evaluate this consolidation when the module import system is in place.

Full Newspeak Lexical Scoping

Newspeak eliminates all imports by passing the platform at the entry point and using nested class lexical scoping for inner access. This is a significantly larger compiler effort (nested class scoping) and may not be warranted given the module import model. A separate discussion/ADR can evaluate this if module imports prove insufficient for capability isolation or testing.

References