ADR 0129: Replace Injected Beamtalk/Workspace/Transcript Bindings with Class-Side Facades

Status

Accepted (2026-09-25)

Amends ADRs 0010, 0019, 0040, 0081 and 0125 (see Amended ADRs). Settles BT-3632 (§6).

Implementation Tracking

Epic: BT-3636 Issues:

Related follow-ups (outside the epic): BT-3633 (node introspection → Node), BT-3634 (Program exit: outside run mode), BT-3635 (File handle ownership; blocks Phase 0b). Status: Planned

Context

Problem

Beamtalk, Workspace and Transcript look like class names but are not classes. Each is a binding name for a singleton instance of another class: BeamtalkInterface, WorkspaceInterface and TranscriptStream. The workspace creates those instances at boot and stores each one in a current class variable. The REPL resolves the short names to them. Whether any of this works depends on the boot context the code runs in.

This surfaced after BT-3622 let classNamed: take a dynamic symbol. beamtalk-exdura tried to replace its untyped FFI call (Erlang beamtalk_interface) findClass: aName in ExduraHttpServer>>resolveWorkflowClassNames: with a typed one. Every spelling fails:

SpellingResult
Beamtalk classNamed: aNameA REPL-only binding. Compiled code gets an "Unresolved class" warning and a runtime class_not_found.
BeamtalkInterface classNamed: aNameclassNamed: is instance-side: DNU. The type checker infers Dynamic and stays silent.
BeamtalkInterface new classNamed: aName"Object-kind class BeamtalkInterface cannot be instantiated with new".
BeamtalkInterface current classNamed: aNameWorks in the REPL. Under beamtalk test it raises UndefinedObject does not understand 'classNamed:'.

current is set only by beamtalk_workspace_bootstrap, which beamtalk_workspace_sup starts in run, workspace and release modes (ADR 0125). beamtalk test starts only beamtalk_stdlib, so current is nil there.

The bug class: "works in the REPL, breaks elsewhere"

The same identifier resolves three ways, depending on where the code was compiled:

Where the code runsWhat Transcript show: x compiles toOutcome
REPL expressionSession locals, then beamtalk_workspace:resolve_singleton_instance/1 (generate_binding_aware_class_send)Works
Method body of a class loaded into the REPLwhereis_class('Transcript'), undefined → nil (generate_workspace_class_send)Silently nil; prints nothing
Batch compile (beamtalk build, beamtalk test)whereis_class → class_send(undefined, …)"Unresolved class" warning, then class_not_found

The codebase works around this:

Current mechanics

About 35 sites in the runtime and compiler exist only to make these three names resolve:

Why the Smalltalk model doesn't fit

In Pharo, Smalltalk is the SystemDictionary of a single live image. It holds classes and globals, and every free identifier in all code binds to an entry in it. This works because every line is compiled and run inside that one image.

Beamtalk has no image. It compiles ahead of time to BEAM modules that run in several boot contexts: the REPL workspace, beamtalk run or an escript, beamtalk test, and an OTP release. Each context starts a different set of applications. A name that one context injects is missing in the others.

Beamtalk also has two things called "globals", and neither is Pharo's:

The rest of the stdlib already uses the shape this ADR adopts. System, File, Console, Logger and Erlang are class-side APIs with no singleton, and they behave the same everywhere.

The original arguments for injection no longer apply:

Constraints

  1. Class-side dispatch.
    • By default, a class-side method runs in its class's gen_server. So process-local context is lost, all callers queue on one process, class_send has 60 s / 5 min timeouts, and a re-entrant send raises dispatch_error.
    • Codegen emits a direct function call instead when three conditions hold: the class is sealed, it has no class variables, and the method is class sealed (compute_direct_call_eligible, driver.rs).
    • That direct path is applied only to statically resolved sends in compiled modules. REPL expression codegen (crates/beamtalk-repl/src/codegen.rs) does not compute eligibility, and dynamic sends (beamtalk_class_dispatch:class_send/3) always use the gen_server.
  2. Dependencies flow downward.
    • beamtalk_stdlib depends on beamtalk_runtime, not on beamtalk_workspace.
    • Under beamtalk test, the workspace application's code is on the load path, but none of its processes run: compiler server, loader, ChangeLog, session supervisor.
    • So "is a workspace running?" cannot be answered by checking whether its code is loaded.
  3. Refusals have one home. beamtalk_capability (ADR 0125 §1.4–1.5) answers "is this operation available on this node?". Every refusal it issues is a #beamtalk_error{}.
  4. One user. The only external consumer is beamtalk-exdura, which has the same owner. A hard break with no shims is acceptable.

Decision

Guiding principle: Smalltalk's vocabulary, not the image's mechanics

Beamtalk is Smalltalk-like, not Smalltalk-compatible (docs/beamtalk-principles.md):

This principle is added to docs/beamtalk-principles.md §4 and to the essential rules in CLAUDE.md.

Summary

The compiler and runtime inject no names. Beamtalk, Workspace and Transcript become ordinary sealed, stateless stdlib classes with class-side APIs. SystemNavigation follows the same rule. Identifiers resolve the same way in REPL expressions, method bodies, beamtalk build, beamtalk test, beamtalk run and releases.

NameClassWrapsOutside a workspace
Beamtalkrenamed from BeamtalkInterfaceClass registry, help:, version, release info, logging configWorks everywhere
Workspacerenamed from WorkspaceInterfaceSessions, bindings, ChangeLog, load:, flush, supervisors, testsRaises no_workspace
TranscriptnewThe REPL's TranscriptStream processRoutes output through Logger

1. How the stdlib exposes system services

Every stdlib class that exposes a system service uses exactly one of three shapes:

  1. Class-side facade. For a stateless service with a single scope.
    • A sealed class with class-side methods only.
    • No classState:; the class is never instantiated.
    • Examples: Beamtalk, Workspace, Transcript, SystemNavigation, System, File, Console, Logger.
  2. default and named factories. Only for classes whose instances carry state or scope, where default selects one scope among several.
    • Examples: ProcessNavigation default / system / from: / on:; AnnouncementNavigation default / of:; Node named: (ADR 0126).
  3. current. Only for a genuinely live runtime entity.
    • It is found by asking the runtime at call time.
    • It returns | Nil only when no such entity legitimately exists.
    • Examples: Session current (none outside a REPL session), SystemAnnouncer current, Supervisor current.

Forbidden: a singleton stored in a class variable by bootstrap code.

2. Properties every facade has

3. Beamtalk: system reflection, everywhere

BeamtalkInterface becomes Beamtalk. Its methods become class sealed with unchanged selectors and types:

The logging-control selectors (logLevel, logLevel:, logFormat, logFormat:, debugTargets, enableDebug:, disableDebug:, activeDebugTargets, disableAllDebug, loggerInfo) do not stay on Beamtalk. They moved to class-side Logger with unchanged selectors and no shim (BT-3653), and the LogLevel and LogFormat type aliases moved with them. ADR 0064 Alternative E rejected a separate LogConfig singleton; a class-side Logger is a sealed, stateless class-side facade (§2), not a singleton, so that rejection does not apply. None of these methods needs a workspace.

Beamtalk globals is removed, with beamtalk_interface:globals/0 and handle_globals/0. Use Beamtalk classNamed:, Beamtalk allClasses or SystemNavigation instead.

// exdura, compiled package
resolveWorkflowClassNames: names :: List(Symbol) -> List(Class) =>
  names collect: [:aName |
    (Beamtalk classNamed: aName) ifNil: [
      self error: "unknown workflow class " ++ aName printString]]
Beamtalk classNamed: #Integer      // => Integer
Beamtalk classNamed: #NoSuchThing  // => nil
Beamtalk version                   // => "0.x.y"

4. Workspace: project operations where a workspace runs

WorkspaceInterface becomes Workspace. Its methods become class sealed with unchanged selectors and types, except that globals becomes bindings (§7) and isAvailable is new:

A workspace is running once beamtalk_workspace_sup has started and recorded the node's capabilities. That happens in run, workspace and release modes.

Outside a workspace (under beamtalk test, or on a bare runtime), every method except isAvailable raises:

#beamtalk_error{
  kind     = no_workspace,
  class    = 'Workspace',
  selector = 'load:',
  message  = <<"Workspace>>load: needs a running workspace; none is running on this node">>,
  hint     = <<"Workspace operations are available in the REPL (beamtalk repl), "
               "beamtalk run and releases, not under beamtalk test. "
               "For class lookup use Beamtalk classNamed:.">>
}

Workspace isAvailable -> Boolean never raises. A test class can run both under beamtalk test and inside a workspace (Workspace test, REPL :test, MCP run_tests). So a test of the refusal states its context:

testWorkspaceRefusesWithoutWorkspace =>
  Workspace isAvailable ifTrue: [^self skip: "runs only outside a workspace"]
  self should: [Workspace classes] raise: #no_workspace

Availability is decided in beamtalk_capability:

5. Transcript: the REPL's shared log and a day-0 convenience

Transcript exists for newcomers and REPL use. Programs use Logger (ADR 0064), or Console for plain stdout/stderr. Transcript has no stream protocol, no capture API and no instance. The language guide, the class comment and the beamtalk new template all say this.

SelectorIn an interactive workspaceElsewhere
show: valueAppends value printString (a String as-is) to the TranscriptStreamOne Logger notice event, domain [beamtalk, user, transcript]
crAppends a newlineNo-op
showCr: valueshow: then crOne Logger event
recentThe buffered linesno_workspace
clearEmpties the bufferno_workspace
// REPL
Transcript show: "Hello"; cr; show: "World"   // shown in the transcript pane
Transcript recent                             // => #("Hello", "World")
// compiled code under beamtalk run
Transcript showCr: "starting"    // prints: starting
Transcript recent                // raises no_workspace

6. SystemNavigation is a class-side facade; Object-kind classes are never instantiated

SystemNavigation is a stateless service over the node's single class registry, so it follows rule 1. Every query becomes class sealed, and default is removed:

SystemNavigation sendersOf: #printString
SystemNavigation implementorsOf: #asString
SystemNavigation actorClasses

This settles BT-3632:

7. Name resolution and Workspace bindings

A free identifier in a REPL expression resolves through:

  1. session locals;
  2. Workspace bind:as: entries;
  3. the class registry.

Method bodies and batch-compiled code use only the class registry. The three names therefore resolve exactly as Integer does, which is the rule ADR 0081 already applies to Session.

Workspace globals is renamed Workspace bindings.

Workspace bind: 42 as: #answer
Workspace bindings                // => a BindingsView {#answer => 42}
Workspace bindings at: #answer    // => 42

Protecting class names.

8. Distribution: facades are node-local

Facades act on the node that runs the code. That matches ADR 0126, where a class object that crosses nodes is resolved by name on the receiving node, so every class name means "this node's class". It also matches Erlang, where code: and logger: are node-local and remote access is an explicit erpc:call/4.

Node kindBeamtalkWorkspaceTranscript
Workspace (REPL)Its registryIts workspaceIts TranscriptStream
run / escriptIts registryIts run-mode workspace (§4)Its Logger
ReleaseIts registryPartially available per ADR 0125 §1.5: introspection such as actors/sessions works; compiler ops need include_compiler; flush/rename are refusedIts Logger, or its TranscriptStream when the console runs
Bare runtime (e.g. beamtalk test)Its registryno_workspaceIts Logger

9. Removed names

No shims or deprecation period:

Code that uses a removed name gets the standard class_not_found or DNU, with a hint naming the replacement.

10. Related context-dependent behaviour

Other places where behaviour depends on boot context are resolved as follows.

Fixed by this ADR:

BehaviourResolution
A class body loaded into the REPL turns any missing class into nil (generate_workspace_class_send). Batch-compiled code raises class_not_found.That path is deleted (Phase 4). Both raise class_not_found.
beamtalk_capability reports workspace capabilities on nodes that never started a workspace, so every capability guard passes under beamtalk test.require_workspace/1 (§4).
Behaviour workspace operations detect a missing workspace by catching error:undef. Under test the code is loaded, so they fail later with compile_failed {noproc…}.Guarded by require_workspace/1 (§4).

Separate follow-ups:

Context-bound by design, and already conforming:

Misuse and error examples

Beamtalk new                        // compile error: Object-kind class `Beamtalk` cannot be instantiated with `new`
BeamtalkInterface current           // class_not_found: BeamtalkInterface. Hint: renamed to Beamtalk (ADR 0129)
Workspace changes                   // under beamtalk test: #beamtalk_error{kind: no_workspace, selector: 'changes', …}
Workspace bind: 3 as: #Transcript   // name_conflict: Transcript is a stdlib class and cannot be shadowed
Beamtalk classNamd: #Foo            // unknown-selector warning at compile time; DNU at runtime
SystemNavigation default            // DNU. Hint: SystemNavigation queries are class-side (ADR 0129)

Prior Art

LanguageHow system-level objects are reachedTakeaway
Pharo / SqueakSmalltalk (a SystemDictionary) and Transcript (a ThreadSafeTranscript) are image globals; all code is compiled inside the image.Works only with one image and in-image compilation. Beamtalk has neither.
GNU SmalltalkThe same globals, plus a scripting mode where Transcript flushes to stdout.Even Smalltalk needs a non-interactive fallback for Transcript. Ours is Logger.
NewspeakNo globals. The platform object (transcript, mirrors) is passed to the module constructor.Explicit capabilities. It needs a module-parameter system that Beamtalk lacks (ADR 0010: not planned).
ErlangModule functions: code:which/1, logger:notice/1, io:format/1. Node-local, available in every node type.Exactly what a sealed, stateless class-side facade compiles to.
ElixirCode, Logger, IO and System work everywhere. IEx helpers (h/1, recompile/0) are imported only into the shell. Mix.Project raises when Mix isn't running.Shell-only helpers stay out of compiled code. A context-bound service raises clearly, as Workspace does with no_workspace.
Gleamio.println; no ambient objects.A BEAM language works without globals.
Python / IPythonsys and logging are ordinary modules. IPython injects get_ipython(), display() and magics into the shell only; code copied into a .py file fails with NameError.The same "works in the REPL, breaks in the script" trap.
Livebook / KinoKino functions are ordinary module calls that degrade outside a Livebook runtime.A facade that adapts to its context, like Transcript routing to Logger.
Ponyenv.out is a capability passed to Main.Explicit, but it must be threaded through everywhere; ADR 0058 rejected capability restriction.

User Impact

Newcomer.

Smalltalk developer.

Erlang/Elixir developer.

Production operator.

Tooling developer.

Steelman Analysis

A. Status quo

B. Keep injection; initialise current at runtime startup

C. Extend the injected bindings into compiled code

D. Class-side facades (chosen)

E. Smalltalk-style globals: ADR 0081's resolver in compiled code

F. Keep the old class names; add class-side methods and aliases

G. Beamtalk facade only; Workspace stays REPL-scoped

Tension points

Alternatives Considered

A. Status quo

Leaves silent nil in REPL-loaded method bodies, keeps exdura on untyped FFI, and limits live facade testing to the REPL e2e suite.

B. Keep injection; initialise current at runtime startup

Fixes Beamtalk only; Workspace has no instance to set outside a workspace. Two spellings remain per concept, current stays | Nil, and all the injection machinery stays.

C. Extend injected bindings into compiled code

Makes a runtime-resolved, Dynamic-typed name a permanent language feature. Every send becomes a runtime lookup, and the lookup still yields nil where no workspace runs.

E. Smalltalk-style globals

Extend the REPL resolver (bind:as:, singletons, class registry) to every free capitalised identifier in compiled code, and populate it in every boot context.

F. Keep the old names, with aliases

Beamtalk has no class aliases. ADR 0019 rejected names that "look like class names but aren't". It leaves two names per concept.

G. Beamtalk facade only

Transcript alternatives

Consequences

Positive

Negative

Neutral

Implementation

The work is one epic; main stays green after each phase.

Phase 0a: direct calls from REPL expressions (codegen, S)

Phase 0b: direct dispatch for dynamic class sends (codegen + runtime, M)

Phase 1: Beamtalk (stdlib, S–M)

Phase 2: Workspace (stdlib + runtime, M)

Phase 3: Transcript (stdlib + runtime, M)

Phase 4: remove injection (compiler + runtime, L)

Phase 5: docs, examples and templates (M)

Phase 6: SystemNavigation + BT-3632 (S–M)

Single source of truth.

Migration Path

BeforeAfter
BeamtalkInterface current <sel> / REPL Beamtalk <sel>Beamtalk <sel>
WorkspaceInterface current <sel> / REPL Workspace <sel>Workspace <sel>
TranscriptStream current show: x / REPL Transcript show: xTranscript show: x; Logger or Console in programs
(Erlang beamtalk_interface) findClass: nBeamtalk classNamed: n
Test setUp swapping TranscriptStream current:Assert on return values, or configure a Logger handler on [beamtalk, user, transcript]
Transcript showLine: xTranscript showCr: x
Beamtalk globalsBeamtalk classNamed:, Beamtalk allClasses, SystemNavigation …
Workspace globalsWorkspace bindings
SystemNavigation default sendersOf: #xSystemNavigation sendersOf: #x
self new in a class method of an Object subclass:A class-side API, or Value subclass:

REPL users type what they typed before. beamtalk-exdura switches resolveWorkflowClassNames: to Beamtalk classNamed: once Phase 1 ships.

Amendment (BT-3633): homes for the ADR 0040 method sets

ADR 0040's WorkspaceInterface held selectors that are facts about a BEAM node or the running program, not about the development workspace. Under §1 (a service lives where its scope lives) and §8 (facades are node-local; the instance carries the node), they move. The Workspace facade keeps the development-loop operations only.

Rule. Reads of node state go to Node. Facts about the running program go to Program. Declared manifest facts go to Package. Development-loop operations stay on Workspace.

ADR 0040 selectorNew homeNotes
Workspace actors / actorsOf: / actorAt:aNode actors / actorsOf: / actorAt:Result(…, Error) on every receiver, Node current included; a peer is queried via erpc (ADR 0126 §5.1, §7). Backed by beamtalk_actor_registry, now started by beamtalk_runtime_sup, so it holds in every boot context. Every actor tracks itself from its lifecycle-start telemetry; the REPL spawn path's explicit registration is idempotent.
Workspace processesaNode processesResult(SupervisionTree, Error), the default-scope tree (ADR 0092), == (ProcessNavigation on: aNode) unwrap tree.
Workspace supervisorsaNode supervisorsRoot application supervisor plus workspace-attached ones.
Workspace nodesNode connected + aNode shapeSkewshapeSkew is a per-node accessor on the queryable skew tally. connectedWithSkew is removed; the MCP nodes tool and the LiveView nodes op map shapeSkew over Node connected.
Workspace supervisorProgram rootSupervisorbeamtalk_supervisor:get_root/0. The registry table is created by beamtalk_runtime's application start, so REPL, run, service and release all answer without a workspace; nil when no [application] has started. A node has one root slot and only the root package gets an app callback; Program is chosen over Node for semantics and future multi-app releases, not to fix a bug.
(new)Package supervisorClassReads the {supervisor, 'X'} entry beamtalk build now writes into the .app env for every package with an [application] section, path dependencies included. Class or nil.
Workspace startSupervisor: / stopSupervisor:stay on WorkspaceThey start the supervisor as a child of beamtalk_workspace_sup, so they are workspace-only.

aNode actors and Actor allRegistered / allRegisteredOn: are not duplicates. The former lists every live actor; the latter list only name-registered actors (Actor named:). Their doc comments cross-reference each other. Node stays a stateless sealed Value with no class state (§2). No shims are left behind (§9): the removed Workspace selectors raise does_not_understand.

Logging control (BT-3653). Beamtalk's logging selectors move to a class-side Logger facade. ADR 0064's Alternative E rejected a singleton logger object (an injected instance with mutable state). A stateless, sealed, class-side facade over the runtime's own logger configuration is a different thing and satisfies §1 and §2, so that rejection does not apply.

Amended ADRs

References