ADR 0040: Workspace-Native REPL Commands, Facade/Dictionary Split, and Class-Based Reload

Status

Accepted | Implemented (2026-02-24)

Context

Problem

Beamtalk's REPL has 8 magic commands that bypass the language entirely:

CommandPurpose
:load / :lLoad file or directory
:reload / :rReload module (with hot code migration)
:modules / :mList loaded modules
:help / :hShow documentation
:bindings / :bList variable bindings
:clearClear bindings
:test / :tRun test classes
:show-codegen / :scDisplay generated Core Erlang

These commands are parsed in Rust (mod.rs:repl_loop), converted to JSON protocol messages, and dispatched to Erlang handler modules (beamtalk_repl_ops_*.erl). They exist outside the Beamtalk type system, are invisible to compiled code, cannot be composed with other expressions, and cannot be invoked from an LSP, test harness, or programmatic client without reimplementing the : prefix protocol.

This violates Principle 6 (Everything is a message send) and Principle 8 (Reflection as primitive). The REPL already migrated actor commands to Beamtalk-native message sends (ADR 0019: :actorsWorkspace actors, :killactorAt: kill, :unloadremoveFromSystem). The remaining commands should follow the same pattern.

The Beamtalk / Workspace Tension

Two singleton objects exist today:

BindingClassCurrent Scope
BeamtalkSystemDictionaryVM reflection: allClasses, classNamed:, globals, version
WorkspaceWorkspaceInterfaceActor introspection: actors, actorAt:, actorsOf:, sessions

Three problems with the current design:

  1. No home for REPL commands. Loading files, running tests, and querying the project namespace have no Beamtalk-native API.

  2. Beamtalk conflates facade and dictionary. Today Beamtalk is an instance of SystemDictionary — it is both the typed facade (allClasses, version) and the namespace container. In Pharo, this was recognized as a mistake and fixed post-2.0 by splitting SmalltalkImage (facade) from SystemDictionary (dictionary).

  3. File-based, not class-based. The current :reload and :modules commands think in terms of files and modules. But Beamtalk is a Smalltalk: the unit of code is the class, not the file. In Pharo, you reload a class (Counter recompile), not a file. Files are a transport mechanism.

Constraints

Decision

1. Facade/Dictionary Split

Separate the typed facade from the namespace dictionary. The facades (BeamtalkInterface, WorkspaceInterface) provide typed, discoverable methods. The globals accessor on each returns a plain Dictionary — the same class for both.

Pharo:
  Smalltalk         → SmalltalkImage   (facade: version, allClasses, snapshot)
  Smalltalk globals → SystemDictionary (dictionary: at:, keys, values)

Beamtalk:
  Beamtalk          → BeamtalkInterface      (facade: version, help:, allClasses)
  Beamtalk globals  → Dictionary             (dictionary: at:, keys, values)

  Workspace         → WorkspaceInterface   (facade: classes, actors, load:, test)
  Workspace globals → Dictionary             (dictionary: at:, keys, values)

The intelligence lives on the facade, not the dictionary. Beamtalk allClasses filters self globals values select: [:each | each isClass]. The dictionary itself is a plain Dictionary with no special behavior — same class for both Beamtalk globals and Workspace globals.

SystemDictionary as a separate class goes away. It was only special because it had class-aware methods — but those belong on the facade. What's left is just Dictionary.

2. Two-Object Model: Beamtalk (System) and Workspace (Project)

Beamtalk (class: BeamtalkInterface) — "What does the language know?"

The system facade. Classes, version, documentation. Read-only for user code.

CategoryMethods
Identityversion
ClassesallClasses, classNamed:
Documentationhelp: aClass, help: aClass selector: aSelector
DictionaryglobalsDictionary (system namespace: classes, system singletons)

allClasses and classNamed: are typed convenience methods that query the class registry directly (for liveness). globals returns an immutable snapshot of the system namespace for inspection. Both views contain the same classes, but allClasses is always up-to-date while globals is a point-in-time snapshot. (Class >> isClass already exists at stdlib/src/Class.bt:44.)

Workspace (class: WorkspaceInterface) — "What is my working context?"

The project facade. Loaded classes, actors, project operations. Mutable.

CategoryMethods
Code loadingload: aPath
Classesclasses, testClasses
Actorsactors, actorAt:, actorsOf:
Testingtest, test: aTestClass
DictionaryglobalsDictionary (project namespace: Transcript, loaded classes)

Implementation note (Phase 3): clear was dropped during implementation. Session-local variable bindings are a per-shell concern, not a workspace concern — and the only way to locate the correct session was via a process dictionary side-channel (bt_session_pid), making it inherently non-composable and broken under async dispatch. The :clear REPL shortcut remains as a direct shell operation. Additionally, testClasses, globals, test, test:, and actorsOf: are implemented as Beamtalk facades rather than Erlang primitives — they delegate to classes, TestRunner, and Behaviour>>includesBehaviour: respectively, reducing the primitive surface area in the gen_server.

Removed from current API:

3. Class-Based Reload on Behaviour

Reload is a class operation, not a workspace operation. When Workspace load: "examples/counter.bt" compiles a class, the runtime records the source file association on the class metadata (alongside doc comments per ADR 0033). The class then knows how to reload itself.

New methods on Behaviour (stdlib/src/Behaviour.bt):

MethodReturnDescription
sourceFileString or nilPath the class was compiled from; nil for stdlib/bootstrap or ClassBuilder-created classes
reloadselfRecompile from sourceFile, load new BEAM module; live actors pick up new code on next dispatch
Counter sourceFile          // => "examples/counter.bt"
Counter reload              // recompile + hot-swap

Integer sourceFile          // => nil (stdlib built-in)
Integer reload              // => Error: Integer has no source file — stdlib classes cannot be reloaded

This follows the Erlang/Elixir pattern:

Hot code swap semantics follow BEAM conventions: reload compiles the source file and loads the new BEAM module. Live actors running old code continue their current message handler; the next message dispatch uses the new code. This is standard BEAM two-version code loading — no custom state migration. (If state migration is needed in the future, it would use OTP's code_change/3 convention, but that is out of scope for this ADR.)

One class per file: Each .bt file defines exactly one class. Counter reload recompiles Counter sourceFile and loads the resulting BEAM module. This one-to-one mapping keeps file-system-backed images simple — the file is the class.

For dynamic classes created via ClassBuilder (ADR 0038): sourceFile => nil, reload returns an error. Dynamic classes exist as runtime objects with method closures — they have no source file. Structural reflection (help:, methods, allFieldNames) still works because it queries the live class object, not source text.

When modules/imports exist in the future, stdlib classes could track their .bt source paths and reload would just work.

4. Dictionary Chain Mental Model

Beamtalk's name resolution can be understood as a scoped resolution order inspired by GemStone/S's SymbolList:

Session locals (implicit)  →  Workspace user bindings  →  Workspace globals  →  Beamtalk globals
   x = 42                     MyTool = <actor>            Transcript = ...       Integer = <class>
   counter = #Actor<...>                                   Counter = <class>      String = <class>
                                                           project singletons     Object = <class>

Since BT-883, this is partially implemented: session startup walks Workspace globals (via beamtalk_workspace_interface_primitives:get_session_bindings/0) to inject singletons and bind:as: registered names into session bindings. Class names continue to resolve via beamtalk_class_registry (not injected into bindings). The model describes the effective resolution order that users experience:

  1. Session locals — per-connection variable bindings (x := 42), implicit scope
  2. Workspace user bindings — workspace-level bindings registered via bind:as:
  3. Workspace globals — project-level entries (Transcript, loaded classes, singletons)
  4. Beamtalk globals — system-level entries (all registered classes, version)

Session locals are implicit scope (like method-local variables) — not a named object. Workspace and Beamtalk are the two named facades with their backing dictionaries.

Important distinction from GemStone/S: GemStone's SymbolDictionaries are mutable containers that the system writes into. Beamtalk's Dictionary is immutable — globals returns a snapshot of the current namespace state, not a live mutable container. Beamtalk globals at: #Integer put: nil returns a new Dictionary; it does not mutate the system namespace. The source of truth for class registration is beamtalk_class_registry, not the globals dictionary. The facade's typed methods (allClasses, classNamed:) query the registry directly for liveness; globals provides a point-in-time snapshot for inspection and meta-programming.

5. Two Interfaces: Typed Methods and Dictionary Access

Following Anders Hejlsberg's principle — design for IntelliSense — the primary interface is typed methods: discoverable, completable, statically analyzable. The dictionary protocol (globals, at:) provides reflective access for meta-programming.

// Primary interface: typed methods (discoverable, completable)
Beamtalk allClasses
Beamtalk help: Counter
Beamtalk version
Workspace load: "examples/counter.bt"
Workspace classes
Workspace actors
Counter reload

// Reflective interface: dictionary access (meta-programming)
Beamtalk globals                    // => {#Integer: <class>, #String: <class>, ...}
Beamtalk globals at: #Integer       // => Integer class object

Workspace globals                   // => {#Transcript: <actor>, #Counter: <class>, ...}
Workspace globals at: #Transcript   // => TranscriptStream singleton

Both layers follow the same pattern: typed convenience methods for daily use, globals for when you need raw namespace access. The facades own the typing; the dictionaries are dumb containers.

REPL Session Examples

>> Workspace load: "examples/counter.bt"
Loaded: Counter (examples/counter.bt)

>> Workspace classes
Counter    examples/counter.bt

>> c := Counter spawn
#Actor<Counter,0.234.0>

>> Counter sourceFile
"examples/counter.bt"

>> Counter reload
Reloaded: Counter (new code loaded)

>> Workspace globals
{#Transcript: #Actor<TranscriptStream,0.90.0>,
 #Counter: Counter,
 #Beamtalk: #Actor<BeamtalkInterface,0.89.0>,
 #Workspace: #Actor<WorkspaceInterface,0.91.0>}

>> Beamtalk globals at: #Counter
Counter

>> Workspace test: CounterTest
CounterTest: 3 tests, 3 passed, 0 failed (12ms)

>> Beamtalk help: Counter
Counter : Actor
  getValue -> Integer
  increment -> Nil

>> Beamtalk help: Counter selector: #increment
increment -> Nil
  Defined in Counter (not inherited)

REPL Alias Mapping

: commands become thin wrappers. The Rust CLI translates them to eval requests:

REPL shortcutDesugars to
:load path/file.btWorkspace load: 'path/file.bt'
:reload(Workspace classes last) reload
:reload CounterCounter reload
:modulesWorkspace classes
:clear(stays REPL-only — session bindings are shell-scoped)
:testWorkspace test
:test CounterTestWorkspace test: CounterTest
:help CounterBeamtalk help: Counter
:help Counter incrementBeamtalk help: Counter selector: #increment

Note: :reload (no argument) and :modules are retained as REPL aliases for backward compatibility, but they desugar to the class-based API.

What Stays as REPL-Only Magic

CommandReason
:exit / :quit / :qClient-side concern — disconnects the TCP/WebSocket session
:show-codegen / :scTakes a raw expression, not a string — requires parser-level handling
:bindings / :bSession locals are implicit scope — no language-level object to query them. Like Erlang's b().

:show-codegen could become Beamtalk showCodegenFor: '1 + 2' in the future, but passing code as a string is awkward and loses static analysis. Deferring this to a future ADR on compiler-as-service APIs.

Error Examples

>> Workspace load: 42
Error: Workspace>>load: expects a String path, got Integer

>> Integer reload
Error: Integer has no source file — stdlib classes cannot be reloaded

>> (ClassBuilder new: #Dyn superclass: Object; register) reload
Error: Dyn has no source file — dynamic classes cannot be reloaded from source

>> Beamtalk help: #NoSuchClass
Error: Class 'NoSuchClass' not found. Use Beamtalk allClasses for available classes.

>> Beamtalk globals at: #Integer put: nil
{#Integer: nil, #String: <class>, ...}   // returns a NEW Dictionary — system unchanged
>> Beamtalk classNamed: #Integer
Integer                                   // still there — globals is a read-only snapshot

Prior Art

Pharo/Squeak Smalltalk

In modern Pharo (post-2.0), Smalltalk is a SmalltalkImage facade; the dictionary protocol (at:, at:put:) lives on Smalltalk globals (a SystemDictionary). The split exists because SmalltalkImage accumulated non-dictionary responsibilities (VM info, snapshots). Typed convenience methods (allClasses, version) live on the facade; raw dictionary access goes through globals.

SmalltalkImage provides: version, allClasses, classNamed:, hasClassNamed:, renameClassNamed:as:, removeClassNamed:, compiler, garbageCollect, snapshot:andQuit:, packages, organization. The facade filters and queries the dictionary; the dictionary (SystemDictionary) is "just" a Dictionary subclass with at:, keys, values.

Code operations are class-based, not file-based. Counter recompile recompiles all methods. Counter compile: 'foo ^ 42' adds a method. Counter removeFromSystem removes the class. Files exist only as serialization (Tonel format, Monticello .mcz). The Playground (workspace) has per-session bindings with no self, similar to our implicit session locals.

Beamtalk adopts the facade/dictionary split directly from Pharo's post-2.0 design, but with two facades instead of one (system + project).

GemStone/S

GemStone/S uses multiple SymbolDictionaries in an ordered chain (SymbolList). Each user has their own list: UserGlobals → project dictionaries → Globals → system. Name resolution walks the list; first match wins. The dictionary protocol (at:, at:put:) is on each individual SymbolDictionary — they are plain dictionaries with no special behavior.

GemStone/SBeamtalkScope
UserGlobalsSession locals (implicit)Per-connection
Project dictionariesWorkspace globalsPer-project
GlobalsBeamtalk globalsPer-VM

GemStone proved that a single SystemDictionary doesn't scale past a single-user image. Multiple scoped dictionaries with a resolution chain is the production Smalltalk answer. GemStone's dictionaries are plain mutable containers — the intelligence is in the SymbolList resolution, not in the dictionaries themselves. We adopt the structural principle (plain dictionaries, intelligence on the facade) but differ on mutability: Beamtalk's Dictionary is immutable, so globals returns a snapshot for inspection, not a live mutable namespace. The class registry remains the source of truth for writes.

Newspeak

No globals at all. Dependencies are injected via usingPlatform: factory methods. Reflection requires an explicit mirror capability (platform Mirrors reflect: obj). The IDE is a regular Newspeak application. Beamtalk's two-facade model is a pragmatic middle ground between Newspeak's purity (no globals, full DI) and Pharo's single global.

Elixir IEx

IEx helpers (c, r, l, h, i) are functions auto-imported from IEx.Helpers. They are thin wrappers over language-native APIs: c/1 wraps Code.compile_file/1, r/1 recompiles from recorded source via Code.get_compiler_option(:source), h/1 reads EEP-48 doc chunks via Code.fetch_docs/1. The language-level Code module works anywhere — not just in IEx. This is exactly the pattern we are adopting: shell shortcuts wrapping real APIs. Notably, r/1 takes a module (class), not a file — it's class-based reload.

Erlang Shell

Shell-internal commands (f(), v(), b()) manage shell state with no language equivalent — like our implicit session locals. Module:module_info(compile) records the source path at compile time. The c module's c:c(Module) uses this to recompile from source — class-based reload. This is the direct precedent for Counter reload using Counter sourceFile.

Anders Hejlsberg / TypeScript Principle

"The compiler is the language service" (Beamtalk Principle 12). Applied to API design: typed methods are the primary interface because they're discoverable via IntelliSense/tab-completion. Raw dictionary access (globals at:) exists for meta-programming but isn't the daily-use API. This is why we have both Beamtalk allClasses (typed, completable) and Beamtalk globals (reflective, dynamic) rather than forcing users to go through at: for everything. The facade owns the typing; the dictionary is dumb.

Summary

LanguageSystem ObjectProject/Session ObjectReload UnitDictionary
PharoSmalltalkImage facade + SystemDictionary globalsPlayground (UI tool)Class (recompile)On Smalltalk globals
GemStone/SGlobals dictionaryUserGlobals, project dictsClass/methodPlain SymbolDictionary
Newspeakplatform (injected)None — modules are classesN/AN/A (no globals)
ElixirCode, System modulesN/AModule (r/1)N/A (module functions)
Erlangerlang, code modulesN/AModule (c:c/1)N/A (module functions)
BeamtalkBeamtalkInterface facade + Dictionary globalsWorkspaceInterface facade + Dictionary globalsClass (reload)Plain Dictionary on both

User Impact

Newcomer (from Python/JS): Workspace load: 'file.bt' reads naturally and is discoverable — tab-completing Workspace reveals all project operations. Counter reload is intuitive: "reload that class." The :load shortcut still works for quick use. Error messages guide toward the class-based API. No need to learn a separate command language.

Smalltalk developer: Everything is now a message send, consistent with Principle 6. The facade/dictionary split mirrors Pharo's SmalltalkImage / SystemDictionary design. GemStone/S developers will recognize the dictionary chain pattern. Beamtalk globals is the direct analog of Smalltalk globals, and both are plain dictionaries. Class-based reload (Counter reload) feels natural — it's how Pharo works. The two-facade split with typed convenience methods is more structured than Pharo's single god-object.

Erlang/BEAM developer: Clean mapping to the c module pattern — Workspace load: is like c:c/1. Counter sourceFile mirrors Module:module_info(compile) → source. Counter reload follows the same pattern as Elixir's r(Module). The API is callable from compiled code, scripts, and remote REPL sessions. Beamtalk help: wraps EEP-48 doc chunks, same as Erlang's h/1.

Production operator: Workspace classes and Workspace actors are callable from a remote REPL or programmatic client — no : prefix protocol needed. Workspace globals provides a machine-readable view of project state. Class-based reload means operators can hot-swap specific classes without reloading entire files.

Tooling developer: LSP can provide completions for Workspace load:, Beamtalk help:, Counter reload — typed methods are statically analyzable. The facade/dictionary split means tooling resolves typed methods through normal dispatch, and globals through dictionary protocol. No need to parse : command syntax separately.

Steelman Analysis

Alternative B: Three Named Dictionaries (Beamtalk + Workspace + Session)

CohortStrongest argument
Newcomer"Session globals makes it clear these are my variables. I know x := 42 goes somewhere specific, not into the shared project."
Smalltalk purist"GemStone has UserGlobals as an explicit, named dictionary. Session state IS different from project state. Making it implicit hides a real architectural boundary."
BEAM veteran"Each REPL session is a process. Session state is per-process. Making Session explicit maps directly to the BEAM process model — Session wraps beamtalk_repl_shell."
Language designer"Three named dictionaries with explicit resolution chain is the most honest model. But implicit session locals are simpler and locals-are-implicit is universal across programming languages."

Alternative C: Single Workspace Dictionary

CohortStrongest argument
Newcomer"One object! I just type Workspace and explore everything. Workspace globals shows me the whole world."
Smalltalk purist"Pharo has Smalltalk as one global" — but GemStone/S proved multiple dictionaries scale better, and Pharo itself split SmalltalkImage from SystemDictionary. Weak argument.
BEAM veteran"Erlang's c module puts everything in one place for the shell. Pragmatic and discoverable."
Language designer"Simplest surface area. But conflating VM identity with project state means Workspace version and Workspace load: are categorically different. A god-dictionary."

Alternative D: Keep SystemDictionary as Special Class

CohortStrongest argument
Smalltalk purist"SystemDictionary IS the tradition. Pharo still has it. GemStone has it. Removing it loses a well-known concept."
Language designer"Having class-aware methods on the dictionary makes the dictionary self-describing. globals allClasses is more natural than facade allClasses."

Alternative E: Dictionary-Only (no typed methods)

CohortStrongest argument
Newcomer"One protocol to learn — at: everywhere. Uniform."
Smalltalk purist"Pure dictionary protocol. GemStone-native. Smalltalk-idiomatic. No facade methods needed."
BEAM veteran"Maps directly to Erlang's maps:get/2. Simple, predictable."
Language designer"Maximally uniform. But you lose discoverability — Workspace at: #load tells you nothing about the parameter type. Anders would not approve."

Tension Points

Alternatives Considered

Alternative B: Three Named Dictionaries

Add an explicit Session object wrapping per-connection state:

Session globals               // => {#x: 42, #counter: #Actor<...>}
Workspace globals             // => {#Transcript: <actor>, ...}
Beamtalk globals              // => {#Integer: <class>, ...}

Rejected because: Session-local variables are implicit scope — like method-local variables. No language makes you write locals at: #x to access a local variable. Making session state implicit matches universal programming language convention. If multi-session isolation becomes important later, Session can be introduced without breaking the model.

Alternative C: Single Object (merge Beamtalk into Workspace)

One facade with all operations:

Workspace allClasses
Workspace load: 'f'
Workspace version
Workspace globals              // => everything in one dict

Rejected because: God object mixing categorically different concerns. Workspace version is about the VM; Workspace load: is about the project. GemStone/S demonstrated that a single dictionary doesn't scale. Pharo split SmalltalkImage from SystemDictionary for the same reason.

Alternative D: Keep SystemDictionary as Special Dictionary Class

Keep globals returning a SystemDictionary (subclass of Dictionary with allClasses, classNamed:, etc.) instead of a plain Dictionary:

Beamtalk globals allClasses          // class-aware method on the dictionary
Beamtalk globals classNamed: #Counter // instead of at: + isClass filter

Rejected because: The intelligence belongs on the facade, not the dictionary. GemStone/S's SymbolDictionaries are plain containers — the system-awareness is in the resolution chain, not the dictionary. Having the same plain Dictionary class for both Beamtalk globals and Workspace globals is simpler and avoids the question of what class Workspace globals would be. The facade already provides allClasses and classNamed: as typed convenience methods.

Alternative E: Auto-Imported REPL Helper (Erlang user_default Pattern)

Define a ReplHelper class whose methods are auto-imported into REPL scope:

load: "examples/counter.bt"    // bare call, auto-imported from ReplHelper
help: Counter                   // bare call

Rejected because: Auto-imported bare calls look like method calls on self, not on a specific object. This harms discoverability — load: doesn't reveal where the capability lives. It also creates flat namespace collision risk. However, this pattern could complement the facade model in the future.

Alternative F: Keep REPL Magic, No Language-Native API (Status Quo)

Leave :load, :help, etc. as REPL-only commands.

Rejected because: Violates Principle 6 (everything is a message send) and Principle 8 (reflection as primitive). Compiled code, LSP tooling, and programmatic clients cannot access these operations. The migration pattern from ADR 0019 Phase 3 already demonstrated the right direction.

Alternative G: Dictionary-Only (no typed methods)

Use at: as the primary interface, no typed convenience methods:

Beamtalk at: #Integer          // instead of classNamed:
Workspace at: #Transcript      // instead of... what?

Rejected because: Violates the Anders/IntelliSense principle (Beamtalk Principle 12). Workspace load: 'foo.bt' is discoverable, type-checkable, and self-documenting. Workspace at: #load is opaque — the tooling cannot help you. Typed methods for daily use, globals for meta-programming.

Alternative H: File-Based Reload on Workspace

Keep Workspace reload and Workspace reload: Counter instead of Counter reload:

Workspace reload                     // reload last loaded file
Workspace reload: Counter            // reload the file Counter came from

Rejected because: This is file-centric thinking. In Smalltalk, the class is the unit of code, not the file. A file may define multiple classes. Putting reload on Behaviour means the class knows its own source — following the Erlang pattern (Module:module_info(compile) → source) and the Elixir pattern (r(Module)). This also means ClassBuilder-created dynamic classes naturally respond with "no source file" rather than Workspace needing to handle that case.

Consequences

Positive

Negative

Neutral

Implementation

Phase 1: Rename SystemDictionary to BeamtalkInterface, globals returns Dictionary

Rename the class from SystemDictionary to BeamtalkInterface. Change globals to return a plain Dictionary populated from the class registry. Existing methods (allClasses, classNamed:, version) stay but are now understood as facade convenience methods.

Affected files:

Phase 2: Add sourceFile and reload to Behaviour

Add sourceFile and reload methods to Behaviour. Implement backing primitives that record source paths at compile time and trigger recompilation + hot code swap.

Affected files:

Phase 3: Expand WorkspaceInterface with project operations

Rename WorkspaceEnvironment to WorkspaceInterface (matching BeamtalkInterface naming convention). Add classes, testClasses, globals, load:, test, test:. Remove sessions. testClasses, globals, test, test:, and actorsOf: are implemented as Beamtalk facades rather than primitives. clear was dropped (see implementation note above). The globals method returns a plain Dictionary.

Implement backing primitives in beamtalk_workspace_environment.erl (rename to beamtalk_workspace_interface.erl), extracting logic from existing beamtalk_repl_ops_load.erl and beamtalk_repl_ops_eval.erl.

Affected files:

Phase 4: Add documentation API to BeamtalkInterface

Add help: and help:selector: to BeamtalkInterface.

Affected files:

Phase 5: Convert REPL : commands to eval aliases

Change the Rust CLI to translate :load path into Workspace load: 'path' and send it as a normal eval request. :reload Counter becomes Counter reload. :modules becomes Workspace classes.

Implementation note — string escaping: The alias translation must properly escape path arguments. For double-quoted strings, escape backslashes and quotes (e.g., path.replace("\\", "\\\\").replace("\"", "\\\"")).

Affected files:

Phase 6: Deprecate custom protocol ops and add tests

Mark dedicated protocol operations ("load-file", "reload", "modules", "bindings", "clear", "docs", "test") as deprecated. Keep them working for backward compatibility with WebSocket clients (ADR 0017), routing through the new Beamtalk-native implementations.

Add BUnit tests for all new facade and Behaviour methods. Add e2e tests exercising the message-send forms alongside : aliases. Update documentation.

Affected files:

Affected components summary:

ComponentChange
stdlib/src/SystemDictionary.btRename to BeamtalkInterface.bt
stdlib/src/WorkspaceInterface.btAdd classes, testClasses, globals, load:, test, test:; remove sessions; clear dropped
stdlib/src/Behaviour.btAdd sourceFile, reload
beamtalk_system_dictionary.erlRename to beamtalk_interface.erl; add help:, help:selector:
beamtalk_workspace_environment.erlNew primitive handlers for project ops + globals
beamtalk_behaviour_intrinsics.erlNew primitives for sourceFile, reload
beamtalk_object_class.erlStore source file in class metadata
beamtalk_repl_ops_load.erlRecord source file; extract shared logic
beamtalk_repl_ops_dev.erlExtract doc logic
beamtalk_repl_ops_eval.erl(clear logic not extracted — clear was dropped)
mod.rs (REPL loop)Translate : commands to eval of message sends
beamtalk_repl_server.erlDeprecation routing for old protocol ops

Migration Path

For REPL Users

No breaking changes. All : commands continue to work as aliases:

BeforeAfter (also works)Shortcut still works?
:syncWorkspace syncYes
:load examples/counter.btWorkspace load: "examples/counter.bt"Yes
:reloadCounter reload (class-based)Yes (desugars to last-loaded class)
:reload CounterCounter reloadYes
:modulesWorkspace classesYes
:help CounterBeamtalk help: CounterYes
:bindings(stays REPL-only — session locals are implicit)Yes
:clear(stays REPL-only — session bindings are shell-scoped)Yes
:test CounterTestWorkspace test: CounterTestYes

For SystemDictionary Users

SystemDictionary is renamed to BeamtalkInterface. Any code referencing SystemDictionary by name must update to BeamtalkInterface. The Beamtalk binding continues to work — its type changes but its API is a superset.

For WebSocket/Protocol Clients

Dedicated protocol ops ("load-file", "docs", etc.) continue to work but are deprecated. Clients should migrate to sending eval requests with the Beamtalk expressions. The protocol wire format is unchanged.

For Compiled Code

New capability — compiled code can now invoke workspace operations, reload classes, and inspect namespaces:

Actor subclass: BuildScript
  run =>
    Workspace load: 'src/'.
    Workspace test.
    Transcript show: 'Build complete'.
    Transcript show: (Beamtalk allClasses size) printString; show: ' classes loaded'

References

Amendment: Mutable Workspace Globals — bind:as: / unbind: (BT-881)

Date: 2026-02-25

Context

ADR-40 established Workspace globals as a read-only snapshot with no write path. In Smalltalk (Pharo/GemStone), globals are read/write — Smalltalk globals at: #X put: y or UserGlobals at: #X put: y are standard patterns. Beamtalk needed an explicit, typed write path for registering named objects into the workspace namespace.

Decision

Add bind:as: and unbind: methods to WorkspaceInterface:

Workspace bind: myActor as: #MyTool     // registers, with checks
Workspace unbind: #MyTool               // removes, errors if not found
Workspace globals                        // R/O snapshot now includes user bindings
Workspace globals at: #MyTool           // read a registered value

Design choices:

Name resolution order (updated from Section 4):

Session locals (implicit)  →  Workspace user bindings  →  Workspace globals  →  Beamtalk globals
   x = 42                     MyTool = <actor>            Transcript = ...       Integer = <class>
   counter = #Actor<...>                                   Counter = <class>      String = <class>

Session locals override workspace user bindings, which override workspace globals and Beamtalk globals. The codegen's maps:find(Name, State) lookup handles this naturally since workspace user bindings are merged into the session bindings map before each eval.

Implementation