ADR 0082: Method-Level Edit and Save in the Live Workspace

Status

Proposed (2026-05-17)

Context

Problem

The runtime already supports live, in-memory method patching via the >> operator (principle #11 in docs/beamtalk-principles.md, ADR 0066 for the syntax). A browser-based IDE (ADR 0017 Phase 3, the Phoenix LiveView upgrade) needs an additional capability that does not exist today: a user-driven save action on a single method that travels back through the runtime and reaches the on-disk .bt file in a controlled, observable way.

We do not have a path for this. Today:

Without an explicit decision, any browser "Save" button has to choose silently between (a) memory-only patching that disappears on workspace restart, (b) write-through that mutates the user's git tree on every keystroke save, or (c) something in between. Each choice has implications for ADR 0004 (memory-only hot reload), ADR 0017 (browser IDE), ADR 0024 (LSP/runtime coherence), and ADR 0046 (VSCode coexistence).

Current State

ConcernToday
Live method patchCounter >> increment => self.value := self.value + 1 — works, memory-only
Source file associationCounter sourceFile // => "examples/counter.bt" — tracked per class
Whole-class reload from diskCounter reload — works
Whole-class load from stringload-source REPL op — works, memory-only
Single-method save to diskDoes not exist
Per-method dirty trackingDoes not exist
Undo / ChangeLogDoes not exist
External-edit detectionDoes not exist
LSP write coordinationDoes not exist (LSP currently only reads)

Constraints

  1. ADR 0004 is explicit: "Hot reloaded code is memory-only. If the node restarts, it loads code from disk (release files), not the hot-reloaded version. This is a fundamental BEAM characteristic." Any persistence decision here must reconcile with that contract.
  2. Principle #5 ("Code Lives in Files") declares the filesystem the source of truth and says: "compiler reads from filesystem; tooling writes changes back to files." Tooling writing back is sanctioned; the runtime unilaterally mutating user source files is not.
  3. Principle #12 ("Compiler is the Language Service") mandates a rich AST with trivia preservation. We have the substrate for non-destructive splice into existing source files.
  4. Surface parity (docs/development/surface-parity.md, mandated in CLAUDE.md): an operation reachable from REPL, MCP, LSP, and browser must produce equivalent effects on every surface. Different-rules-per-surface is forbidden.
  5. Production safety: release nodes (no workspace) must be unaffected. A production triage REPL session must never accidentally mutate code on disk.

Decision

Adopt explicit-flush semantics backed by a workspace-local ChangeLog. Live patches mutate memory and append to the ChangeLog; disk writes occur only on explicit Workspace flush (or the IDE-level equivalent).

Model

┌──────────────────────────────────────────────────────────────────────┐
│                          .bt source file                             │
│                       (source of truth on disk)                      │
└────────────────────────────▲─────────────────────────────────────────┘
                             │ flush (explicit)
                             │ — splice via trivia-preserving printer
                             │ — atomic temp-rename
                             │ — workspace/applyEdit notify to LSP
                             │
┌────────────────────────────┴─────────────────────────────────────────┐
│                       ChangeLog (per workspace)                      │
│              append-only log of method-level patches                 │
│              persists across workspace restarts                      │
└────────────────────────────▲─────────────────────────────────────────┘
                             │ append on every live patch
                             │
┌────────────────────────────┴─────────────────────────────────────────┐
│                 In-memory class (hot-reloaded BEAM)                  │
│      Counter compile:source:    Workspace newClass:at:    load-source │
└──────────────────────────────────────────────────────────────────────┘

Behaviour

Intent: ephemeral vs durable. The ChangeLog records durable changes (intent-to-keep). Ephemeral exploration — spike a fix, check it works, throw it away — is supported but does not produce log entries. Intent is signalled by the operation chosen, not the identity of the caller:

OperationIntentChangeLog
Counter compile: #selector source: body (durable, ADR 0066 >> desugars to this)durable — caller wants to keep itlogged
Counter tryCompile: #selector source: body (ephemeral)ephemeral — exploration / spikenot logged
Workspace newClass: source at: path (new-class creation)durable — caller wants a new filelogged (kind: "new-class")
Counter >> #selector (reader form)n/a — pure readnot logged
load-source of an existing classephemeral by default (legacy browser internal op)not logged unless intent: "save" parameter set

author_kind (human / agent) is recorded on every entry as audit metadata, not as a filter. The pending change set (Workspace changes) includes all logged entries regardless of author — an agent that ran Workspace newClass:at: for ten new test files produces ten visible entries in Workspace changes, because those files are the deliverable.

No new workspace-side REPL ops. All operations described below are Beamtalk method calls submitted via the existing evaluate REPL op. MCP tools, LSP executeCommand handlers, REPL meta-commands, and browser actions are all client-side structured wrappers that construct the Beamtalk expression and submit it via evaluate. The workspace dispatcher does not learn new op names. See Implementation for the rationale.

Logging principle: every in-memory method mutation produces a ChangeEntry. Always. The audit trail is exhaustive — Workspace changes answers "what has the running workspace mutated relative to disk?" without gaps. Whether an entry is flushable and whether the caller intended it as durable are two orthogonal flags on the entry; neither controls whether-it-logs.

Method patch flow (>> patcher form, underlying Behaviour compile:source:). A successful patch does three things in sequence: (1) reads and parses Counter sourceFile (if non-nil) to capture the existing method's exact byte span and source body as prev_source; (2) installs the new method in memory; (3) appends a ChangeEntry to the workspace ChangeLog. The entry carries intent: durable and flushable: true (when sourceFile is in-project) or flushable: false (stdlib / dependency / dynamic class — see Cross-cutting decisions). All-or-nothing applies between steps 2 and 3 — if memory install succeeds, the ChangeEntry is emitted; if memory install fails, neither happens.

New-class flow (Workspace newClass: source at: path). targetPath is required and must lie inside the project source tree. The method (1) compiles and installs the class in memory, (2) writes a ChangeEntry with kind: "new-class", intent: durable, flushable: true, prev_source = nil, span = nil, and the full class source. Subsequent compile:source: patches against this class log additional entries layered on top; at flush time, entries replay in order — the new-class entry writes the initial file, then later method patches splice into it. This avoids needing a class-to-source serialiser; we don't reconstruct from in-memory metadata. targetPath is rejected per the validation rules (see Cross-cutting decisions).

Ephemeral patch flow (Behaviour tryCompile:source:). Installs in memory exactly like compile:source: and also logs a ChangeEntry — but with intent: ephemeral. Agents use this for exploration: spike a candidate fix, run tests via evaluate, observe outcome, and either (a) discard by ignoring it (ephemeral entries auto-prune on flush of durable changes and on workspace restart — see Hygiene below) or (b) promote by calling compile:source: with the same source to upgrade the intent. The tryCompile:compile: step is the agent's analogue of a human typing >> at the REPL: they tried it interactively, now they want to keep it. The audit trail still records every tryCompile: call — visibility into what the agent tried is part of the value of the ChangeLog, not noise to hide.

Flushability: a class is flushable iff sourceFile is non-nil and the source file lies inside the current project's source tree (per the active beamtalk.toml). For flushable classes, intent: durable entries are written to disk by Workspace flush. For non-flushable classes (stdlib, dependency, dynamic — sourceFile = nil or out-of-project), patches still install in memory and still log, but flush skips them with a status report. New classes created via Workspace newClass:at: are flushable by construction.

Workspace flush selection rule: writes only entries where intent = durable AND flushable = true. Other entries — ephemeral entries, non-flushable durable patches — are reported in the flush summary as "skipped: " (ephemeral or not flushable (stdlib) or not flushable (dependency: <path>)).

Hygiene for non-deliverable entries. Ephemeral and non-flushable entries don't accumulate forever:

TriggerWhat it prunes
Workspace restartAll ephemeral entries (orphans by definition — memory state gone). Non-flushable durable entries get auto-tagged orphan (the patch can never be re-applied without re-running it).
Workspace flush succeedsEphemeral entries from the same session are pruned (configurable; default yes — they're noise after the commit). Non-flushable entries persist for audit.
Workspace changes pruneEphemeralManual sweep of intent: ephemeral entries.
Workspace changes pruneOrphansExisting orphan cleanup; now also catches non-flushable entries tagged as orphan after restart.

Surface

Principle (per ADR 0040): every MCP / REPL / LSP / browser tool op is a structured invocation of a Beamtalk-level expression. There are no tool-only operations — every op compiles to something a human could type at the REPL. The tool surface is a convenience layer; the language is the API.

Beamtalk language bindings (the methods every tool calls through to):

WhereBindingUsed by
Behaviour metaclassCounter compile: #selector source: "body" (new, underlying primitive) — durable, logged>> parser desugars to this; MCP save_method, browser "Save", REPL editor save call it directly
Behaviour metaclassCounter tryCompile: #selector source: "body" (new, underlying primitive) — ephemeral, no logMCP try_method calls it directly
Behaviour metaclassCounter >> selector => body (existing patcher form, ADR 0066) — parser sugar that desugars to compile:source:Humans typing at the REPL
Behaviour metaclassCounter >> #selector (existing reader form, ADR 0066) — pure read, returns CompiledMethodtab-completion, inspector
WorkspaceWorkspace newClass: source at: path (new) — durable new-class creation, logged as kind: "new-class"MCP save_class, browser "New File", REPL
WorkspaceWorkspace flush, Workspace flush: aClassMCP flush, REPL :flush, LSP executeCommand, browser "Save All"
WorkspaceWorkspace changes — returns the ChangeLog object (gateway for all pending-state queries)MCP list_changes, MCP dirty, REPL :changes, REPL :dirty, browser ChangeLog viewer, browser dirty indicator
ChangeLog (returned by Workspace changes)size, isEmpty, notEmpty, do:, select:, dirtyMethods, revert:, clear, flushKinds:"Is anything dirty?" is Workspace changes notEmpty; "what's dirty?" is Workspace changes dirtyMethods; MCP dirtyWorkspace changes notEmpty; MCP dirty_methodsWorkspace changes dirtyMethods

The compile:source: / tryCompile:source: distinction matters for implementation: tools take the body as a value (a Beamtalk String passed through the eval pipeline), not as a substring concatenated into a >> expression. Building a >> source string and re-parsing would require escaping the body to be valid Beamtalk source — fragile, breaks on quote chars, multi-line bodies, etc. Calling compile:source: directly bypasses the string-roundtrip and passes the body value end-to-end.

Tool surfaces (each row maps to one or more bindings above):

SurfaceOpCompiles to
REPL meta-command:flush, :flush <Class>Workspace flush / Workspace flush: aClass
REPL meta-command:changesWorkspace changes
REPL meta-command:dirtyWorkspace changes notEmpty
MCPsave_methodaClass compile: aSym source: body
MCPsave_classWorkspace newClass: source at: path
MCPtry_methodaClass tryCompile: aSym source: body
MCPflushWorkspace flush (or Workspace flush: aClass)
MCPlist_changesWorkspace changes (returns serialised log)
MCPdirty_methodsWorkspace changes dirtyMethods
LSPworkspace/executeCommand: flushWorkspace flush
LSPworkspace/executeCommand: save_classWorkspace newClass: source at: path
Browser"Save" (per method)aClass compile: aSym source: body
Browser"New File"Workspace newClass: source at: path
Browser"Save All to Disk"Workspace flush

Workspace facade vs ChangeLog object. The Workspace facade follows Pharo's Smalltalk changes idiom and stays minimal: four methods total (flush, flush:, changes, newClass:at:). All pending-state queries — is anything dirty?, what's dirty?, revert this one, clear them all — live on the ChangeLog returned by Workspace changes, which carries the full collection protocol (size, isEmpty, notEmpty, do:, select:, dirtyMethods, revert:, clear, flushKinds:). The previously-proposed convenience method Workspace dirty was dropped in favour of Workspace changes notEmpty — composes from existing primitives, makes the model explicit (there's a changeset, you're querying it), no capability lost.

REPL session (human, patching existing class)

> Counter >> increment => self.value := self.value + 1
=> a CompiledMethod (#increment in Counter)        // memory patched
> Workspace changes
=> a ChangeLog with 1 entry
> Workspace changes notEmpty
=> true
> Workspace changes dirtyMethods
=> #{Counter -> #{#increment}}
> Workspace flush
=> flushed 1 method across 1 file
> Workspace changes isEmpty
=> true

MCP agent session (spike, then commit new test + impl)

Each MCP tool call below is annotated with the Beamtalk expression it compiles to — the tool is a structured invocation, the language is the API.

// 1. Agent explores via try_method — installs in memory and logs as ephemeral
mcp> try_method(class: "Counter", selector: "doubled", body: "^ self value * 2")
//   ≡ Counter tryCompile: #doubled source: "^ self value * 2"
=> ChangeEntry logged (#doubled in Counter, intent: ephemeral, flushable: true)
mcp> evaluate("(Counter new) doubled")
=> 0                                              // works, agent commits

// 2. Agent promotes the spike — same source, durable intent this time
mcp> save_method(class: "Counter", selector: "doubled", body: "^ self value * 2")
//   ≡ Counter compile: #doubled source: "^ self value * 2"
//   (a human typing `Counter >> doubled => self value * 2` reaches the same method via parser sugar)
=> ChangeEntry logged (#doubled in Counter, intent: durable, flushable: true)
//   The earlier ephemeral entry remains for audit; both shadow-resolve on flush

// 3. Agent creates a new test class for the feature
mcp> save_class(source: "<DoubleCounterTest source>", target: "test/double_counter_test.bt")
//   ≡ Workspace newClass: "<DoubleCounterTest source>" at: "test/double_counter_test.bt"
=> ChangeEntry logged (kind: new-class)

// 4. Agent creates the impl file for a follow-up class
mcp> save_class(source: "<DoubleCounter source>", target: "src/double_counter.bt")
//   ≡ Workspace newClass: "<DoubleCounter source>" at: "src/double_counter.bt"
=> ChangeEntry logged (kind: new-class)

// 5. Human reviews and flushes — same operations, no tool needed
> Workspace changes notEmpty
=> true
> Workspace changes dirtyMethods
=> #{Counter -> #{#doubled},
     DoubleCounterTest -> #new-class,
     DoubleCounter -> #new-class}
> Workspace changes do: [:e | Transcript show: e author_kind]
=> "agent" "agent" "agent"
> Workspace flush
=> flushed 1 method + 2 new files across 3 files

Error examples

> Integer >> double => self * 2          // stdlib class, no source file
=> error: cannot patch Integer — stdlib classes are sealed against
         live editing (sourceFile is nil)

> Counter >> bogus => undefinedSym       // compile failure
=> error: undefined identifier 'undefinedSym' in #bogus
         — memory unchanged, ChangeLog unchanged

> Workspace flush                        // external edit collision
=> error: external edit detected in examples/counter.bt
         (mtime advanced; content hash differs).
         Pending: 2 methods. Choose:
           Workspace flush:force          // overwrite disk
           Workspace changes clear        // discard memory edits
           Workspace changes diff: counter.bt     // inspect conflict

Cross-cutting decisions

ConcernDecision
Splice strategyByte-span replacement, not AST round-trip. At hook time, the disk file is parsed to locate the target method's exact byte span (start..end including the body and trailing newline); that span is stored on the ChangeEntry. At flush time, a new file body is produced by copying bytes verbatim outside the span and substituting the patched source inside it. No reformat, no AST reprint of unchanged content. This sidesteps the open question of whether the formatter can round-trip every .bt file losslessly — only the parser's span resolution must be correct, which ADR 0044's trivia model already supports.
Single-file atomicityWrite <file>.tmp → fsync → atomic rename. Memory install precedes disk write; ChangeEntry is only pruned after rename returns. A crash between install and rename leaves the entry pending — retried on next flush.
Multi-file atomicityTwo-phase per flush operation. Phase A: parse every target file, validate every recorded byte span still resolves cleanly, write every <file>.tmp. Phase B: rename each <file>.tmp<file> in sequence. If any Phase A step fails, abort the entire flush; no temp files are renamed; no ChangeEntries are pruned. If a Phase B rename fails (POSIX guarantees atomicity but the OS may surface I/O errors), the failed file's entries remain in the log alongside any entries for files that haven't yet renamed; already-renamed files are reported as completed. The user sees a per-file status report. This is the strongest atomicity achievable without filesystem transactions, and it ensures the failure mode is recoverable via re-flush, not silent data loss.
Compile failure on patchMemory unchanged, ChangeLog unchanged, error surfaced.
Disk-read failure on patchIf sourceFile cannot be read or parsed at hook time (file deleted, syntax error introduced externally), the patch downgrades to memory-only: memory is patched, but no ChangeEntry is emitted and the user is warned that the patch will not survive workspace restart.
Compile failure on flushShould not happen — patches were already compiled into memory. Splice is purely byte-level. The only flush-time failure modes are external-edit conflicts and I/O errors.
External-edit detectionAt flush time, compare per-file (mtime, content-hash) against the snapshot captured when the first pending ChangeEntry for that file was logged. Mismatch → conflict; pending entries remain in the log; user chooses force/discard/diff.
LSP coordination on flushRuntime emits workspace/applyEdit for each flushed file; VSCode reloads the buffer. If the editor has unsaved changes, VSCode's standard conflict dialog applies.
Multi-client (two browsers)Last-writer-wins on memory install. Both clients' ChangeEntries land in the log; on flush, the second client's entry shadows the first for the same method. Each browser session observes the dirty set and shows "modified by another session" when its local view drifts.
ChangeLog formatTwo-part layout under a dedicated workspace subdir: short metadata lines in <workspace>/changes/changes.jsonl, source bodies stored as plain .bt files in <workspace>/changes/sources/. Each changes.jsonl entry is small (stays under ~300 chars regardless of method size): {ts, seq, epoch, class, selector, kind: "instance"|"class"|"new-class", source_ref, prev_source_ref | null, sourceFile | null, span: {start, end} | null, intent: "durable"|"ephemeral", flushable: bool, not_flushable_reason: "stdlib"|"dynamic"|"dependency:<path>" | null, author, author_kind: "human"|"agent"}. source_ref and prev_source_ref are filenames relative to changes/sources/ (e.g. "000142-source.bt", "000142-prev.bt"); new-class entries have span: null and prev_source_ref: null; non-flushable entries may have sourceFile: null (stdlib/dynamic) or a path outside the project tree (dependency). The source files themselves are plain Beamtalk source — cat, less, bt fmt, diff, syntax highlighting all work without escaping. The author_kind and not_flushable_reason enums are intentionally open. Survives workspace restart. The dedicated changes/ subdir keeps the workspace root uncluttered and gives a single backup/exclude target.
ChangeLog growthBounded ring of last N=1000 entries by default. On rotation, both the metadata segment and the referenced source files are archived: <workspace>/changes/archive/changes-<timestamp>.jsonl.gz for metadata, <workspace>/changes/archive/sources-<timestamp>.tar.gz for the corresponding source files. human and agent entries are retained on equal footing — both represent durable intent and are pruned only by the ring bound. Source-file disk usage scales linearly with logged entries (two .bt files per logged method patch — source + prev_source; one for new-class entries). If usage becomes a concern, a future revision can switch source_ref from seq-numbered filenames to content-addressed hashes for dedup, without changing the on-disk shape.
Orphan entries on restartThe ChangeLog persists across workspace restart; the BEAM module state does not. On startup, the workspace assigns a new epoch and tags every pre-existing entry as belonging to a prior epoch. Entries whose prev_source no longer matches the current on-disk content are tagged orphan (the disk advanced via VSCode/git/another flush while the workspace was down). Both prior-epoch and orphan entries are excluded from the active Workspace changes view by default — their memory state was lost on restart and the patches are no longer installed, so Workspace changes notEmpty returns false for these alone. They remain in the underlying log for audit and inspection via Workspace changes includingOrphans (select: [:e | e isOrphan]); a Workspace changes pruneOrphans operation discards them on demand. Auto-prune-on-startup is opt-in via a workspace setting.
File relocation / deletion at flushExternal-edit detection catches content changes via (mtime, content-hash). A path change (file moved or deleted between patch and flush) surfaces as a flush error with a distinct conflict kind: "source file relocated or deleted." The user chooses: Workspace changes relocate: aClass to: newPath to update the entries' sourceFile, Workspace changes clear: aClass to discard, or Workspace diff: aClass to inspect. The entry is not auto-rewritten — relocation requires explicit human confirmation because the new path may be the wrong one.
Intent vs author vs flushableThree orthogonal flags on every ChangeEntry. intent (durable / ephemeral) is signalled by the method called: compile:source: and newClass:at: ⇒ durable, tryCompile:source: ⇒ ephemeral, >> parser form ⇒ durable (desugars to compile:source:). flushable (boolean) is derived from the class: true iff sourceFile is in-project. author_kind (human/agent) is audit metadata identifying the caller. Workspace flush writes intent = durable AND flushable = true entries only. Workspace changes shows everything; Workspace changes select: [:e | e isFlushable] filters; Workspace changes select: [:e | e isDurable] filters; the flushKinds: selector also accepts author_kind filters (e.g., Workspace changes flushKinds: #{agent} to commit an agent batch separately from human changes).
New-class flushWhen flushing a new-class entry, the splice operation is "write source to targetPath" (no byte-span surgery; the file doesn't exist yet). External-edit detection still applies: if targetPath was created externally between the newClass:at: call and flush, the conflict surfaces with the same force/discard/diff choice. Subsequent compile:source: patches against a not-yet-flushed new class produce additional entries that replay in order at flush — the new-class entry writes first, then later method-patch entries splice into the just-written file.
UndoWorkspace changes revert: aMethod re-installs prev_source from the most recent ChangeEntry for that method and appends a new revert entry (revert is itself a patch, not log mutation). Revert is only possible for flushable classes — ephemeral memory-only patches against stdlib/dependencies are not recorded and therefore not revertible.
Release buildsNo-op. Release nodes do not start a workspace; ChangeLog code is in beamtalk_workspace, not beamtalk_runtime.
Stdlib classessourceFile => nil ⇒ patches install in memory and log a ChangeEntry with flushable: false (reason: "stdlib"). Workspace flush skips them with a status line. Smalltalk-style live debugging of stdlib (e.g. Integer compile: #double source: "^ self * 2") is supported; the patch is real in memory, recorded in the audit log, and operators can see the drift via Workspace changes. Reproducible-build guarantee preserved: flush will not write into the stdlib source tree.
Dynamic classes (ADR 0038, ClassBuilder)sourceFile => nil ⇒ same shape as stdlib: install + log with flushable: false (reason: "dynamic").
Package dependency classessourceFile outside the current project source tree ⇒ same shape: install + log with flushable: false (reason: "dependency: <path>"). Reproducible-build guarantee preserved: flush will not write into the dependency cache.
Workspace newClass: validationThe op raises if: (a) targetPath already exists on disk; (b) targetPath lies outside the project source tree; (c) source parses successfully but the declared class name does not match the basename of targetPath (one-class-per-file convention per ADR 0040); (d) a class with that name is already loaded in memory (use compile:source: against the existing class, or remove it first). All four are loud errors with specific messages — no silent fallback.
tryCompile:source: and restartEphemeral patches log a ChangeEntry (intent: ephemeral) so the audit trail is complete, but the patch itself does not survive workspace restart: on restart, memory wins from disk, and ephemeral entries are auto-pruned from the log (they're orphans by definition — the memory state they recorded is gone). Agents wanting to keep a successful spike call compile:source: (or save_method MCP tool) to upgrade the intent to durable. The log retains pruned-on-restart ephemerals in the rotated archive (changes/archive/) if longer-term audit is wanted.
Concurrent compile + flushWorkspace flush snapshots the set of pending ChangeEntries at flush start (end of Phase A). New compile:source: calls that arrive mid-flush append to the log normally and become pending for the next flush; they do not race with the in-progress flush operation. The ChangeLog gen_server serialises log appends and flush-start reads.
Extension methods (ADR 0066)A class adding extension methods to a foreign class has its own sourceFile; the patch is logged against the extender's file, not the extended class's file. Multi-extender ambiguity: if two packages both extend String >> shout, the patch is logged against the file owning the currently-resolved extension method (whatever the MRO picked at dispatch time). Conflict resolution between competing extenders is ADR 0066's problem, not this ADR's — we faithfully patch whichever extender was active.
autoflush: trueMemory install → flush in the same call. On flush failure (external-edit conflict, write error, multi-file partial), memory and disk diverge — we do not attempt to roll back the BEAM module install (prior binary may be unloaded; live actors may hold references to the new closures). The error surfaces with a "memory ahead of disk" warning and the ChangeEntry remains in the log for manual flush. Autoflush is best-effort consistency, not transactional.

Prior Art

Pharo / Squeak Smalltalk

Pharo's .changes file is the canonical reference. Every method edit appends a chunk to the changes file before the image even commits the change to its method dictionary. The .changes log is browsable, replayable, and is what makes "save in place" tolerable — you can always recover an overwrite. Pharo decouples the log from the image snapshot: log is continuous, snapshot is on-demand.

Adopted: append-only log of method-level patches, used both as dirty tracker and as undo store. Adapted: the log persists across workspace restarts (matching Pharo's .changes durability) but unlike Pharo there is no image — flush writes the splice into the .bt source files instead of into a binary image. Rejected: Pharo's auto-write-on-edit behaviour. Pharo's image-based model means "write" doesn't touch user-visible files. Our files are user-visible (and version-controlled), so silent writes on every edit are wrong by default.

GemStone/S (GemTalk Systems)

GemStone/S is a multi-user, persistent Smalltalk: classes, methods, and all live objects reside in a transactional object repository, not in source files. Edits happen inside a per-session transaction; System commitTransaction makes them durable and visible to other sessions, System abortTransaction discards them. Concurrent commits to the same method surface as a first-class TransactionConflict that the user resolves explicitly. GemStone has run production multi-developer Smalltalk systems at scale for thirty years — it is the canonical reference for the workflow shape this ADR adopts.

Workflow parallel. The three core steps map one-to-one:

GemStone/SBeamtalk (this ADR)
Edit method → in-session installCounter compile: #sel source: body (or >> parser sugar) → memory install + ChangeEntry
System commitTransactionWorkspace flush
System abortTransactionWorkspace changes clear
TransactionConflict on commitExternal-edit conflict at flush time
File-in from topaz / GBSWorkspace newClass: source at: targetPath
Per-session transaction isolationSingle shared workspace; multi-client last-writer-wins (simpler point on the same axis)

Adopted: the explicit-commit, conflict-as-first-class model. GemStone proves at production scale that a save → commit → conflict-aware workflow is intuitive when the vocabulary is explicit and the conflict surface is part of the contract, not an afterthought. Our Workspace flush and external-edit detection inherit this directly. The "commit/abort/conflict" vocabulary is also worth borrowing in user-facing docs — newcomers from any DB-backed system will recognise it.

Adapted: GemStone's per-session transaction isolation. They support arbitrarily many concurrent sessions, each with its own pending edits, reconciled via optimistic concurrency at commit time. We don't need that today — multi-client coordination is last-writer-wins on memory install, and conflict surfaces only at flush against the on-disk file. The model is the same; the scope is narrower. If multi-session isolation becomes a need, GemStone's optimistic-concurrency approach is the upgrade path.

Rejected: the object repository as the source of truth. ADR 0004 made the opposite architectural choice — files are the source. GemStone solved the persistence problem by making the database authoritative and treating source text as a projection; we solve it by making the filesystem authoritative and treating memory + ChangeLog as a transactional staging area on top. Architecturally opposite; user-experience-wise close enough that GemStone is the strongest single piece of prior art we have for the workflow shape, even though the storage model is mirror-image different.

Erlang / Elixir

Erlang's code:load_binary/3 and Elixir's Code.compile_string/2 install modules from in-memory source. Neither has a "save back to file" path — production releases ship .beam only, and source-editing happens externally in editors. ElixirLS and Erlang LS read files; they never write code back.

Adopted: the runtime-is-loader, editor-is-writer split. Our flush operation is the explicit bridge; without it, the runtime stays in the BEAM tradition of memory-only patching. Rejected: the implicit split where memory and disk simply never reconcile. We need explicit reconciliation because the IDE story demands it.

Newspeak / Hopscotch

Newspeak has no global namespace and edits happen inside a Hopscotch browser against an image. The whole notion of "splice into a source file" doesn't apply — the image is the source. We diverge because we explicitly rejected the image model in ADR 0004.

LSP — workspace/applyEdit

The LSP spec defines a server-initiated edit message that clients (VSCode, etc.) handle by applying changes to open buffers, prompting on conflict, and refreshing on-disk state. This is the only sanctioned protocol for "an external process is about to modify a file the editor may have open."

Adopted: flush emits workspace/applyEdit per file. VSCode handles the conflict UX for us.

Git's index vs working tree

The two-stage model (stage with git add, commit with git commit) is the closest mainstream analogue to our memory + ChangeLog + flush split. The ChangeLog is roughly the index; flush is roughly commit. The conceptual familiarity is useful to lean on in user docs.

User Impact

Newcomer (from VSCode / Python / JS)

Smalltalk developer

Erlang / Elixir developer

Production operator

Tooling developer (LSP/IDE)

Steelman Analysis

Alternative A — Memory-only (status quo + "Export Changes")

Alternative B — Write-through (every patch writes immediately)

Alternative D — REPL memory-only, browser-editor write-through

Alternative F — Shadow-file overlay (Monticello-style)

Tension points

Alternatives Considered

Alternative A — Memory-only (status quo + "Export Changes")

See steelman above. Smallest surface; loses work; no audit trail; rejected.

Alternative B — Write-through (every patch writes immediately)

See steelman above. Maximally consistent but operationally dangerous; constant collision with editors and source control; rejected.

Alternative D — REPL memory-only, browser-editor write-through

See steelman above. Matches per-surface intuition but violates surface parity; rejected.

Alternative E — Image-style snapshot (revisit ADR 0004)

Drop file-based source entirely; persist the workspace as a binary image. Rejected by ADR 0004 with extensive rationale; this ADR does not revisit that decision.

Alternative F — Shadow-file overlay (Monticello-style)

See steelman above. Pharo's Monticello uses overlay files for package deltas with decades of production use, and F has genuine wins over C: overlays survive workspace restart automatically (the loader re-applies them, no orphaned ChangeLog); crash-safety is better (plain file I/O vs in-memory gen_server state); pending state is greppable / cat-able (operational legibility). Rejected because in our multi-surface architecture (VSCode + LSP + LiveView + MCP + REPL) the overlay creates two-source-files-per-method, fracturing every external tool's view of "where does this method live?" — a schism ADR 0024's static-first/live-augmented model cannot mediate the way it mediates C's memory/disk drift. Right model for a monolithic IDE (Pharo's image); wrong model when the editor and runtime are separate processes. Revisit if Beamtalk ever ships a unified IDE that owns both.

Consequences

Positive

Negative

Neutral

DDD Model Impact

Implementation

Affected components

LayerChange
crates/beamtalk-core/src/source_analysis/New byte-span resolver: given source text and (class, selector, kind), return the byte span of that method's definition. Pure parser-level work; no new printer.
runtime/apps/beamtalk_workspace/src/beamtalk_workspace_changelog.erl (new)Gen_server owning the append-only ChangeLog. ETS for live state; on disk, JSON-Lines metadata in changes/changes.jsonl plus per-entry source files in changes/sources/. Exposed via the Workspace facade per ADR 0040. Lives in the workspace context, not REPL, because it's consumed cross-surface.
runtime/apps/beamtalk_runtime/src/beamtalk_extensions.erlThe >> patch install chokepoint (already exists, 259 LOC). Hook the install path to (1) read+parse sourceFile to capture span and prev_source, (2) install in memory, (3) emit ChangeEntry. Flushability check (project-tree containment) gates the emit.
runtime/apps/beamtalk_workspace/src/beamtalk_repl_ops_load.erlNo changes — no new workspace-side ops. All operations are reached via the existing evaluate op, which receives a Beamtalk expression constructed by the calling layer (MCP / LSP / REPL CLI / browser). See Rationale: why no new REPL ops below.
stdlib/src/Workspace.btNew facade methods: flush, flush:, changes (returns ChangeLog), newClass:at:. Four methods total — pending-state queries live on the ChangeLog object (changes notEmpty, changes dirtyMethods, etc.), matching Pharo's Smalltalk changes idiom.
stdlib/src/Behaviour.btTwo new class-side methods: compile: aSym source: aString (durable, logs) and tryCompile: aSym source: aString (ephemeral, no log). compile:source: is the underlying primitive that the existing >> patcher form desugars to (ADR 0066 parser rule updated). Both share the same compile-and-install path; only compile:source: emits a ChangeEntry. MCP tools call these directly with body values, avoiding fragile string-construction of >> expressions.
stdlib/src/ChangeLog.bt (new)The navigable ChangeLog object: size, isEmpty, do:, select:, dirtyMethods, revert:, clear, flushKinds:. Backed by beamtalk_workspace_changelog.erl via FFI.
crates/beamtalk-cli/src/commands/repl/mod.rsNew meta-commands: :flush, :flush <Class>, :changes, :dirty. Each is a CLI-side shortcut that constructs the equivalent Beamtalk expression (e.g. :flushWorkspace flush) and submits via the existing evaluate op — no new workspace-side dispatch.
crates/beamtalk-mcp/src/server.rsNew tools: save_method, save_class, try_method, flush, list_changes, dirty_methods. Each tool implementation takes typed args, constructs the corresponding Beamtalk expression (see Surface table), and submits it via the existing evaluate pathway — there is no workspace-side op to dispatch. The MCP layer is purely a typed front for the language. MCP-issued logged patches are auto-tagged author_kind: agent (passed as metadata on the eval submission).
crates/beamtalk-lsp/src/server.rsHandle workspace/executeCommand for flush and save_class by constructing the Beamtalk expression and submitting via evaluate. Emit workspace/applyEdit to clients on flush events received from the runtime.
runtime/apps/beamtalk_workspace/priv/static/workspace.jsPer-method dirty indicator, "Save" per method, "Save All to Disk" workspace-level.
docs/development/surface-parity.mdNo new REPL-op rows. The MCP tools and LSP commands listed in the Surface table compile to evaluate of a known Beamtalk expression; the drift checker should treat these as parity-compliant by virtue of the expression being the contract. May need a small drift-checker update to recognise the "MCP tool ≡ Beamtalk expression" pattern.
docs/beamtalk-language-features.mdDocument Workspace flush semantics.

Phased rollout

PhaseScopeEffortTests
0Validation spike (internal scaffolding, not user-facing). Implement the byte-span resolver and prove it against the entire stdlib + examples corpus: parse, locate every method's span, re-serialise file with a no-op span replacement, and assert byte-identical output. This is the load-bearing assumption of the design — validate it before building anything else.SCorpus round-trip tests in crates/beamtalk-core/src/source_analysis/.
1ChangeLog gen_server + two-part on-disk persistence (changes/changes.jsonl for metadata + changes/sources/ for source bodies) + Workspace changes (returns ChangeLog object) + ChangeLog collection protocol (isEmpty, notEmpty, size, do:, select:, dirtyMethods). Hooks into beamtalk_extensions.erl. No flush yet. author_kind plumbing through REPL/MCP.MEUnit tests for the gen_server (including crash-safety: write metadata + source files atomically); BUnit tests for Workspace changes and the ChangeLog collection methods.
2Workspace flush + flush: + single-file atomic temp+rename + multi-file two-phase (Phase A all writes, Phase B all renames) + external-edit detection. Pruning rules implemented.MEUnit tests for atomicity (kill the process between phases); BUnit tests for the facade.
3MCP tools (save_method, save_class, try_method, flush, list_changes, dirty_methods) implemented as expression-building wrappers over the existing evaluate op; LSP executeCommand handlers (flush, save_class) same pattern; REPL meta-commands (:flush, :changes, :dirty) construct expressions CLI-side; browser "Save" / "New File" / "Save All" actions same pattern. No workspace-side REPL ops added. Surface-parity table updated to recognise expression-backed tools as parity-compliant.MMCP integration tests for try→save promotion; browser e2e for Save and New File; LSP command tests; surface-parity drift check passes.
4ChangeLog object operations (revert:, clear, flushKinds:, do:, select:) + autoflush workspace setting. ChangeLog browsing UI in the browser workspace.SBUnit tests for revert and ChangeLog navigation; e2e for autoflush.
5LSP-side workspace/applyEdit consumption in VSCode + e2e test that flush refreshes an open buffer.SVSCode extension e2e.

Total: ~M-L across 6 phases. Phase 0 is scaffolding — its deliverable is evidence that byte-span splice works on real code, not a shippable feature. If Phase 0 reveals that the parser cannot reliably resolve method spans against arbitrary .bt files, the design pivots before phases 1–5 commit to it.

Rationale: why no new REPL ops

A previous draft of this ADR registered six new workspace-side REPL ops (save-method, save-class, try-method, flush, list-changes, dirty), each implemented as a thin handler that constructed the equivalent Beamtalk expression and submitted it through the workspace evaluator. That layer was redundant.

The existing evaluate REPL op is the universal mechanism: it receives a Beamtalk expression string and returns the result. Every operation this ADR describes is expressible as a Beamtalk expression (per the Beamtalk language bindings table in Surface). So the workspace dispatcher does not need to learn new op names — it already knows evaluate, which is enough.

The reasons to register new ops on the workspace side are narrow:

ReasonApplies here?
Transport mechanism that can't be expressed in-language (e.g. interrupt, complete, session lifecycle)No — every operation here is in-language
Out-of-band signal (interrupt while eval is blocking)No
OS-level concern (load-project reads beamtalk.toml from disk before any class is loaded)No
Discoverability for agentsProvided by the MCP tool schema layer, not by the workspace op registry — agents discover via the MCP schema, not by introspecting workspace ops
Discoverability for editorsProvided by the LSP executeCommand registry, not the workspace
Structured returnsEventually fixable in evaluate itself (a toJson selector on values, or an eval mode that returns structured data) — out of scope for this ADR but tracked for a future improvement
Telemetry / audit (author_kind)Passed as metadata on the eval submission, not as a separate op

Pharo and GemStone follow the same architecture: there is no "image RPC API" beyond message-send and reflection. The IDE tools (System Browser, Inspector, Test Runner, Monticello) are Smalltalk code that compose message-sends. We mirror that: MCP tools, LSP commands, REPL meta-commands, and browser actions are all tooling layers that compose Beamtalk expressions and submit via evaluate. The workspace is the runtime; the tools are the IDE.

Out of scope for this ADR but related: the existing workspace has ~17 redundant REPL ops (actors, methods, list-classes, inspect, test, etc.) that predate the Workspace / Beamtalk facade (ADR 0040) and could likewise be collapsed into evaluate of a known expression. That cleanup is opportunistic — touched when those handlers next break or are modified. A future "REPL op consolidation" epic may sweep them out.

Out of Scope

This ADR covers patch (existing method) and create (new class file). The following are deliberately deferred to follow-up ADRs so that the persistence model can ship without being held up by destructive-op design:

Deferred concernWhy deferredFuture ADR
Method-level removal (aClass removeSelector:)The language primitive does not exist yet. Adding it is a separate design question (raise vs no-op on absent selector? cascade to overrides? extension-method handling?) — not bundled with persistence. The runtime can erase a method's method_signatures entry (beamtalk_object_class.erl:640) but there is no first-class Beamtalk method that calls it."Method-level Removal Language Primitive"
Class-level removal flush UXaClass removeFromSystem already exists (BT-785) for memory removal. What it should mean to flush a class removal — deleting a .bt file from disk — is irreversibly destructive and wants its own UX: confirmation prompt, .bt.deleted tombstone, undo flow. Different concerns than patch/create."Destructive Workspace Operations"
Renames (class rename, method rename, file relocation)Touches two paths (the old and the new), needs cross-file rename detection in the splice machinery, and benefits from concrete usage data from the patch-and-create case before its UX is locked in."Destructive Workspace Operations"
Schema accommodationThe ChangeLog format reserves the kind enum as open ("instance", "class", "new-class" today; "remove-method", "remove-class", "rename" will slot in later) and author_kind as open. Future ADRs extend the enum without breaking the format. No prep-work needed in this ADR's implementation phases.n/a

Implementation order: ADR 0082 phases 0–3 land first → method-removal language primitive ADR lands in parallel → destructive workspace ops ADR is written after phases 1–2 ship and produce real usage signal (i.e., the UX questions are answered by what users actually try to do, not by speculation now).

Migration Path

No user code changes required. Existing >> patches continue to work identically — they now additionally append to the ChangeLog (silently, until the user looks).

For users who today rely on workspace-restart wiping memory patches (intentional ephemerality): behaviour is preserved. The ChangeLog persists across restart but memory does not; on restart, disk wins, and the ChangeLog contents become orphaned entries (patches whose "memory state" is no longer installed). Per Orphan entries on restart in Cross-cutting decisions, the workspace assigns a fresh epoch on startup and excludes prior-epoch entries from the active Workspace changes view automatically — the user does not need to manually clear unless they want the entries pruned from the audit log.

For ADR 0046 (VSCode sidebar): no migration. The sidebar gains a "pending changes" indicator (computed from Workspace changes notEmpty) and a "Flush" command surface as a phase-3 deliverable.

References