ADR 0042: Immutable Value Objects and Actor-Only Mutable State

Status

Accepted | Implemented (2026-02-26)

Context

The State-Threading Quagmire

BeamTalk compiles to Core Erlang on the BEAM VM, which has no mutable variables. The current compiler maps Smalltalk's mutable-object semantics onto BEAM using compile-time state threading — an additional StateAcc parameter threaded through generated code, effectively implementing a state monad transformation as a compiler pass.

This approach has proven deeply problematic:

  1. Self-sends require data dependency analysis. Sequential self-sends that mutate state (self name: 'foo'. self age: 42.) must be detected and compiled with explicit state threading between them.

  2. Conditional mutations require reconciling which version of "self" emerges from branches of ifTrue:ifFalse:.

  3. Blocks that modify local state are currently disallowed — blocks cannot modify enclosing local variables. This breaks common Smalltalk patterns and hurts the "it feels like Smalltalk" developer experience.

  4. Higher-order message sends are unsupported due to the complexity of threading state through arbitrary block-passing patterns.

  5. Deep call chains with self-mutation require propagating changed state back up through the return path, which Smalltalk makes invisible (pointer mutation) but the BEAM requires explicit handling for.

ADR 0041 (Universal State-Threading Block Protocol) solved the block composability aspect by making state threading universal rather than whitelist-gated. This is largely implemented and working — blocks passed to user-defined HOMs now correctly propagate mutations. However, ADR 0041 addresses one facet of a deeper problem: trying to emulate mutable-object semantics on an immutable platform. Self-send chains, conditional mutations, and deep call-chain propagation remain complex even with universal block threading.

The Root Cause

The fundamental tension is between Smalltalk's model (objects are mutable places in memory, assignment mutates a slot) and the BEAM's model (all data is immutable, state lives in processes). Every compiler pass that threads state, every StateAcc map, every dual codegen path for pure/stateful blocks exists because we're fighting the platform.

What Actually Needs Mutability?

Examining real Smalltalk codebases, mutable state falls into two categories:

  1. Identity-bearing stateful entities — counters, connections, UI widgets, services. These have a stable identity that persists across state changes. On the BEAM, these naturally map to processes (gen_servers).

  2. Value computations — points, colors, dates, collections, DTOs. These are computed, passed around, and transformed. They don't need identity — two Point x: 3 y: 4 instances are interchangeable. On the BEAM, these naturally map to immutable terms (maps, tuples, lists).

The state-threading complexity exists entirely because we try to make category 2 behave like it has mutable slots. If we stop doing that, the compiler problem disappears.

Decision

Adopt immutable value semantics as the default for all BeamTalk objects. Mutable state is confined to actor processes (BEAM processes / OTP gen_servers), where self.slot := is allowed and compiles to direct maps:put on the state map.

Two Kinds of Entities

  1. Value Objects — Immutable. Every "mutating" message returns a new object. self.slot := is a compile error. These compile directly to BEAM terms (maps) with no transformation.

  2. Actors — BEAM processes wrapping a gen_server. self.slot := is allowed inside actor methods, compiling to direct maps:put on the state map. Auto-generated with*: methods serve as the public API for external callers. The gen_server manages state persistence across method invocations.

Value Object Semantics

Declaration and Construction

Value objects are declared with state: and constructed via the existing new/new: protocol:

Value subclass: Point
  state: x = 0
  state: y = 0

point := Point new: #{#x => 3, #y => 4}.   "Map-based — any subset of fields, defaults fill the rest"
point := Point new.                          "All defaults — Point(0, 0)"
point := Point x: 3 y: 4.                   "All-fields keyword constructor (auto-generated)"

Syntax note: Today, value types use Object subclass: with state: declarations. This ADR introduces Value subclass: as the explicit declaration form. Value becomes a new root class in the hierarchy between Object and user value classes. The state: keyword is retained as the universal slot declaration for both value types and actors — the Value vs Actor superclass determines immutability semantics, not the declaration keyword. This is deliberate: state: declares "the named data this object holds," which is the same concept regardless of mutability. Whether those slots are immutable (value types) or mutable (actors) is determined by the class kind, not the declaration keyword — just as Smalltalk's instanceVariableNames: doesn't change meaning based on whether the class uses mutable or copy-on-write semantics. Introducing separate keywords (slots: vs state:) was considered and rejected as unnecessary churn: the Value/Actor superclass already provides a clear, visible signal at the declaration site.

Construction forms: Three ways to construct a value object, avoiding Smalltalk's constructor explosion (2^N constructors for N fields):

  1. new: #{...} — Map-based, pass any subset of fields. Defaults fill the rest. This is the primary construction form and already exists today. Ideal for programmatic construction, partial initialization, and agent use.
  2. new — All defaults. Already exists today.
  3. ClassName field1: val1 field2: val2 — Auto-generated all-fields keyword constructor. One method per class, always requires all fields. Provides LSP discoverability for free (method selector completion works out of the box). If usage data shows this is unused, it can be removed.

Discoverability: Field names are discoverable via Point fieldNames (returns #[x, y]) in the REPL and via :help Point. The keyword constructor additionally makes fields visible through IDE autocomplete — typing Point x:<TAB> triggers standard method selector completion.

Class-level state: classState: declares class-level mutable state (it lives in the class object's gen_server state), regardless of whether the class is a Value or Actor type:

Value subclass: Point
  classState: instanceCount = 0
  state: x = 0
  state: y = 0

  class create: x y: y =>
    self.instanceCount := self.instanceCount + 1.
    self new: #{#x => x, #y => y}

Functional Updates via with*:

"Mutating" messages return a new object. The compiler auto-generates with*: methods for each slot:

point := Point new: #{#x => 3, #y => 4}.
point2 := point withX: 5.       "Returns new Point(5, 4). point is unchanged."

list := List new.
list2 := list add: 'hello'.     "Returns new List containing 'hello'."

For a value class with declared slots name, age, email, the compiler generates:

Under the hood, these compile to trivial Erlang map operations. For value types, where Self is the map directly:

%% getter (value type — Self is the map)
'x'/1 = fun (Self) -> call 'maps':'get'('x', Self)

%% functional setter — preserves all slots, including subclass slots
'withX:'/2 = fun (Self, NewX) -> call 'maps':'put'('x', NewX, Self)

For actors, the same with*: methods exist but the dispatch framework mediates: self in an actor method is a #beamtalk_object{} record (see "self and Actor Identity" below), and self withValue: 5 is a self-send that goes through Module:dispatch, which provides the underlying state map to the method. The generated with*: code operates on the state map, not the #beamtalk_object{} record.

Subclass safety: Since with*: compiles to maps:put on the existing map, it preserves all slots — including slots added by subclasses. aColorPoint withX: 5 returns a new map with the same class, color, y, and updated x. The result is still a ColorPoint, not a Point. This works because Erlang maps preserve all keys through maps:put. No virtual dispatch or factory method is needed.

Subclass depth: Value type subclassing has no artificial depth limit. The map representation handles inheritance naturally — each subclass adds its own slots to the map. Dispatch chains walk up the class hierarchy via superclass/0 delegation (the existing mechanism). Since maps have O(log N) key access (small maps use flat arrays with O(N) scan; large maps use HAMTs with O(log₃₂ N) lookup — effectively constant for realistic slot counts), deep hierarchies impose no meaningful per-slot-access penalty. The practical limit is the same as any Smalltalk class hierarchy — deep hierarchies are a design smell, not a technical constraint.

Equality: Value objects use structural equality. Two Point instances with the same x and y are equal (=:=). This follows naturally from the Erlang map representation — internally #{'$beamtalk_class' => 'Point', '__class_mod__' => ..., x => 3, y => 4}, where all metadata keys are identical for instances of the same class, so structural equality reduces to slot-value comparison. For value objects, == (loose equality) and =:= (strict equality) both compare structurally. Actors use process identity — two actors with identical state are different objects if they are different processes.

No Instance Variable Assignment on Value Types

Value types prohibit := assignment to instance variables. If you want a modified version, you create a new one:

"In a Value subclass method:"
self x := 5.        "COMPILER ERROR — no instance variable assignment on value type"

"Instead:"
self withX: 5       "Returns new object"

Actors DO allow self.slot := — see "Actor Semantics" below.

Reflection API: fieldAt:put: on value types raises a #beamtalk_error{type => immutable_value} at runtime. On actors, fieldAt:put: works normally (actors have mutable state). Immutability is enforced both at compile time (self.slot := rejected by semantic analysis) and at runtime (reflection API raises error on value types). The compile-time check catches the common case; the runtime check catches the reflection path.

Local Variable Rebinding

Local variables can be rebound with :=. Each rebinding creates a new value — this is shadowing, not mutation, consistent with Elixir's model and with BeamTalk's own REPL semantics:

x := 0.
x := x + 1.       "Rebinding — x is now 1"
x := x * 2.       "Rebinding — x is now 2"

Blocks capture the value at binding time, not a mutable slot. Rebinding a local inside a block propagates via ADR 0041's state-threading protocol (already implemented):

count := 0.
items do: [:each | count := count + each].
count  "=> sum of items — state threaded through block"

This is consistent with how the REPL already works — top-level := rebinds workspace variables, and blocks inside REPL expressions can mutate captured locals via state threading. Without local rebinding in compiled code, there would be a pedagogical cliff: code that works in the REPL would break when moved to a class method.

Rationale for rebinding over single-assignment:

What rebinding is NOT:

Future: update*: Methods

A future enhancement could add update*: methods that take a block for slot transformation:

point updateX: [:x | x + 1].     "equivalent to: point withX: point x + 1"

This is not part of the initial implementation but is a natural extension.

Actor Semantics

Declaration and State

Actors are declared with state: and default values. State is initialized when the actor is spawned:

Actor subclass: Counter
  state: value = 0

counter := Counter spawn.         "Spawns a gen_server process"

Methods and State Mutation

Inside actor methods, slots are updated directly with :=. This compiles to the same underlying slot-write operation that with*: uses (today: maps:put on the state map), but without a dispatch call or intermediate variables:

Actor subclass: Counter
  state: value = 0

  increment => self.value := self.value + 1
  decrement => self.value := self.value - 1
  getValue  => self.value

The compiler auto-generates with*: methods as the public API for external callers. Inside actor methods, direct slot assignment is preferred:

"FOOTGUN — with*: chain, uncaptured intermediate silently loses balance update:"
deposit: amount =>
  self withBalance: self balance + amount.            "← result discarded!"
  self withTransactions: (self transactions add: amount)  "operates on ORIGINAL state"

"SAFE — direct slot assignment, impossible to lose an update:"
deposit: amount =>
  self.balance := self.balance + amount.
  self.transactions := self.transactions add: amount

Return Value and State Update Rules

The gen_server framework uses two rules to interpret method results:

  1. Last expression (no ^): The result is the reply to the caller. The gen_server state is the accumulated result of all self.slot := assignments in the method body. If the method contains no slot assignments (pure query), the original state is preserved.

  2. Early return (^): The ^ expression is the reply to the caller. State accumulated up to the ^ point is preserved — any slot assignments before ^ are kept.

Under the hood, self.slot := expr compiles to StateN = maps:put(slot, Expr, StateN-1) — sequential state variable renaming within the method body. The generated handle_call returns {reply, LastExpr, FinalState}.

"State mutation — last expression is the reply:"
increment => self.value := self.value + 1
"Compiles to: State1 = maps:put(value, maps:get(value, State0) + 1, State0)"
"Generated: {reply, NewValue, State1}"

"Query — last expression is the reply, state unchanged:"
getValue => self.value
"Generated: {reply, Value, State0}"

"Mixed — mutate then return a specific value:"
incrementAndGetOld =>
  | old |
  old := self.value.
  self.value := self.value + 1.
  ^old
"State updated to State1, old value returned"
"^old throws {$bt_nlr, Token, old, State1}"

Self-sends within actor methods still go through Module:dispatch, which returns {reply, Result, NewState}. The compiler threads the new state forward. But for simple slot updates, direct self.slot := avoids the dispatch round-trip entirely.

Why this eliminates self-send dependency analysis: Under the status quo, self name: 'foo'. self age: 42. requires the compiler to detect the data dependency between sequential slot mutations. Under ADR 0042, direct slot assignments (self.slot :=) compile to sequential maps:put calls with explicit state variable threading — no dependency analysis needed. Self-sends to other methods (self someMethod) go through dispatch and return {reply, Result, NewState}, which the compiler threads automatically.

Sequential State Updates in Actor Methods

Multi-step state updates are straightforward — each line updates state directly:

Actor subclass: Account
  state: balance = 0
  state: transactions = List new

  deposit: amount =>
    self.balance := self.balance + amount.
    self.transactions := self.transactions add: amount

  depositAll: amounts =>
    amounts do: [:amount |
      self.balance := self.balance + amount
    ]

Slot assignments inside blocks are handled by ADR 0041's state-threading protocol — the same mechanism that threads local variable rebindings through blocks. The self.slot := is syntactic sugar for updating the state map variable, which ADR 0041 threads through the block like any other captured local.

self and Actor Identity

Inside an actor method, self is a #beamtalk_object{class, class_mod, pid} record that carries both identity (the process pid) and state access (slot reads dispatch to the underlying state map). This is the existing implementation and it naturally solves the "how do I get my pid?" question.

"Inside the actor method:"
increment => self.value := self.value + 1      "direct state map update"
notifyOther: other => other update: self.pid   "self.pid returns the actor's pid"

"Outside — counter is a pid, message goes through gen_server:"
counter increment.
counter notifyOther: logger.

The #beamtalk_object{} record is the actor's "calling card" — it can be passed to other actors as a reference. From outside, message sends go through gen_server:call/cast. From inside (self-sends), the dispatch bypasses gen_server to avoid deadlock, calling Module:dispatch directly.

self.pid returns the underlying BEAM process identifier (erlang:element(4, Self) on the #beamtalk_object{} tuple). This is defined as a method on the Actor base class (not auto-generated per-class). It is useful for:

State map vs self: Internally, the gen_server callbacks receive a plain state map (State). The runtime's make_self/1 wraps it in #beamtalk_object{} before each method invocation. Slot reads on self dispatch to maps:get on the state map. with*: methods operate on the state map and return a new state map. The #beamtalk_object{} wrapper is transparent — developers work with self uniformly.

Message Send Semantics: Call vs Cast

See ADR 0043 (Sync-by-Default Actor Messaging). The call/cast protocol (. for synchronous gen_server:call, ! for fire-and-forget gen_server:cast) is specified in its own ADR, independent of the value/actor object model.

Cascade Semantics

Deferred to ADR 0044. Cascade semantics for immutable value objects are a significant design question — Smalltalk cascades (;) send multiple messages to the same receiver, but with immutable objects each message returns a new object, making traditional fan-out semantics problematic. The leading candidate is cascade-as-pipeline on value objects (each message sent to the result of the previous), which would give BeamTalk Elixir-style |> pipelines for free (BT-506). However, the type-dependent semantics (pipeline on values, fan-out on actors), dynamic receiver handling, and interaction with ADR 0039 deserve their own focused ADR.

For the initial implementation of ADR 0042, explicit chaining works correctly and unambiguously:

((point withX: 5) withY: 10).
(items select: [:x | x > 0]) collect: [:x | x * 2].

REPL Semantics

The REPL and compiled code have consistent rebinding semantics. Local variable rebinding works the same way in both contexts:

"In the REPL:"
x := 0.
x := x + 1.
items do: [:each | x := x + each].

"In a compiled method — identical behavior:"
myMethod =>
  x := 0.
  x := x + 1.
  items do: [:each | x := x + each].

Under the hood, the mechanisms differ — the REPL threads state through workspace bindings managed by beamtalk_repl_shell (ADR 0040), while compiled methods use SSA bindings or ADR 0041's StateAcc for block captures — but the observable behavior is identical. Code can move freely between the REPL and class definitions without rewriting.

Prior Art

Erlang/OTP — The Platform We Compile To

Erlang embraces immutability as a core language feature. Variables are single-assignment. State lives in processes (gen_servers). This ADR aligns BeamTalk with the platform rather than fighting it.

Adopted: The process-as-state-container model. Actor = gen_server with functional state transitions.

Clojure — Immutable by Default, Controlled Mutability

Clojure's persistent data structures are immutable. Mutable state is managed through controlled references (atoms, refs, agents). This separation of "values" from "identity" directly inspired BeamTalk's value object / actor split.

Adopted: The values-vs-identity distinction. Value objects are Clojure's persistent maps; actors are Clojure's atoms/agents.

Similar: Both allow local rebinding. Clojure uses loop/recur for accumulation; BeamTalk uses := rebinding and inject:into:.

Elixir — Rebinding as Ergonomic Immutability

Elixir is fully immutable on BEAM but allows variable rebindingx = x + 1 creates a new binding that shadows the old one. This is semantically distinct from mutation (closures capture the value at binding time, not a mutable slot), but it feels like mutation to developers coming from Ruby.

This was a deliberate design choice by José Valim to ease the Ruby-to-BEAM transition. Elixir faced exactly the same problem BeamTalk faces: bringing developers from a mutable OOP language (Ruby) to an immutable platform (BEAM). Elixir's solution was to make immutability invisible at the surface syntax level while keeping it real at the semantic level.

Accumulation patterns use Enum.reduce/3 (equivalent to inject:into:). Elixir allows rebinding captured variables inside anonymous functions, but the rebinding does not propagate back to the enclosing scope — fn x -> count = count + x end creates a new binding of count inside the fn, but the outer count is unchanged. BeamTalk's approach differs here: with ADR 0041's state threading, rebinding does propagate through blocks, which is more powerful than Elixir but requires compiler infrastructure.

Adopted: The precedent that BEAM languages can successfully onboard developers from mutable-OOP backgrounds. Elixir proved it with Ruby developers; BeamTalk aims to do the same with Smalltalk developers.

Relevant insight: Elixir's rebinding is essentially what BeamTalk's REPL already does — top-level := creates new bindings in the workspace, not mutations. The REPL "feels mutable" but is semantically immutable. This ADR extends that principle to the whole language.

Adopted: Rebinding within method bodies. Like Elixir, BeamTalk allows x := x + 1 as a rebinding (not mutation). This is essential for REPL/compiled-code consistency and for onboarding Smalltalk developers who expect mutable temporaries. The syntax is different (:= vs =) but the semantics are the same: new binding, old value unchanged, closures capture at binding time.

Gleam — Strict Immutability on BEAM

Gleam compiles to Erlang with strictly immutable semantics. let x = 1 followed by let x = x + 1 is allowed (shadowing, like Rust), but there is no variable reassignment and no mutable state outside processes. Accumulation uses list.fold.

Gleam is the strictest BEAM language and leans into it — the community accepts strict immutability as a feature, not a limitation. Gleam has grown significantly since its 1.0 release, validating that strict immutability does not prevent adoption on BEAM.

Adopted: The validation that strict immutability works on BEAM without driving users away. If Gleam can build a thriving community with no mutable variables, no classes, and no OOP, BeamTalk can certainly work with immutable objects and Smalltalk's rich collection protocol.

Difference: Gleam has no OOP — no classes, no methods, no inheritance. BeamTalk keeps the Smalltalk class model, which provides richer vocabulary for functional patterns (inject:into: reads more naturally than list.fold).

Newspeak — Smalltalk's Successor

Newspeak (Gilad Bracha's successor to Smalltalk) retains mutable instance variables but adds module-level encapsulation. It doesn't attempt immutability.

Not adopted: Newspeak's approach assumes a traditional VM with mutable heap objects. Not viable on BEAM without the state-threading tax.

Swift Value Types

Swift distinguishes between value types (struct) and reference types (class). Value types are copied on assignment and cannot be mutated without mutating keyword (which creates a new value under the hood).

Adopted: The distinction between value types (copied, immutable in effect) and reference types (identity-bearing, mutable). Swift's mutating is analogous to BeamTalk's with*: — both create new values, just with different syntax.

C# — struct vs class, async/await, LINQ (Anders Hejlsberg)

C# is the closest mainstream precedent for Beamtalk's overall design philosophy. Anders Hejlsberg's career-long pattern — making platform complexity accessible through type-level distinctions with excellent compiler guidance — directly parallels what Beamtalk does with BEAM.

Value vs reference types (ADR 0042). C#'s struct vs class is the same fundamental insight as Value subclass: vs Actor subclass:. Structs are value types — copied, no identity, stack-allocated. Classes are reference types — shared, identity-bearing, heap-allocated. The developer chooses once at the declaration site; the compiler enforces the consequences. C# spent years refining diagnostics for struct/class misuse (boxing warnings, readonly struct guidance, in parameter suggestions). Beamtalk should invest similarly in error messages at the Value/Actor boundary.

Adopted: The declaration-site choice pattern. The developer picks Value or Actor once; the compiler handles implications. C# proved this scales — millions of developers understand struct vs class without understanding the memory model underneath.

Sync-first, async opt-in (ADR 0043). C# shipped sync-by-default and added async/await in C# 5.0 — years later, when the need was clear. The design principle: synchronous is the natural mental model; async is opt-in complexity. Beamtalk's . (sync) vs ! (async) follows the same principle. C#'s lesson: making everything async by default (as Beamtalk currently does with Futures) forces every developer to pay the cognitive cost of concurrency, even when they don't need it.

Adopted: Sync-by-default with explicit async opt-in. C#'s async/await is the gold standard for this pattern; Beamtalk's ./! is simpler (no function colouring) but follows the same philosophy.

Pipeline expressions (ADR 0044). LINQ is cascade-as-pipeline for C# — each method chains on the result, not the original receiver. LINQ works in expression context (capture the result), not as statement-level mutation. This matches exactly where Beamtalk's pipeline cascade is most powerful: inside blocks and HOMs where the result flows through without rebinding. Anders would say pipeline cascade is an expression-level feature, not a mutation substitute.

Insight adopted: Focus pipeline cascade on expression contexts (blocks, HOMs, construction) where the result flows naturally. Don't try to make it a statement-level mutation substitute — that's where the rebinding problem bites.

Tooling as language feature. Anders's deepest conviction: a language feature is only as good as its IDE support. C#/TypeScript invest heavily in auto-complete, inline diagnostics, and quick-fixes. For Beamtalk, this means: auto-complete for with*: methods, inline diagnostics for Value/Actor misuse, quick-fixes that suggest the right pattern (e.g., "Did you mean self withX: 5?"). The language design is sound; the developer experience must match.

Pony — Reference Capabilities

Pony uses reference capabilities (iso, val, ref, box, trn, tag) to distinguish mutable from immutable data at the type level. Closures copy captured variables — mutation doesn't propagate.

Insight adopted: The distinction between "values you can share" and "entities with identity" is fundamental. BeamTalk makes this distinction at the class declaration level (Value subclass: vs Actor subclass:) rather than at the type annotation level.

Alan Kay on Erlang

Alan Kay has acknowledged that Erlang captures the message-passing spirit of Smalltalk better than most Smalltalk implementations. Processes communicating via messages — not objects sharing mutable memory — was the original vision.

Adopted: The actor model IS the Smalltalk vision, properly realized. Value objects are the data that flows between actors.

User Impact

Newcomer (from Python/JS/Ruby)

Positive: The mental model is simple — things are either data (immutable, like Python tuples/frozen dataclasses) or services (actors, like microservices with state). No hidden aliasing bugs where modifying one reference affects another. Local variable rebinding works as expected (count := count + 1), so accumulator patterns are familiar.

Concern: No instance variable assignment (self x := 5) is a departure from Ruby/Python class patterns. Must learn with*: methods for updating objects. The distinction between "I can rebind locals but not slots" requires explanation.

Mitigation: Auto-generated with*: methods make immutable updates ergonomic. Good error messages at the self.slot := boundary should guide toward self withSlot: newValue. The collect:, select:, inject:into: vocabulary provides rich alternatives to imperative collection manipulation.

Smalltalk Developer

Concern: Mutable instance variables are fundamental to Smalltalk's model. The Visitor pattern, the Builder pattern, the Observer pattern — they all assume mutable state. self x := 5 not working is a significant departure. The "it's just Smalltalk" story weakens.

Positive: Local variable rebinding works (result := result + each inside blocks), so method-level accumulator patterns are preserved. Blocks and HOMs work universally — no whitelist, no mysterious failures. The actor model preserves Smalltalk's core insight (objects communicating via messages).

Mitigation: Position as "Smalltalk's message-passing philosophy on an immutable platform." The with*: methods are analogous to Smalltalk's copy-based patterns (shallowCopy + modify). Document common Smalltalk patterns and their BeamTalk equivalents.

Erlang/BEAM Developer

Positive: This is how they already think. Immutable data, processes for state, gen_server for managed state transitions. BeamTalk becomes a natural Smalltalk-syntax skin over BEAM idioms rather than an impedance mismatch.

Neutral: The value/actor split pairs naturally with ADR 0043's sync/async messaging (. for call, ! for cast). BEAM developers will find both familiar.

Operator / Production User

Positive — Reasoning about production systems becomes dramatically simpler.

Immutable data means no race conditions on shared state. Actor state transitions are serialized through the gen_server mailbox. When something goes wrong at 3am, the operator knows: the bug is in an actor's state transition, and the state at the time of the crash is in the crash dump, fully inspectable. There's no hidden aliasing, no "which reference mutated this object," no shared mutable state between processes. The failure domain is always a single actor.

Positive — Observability is native.

Since actors are gen_servers, standard BEAM tooling works out of the box: sys:get_state/1 to inspect any actor's current state, observer to see process trees and message queues, recon for production profiling. Value objects are plain maps/tuples — they show up legibly in crash dumps, trace output, and log messages without decoding. There are no compiler-generated StateAcc maps or hidden state-threading artifacts to decode.

Positive — Operational semantics match BEAM idioms.

Supervision trees, hot code reloading, and distributed Erlang all assume immutable data flowing between processes. This ADR makes BeamTalk a natural citizen of the BEAM ecosystem rather than a special case. OTP patterns (gen_server, gen_statem, supervisor) apply directly. The operator's existing BEAM mental model transfers without translation.

Positive — Team onboarding from the BEAM ecosystem.

If hiring from the Erlang/Elixir pool — the natural talent pool for a BEAM language — immutability is not a learning curve, it's the absence of one. BEAM developers already think in accumulators, folds, and process state. inject:into: is lists:foldl with Smalltalk syntax. collect: is lists:map. They'd be more confused by mutable semantics on BEAM than by immutability — "wait, this variable changes? On BEAM? How?" Immutability makes BeamTalk a Smalltalk-syntax gateway into the BEAM for operators who already have BEAM teams, rather than a Smalltalk that happens to run on BEAM. The addition of self.slot := and variable rebinding gives these developers an ergonomic shorthand they can use or ignore — the functional model they know is still there underneath.

Concern — Team onboarding from outside the BEAM ecosystem.

With local variable rebinding (ADR 0041) and self.slot := for actors (this ADR), the onboarding curve is significantly reduced. Developers from Python/Ruby/JS can write result := result + each in blocks and self.count := self count + 1 in actor methods — familiar imperative patterns that compile to functional state threading under the hood. The purely functional style (inject:into:, with*: copies) remains available and idiomatic, but is no longer the only way. This is a gentler on-ramp than Elixir offered — Elixir required learning Enum.reduce with no mutable escape hatch, and still succeeded in hiring from the Ruby community.

Concern — Error messages at the immutability boundary.

When a developer tries self x := 5 in a value type method, the error message must be exceptional. Not just "cannot assign to slot on value type" but guidance: "use self withX: 5 to create a new instance with the updated slot." Poor error messages at this boundary would turn a design decision into a daily frustration for the team.

Concern — Library ecosystem.

With self.slot := for actors and with*: for values, library authors have clear idioms for both mutable and immutable patterns. The risk of "poorly-adapted mutable-Smalltalk ports" is reduced — actor libraries can use familiar assignment syntax, while value-type libraries naturally compose through functional copies. The remaining concern is consistency: will the ecosystem converge on idiomatic patterns, or will some libraries use self.slot := where with*: chains would be cleaner (and vice versa)? Style guides and stdlib examples will matter more than language enforcement here.

Steelman Analysis

Best Argument for Alternative 1: Continue with State Threading (Status Quo)

CohortTheir strongest argument
Newcomer"I can't even write a simple accumulator loop? result := 0. items do: [:each | result := result + each] is the first thing I'd try." — Addressed: ADR 0041 rebinding makes this work. Newcomers can write imperative accumulation patterns; functional style (inject:into:) is available but not forced.
Smalltalk purist"Mutable instance variables are fundamental to Smalltalk's 50-year history. The Visitor, Builder, Observer patterns all assume mutable state." — Largely addressed: self.slot := in actors provides familiar mutable-feeling instance variables. Value types are the only departure from Smalltalk convention, and with*: is a reasonable functional equivalent. Classic mutable patterns work naturally in Actor subclasses.
BEAM veteran"ADR 0041 is working. Self-sends need dependency analysis, not language restrictions. Why restrict instead of solving the compiler problem?" — Addressed differently: We didn't restrict — we gave both self.slot := (actor sugar over functional state) AND rebinding (ADR 0041). The compiler does the state threading; the developer writes natural code.
Language designer"You're confusing 'hard to compile' with 'wrong design.' Kotlin and Swift solve harder problems on harder platforms." — Agreed and addressed: self.slot := is exactly the kind of compiler sugar they advocate — imperative syntax, functional implementation. The compiler handles StateN threading transparently.
Operator"If hiring from outside the BEAM world, 'you can't mutate anything' is a hard sell." — Addressed: Actors feel mutable (self.slot :=), rebinding works in blocks, only value types enforce immutability — which BEAM hires already expect. The hiring story is "Smalltalk that feels natural" not "Erlang with different syntax."

Best Argument for Alternative 3: Full Immutability, No Local Rebinding (Gleam-Style)

CohortTheir strongest argument
Language designer"Rebinding is a half-measure. It looks like mutation but isn't — that's confusing, not ergonomic. Gleam proves you can succeed on BEAM with strict immutability. Draw the line clearly: nothing mutates, everything is a new value." — Valid but targets a different language. Beamtalk's identity is "Smalltalk on BEAM," not "Gleam with objects." Smalltalk developers expect mutable-feeling state; BEAM provides the safety underneath. Full immutability optimises for purity at the cost of Smalltalk familiarity — the wrong trade-off for this project.
BEAM veteran"Single-assignment is the Erlang way. Rebinding adds compiler complexity for a feature your core audience doesn't need." — Valid argument, wrong audience assumption. If the core audience were Erlang developers, this would be decisive. But Beamtalk targets developers who want Smalltalk's interactive, object-oriented feel on BEAM — they expect x := x + 1 to work. The compiler complexity (ADR 0041) is the price of that identity.
Compiler engineer"Without rebinding, blocks are pure closures with zero overhead. No StateAcc, no pack/unpack. The compiler is maximally simple." — Valid trade-off, consciously accepted. ADR 0041's state threading is real compiler complexity. We accepted it because the alternative — forcing every developer to learn inject:into: before they can write a loop — contradicts the "accessible Smalltalk" goal. Simpler compiler, harder language is the wrong optimisation.

Best Argument for Alternative 4: Functional-Only Actor State (with*: chains only)

CohortTheir strongest argument
Language designer"Allowing self.slot := in actors but not value types creates two mental models for state." — Partially validated: The underlying model IS uniform — self.slot := compiles to the same operation as with*:. The two mental models concern is real but mitigated: actors allow := because they have a process to hold state; values don't because they're plain maps. The rule is "actors are mutable, values are immutable" — which maps to the Actor subclass: vs Value subclass: declaration the developer already chose.
BEAM veteran"Erlang gen_servers are pure functional state machines. self.slot := hides the functional reality." — Validated and accepted as a trade-off: The generated Core Erlang IS functional state threading (State0 → State1 → StateN). self.slot := is deliberate sugar — the BEAM veteran can read the Core Erlang output and see exactly the functional model they expect. The abstraction leaks, but it leaks in the right direction — toward understanding, not confusion.
Compiler engineer"The compiler still needs to track slot modifications and generate StateN chains." — Validated and accepted: This is simpler than full ADR 0041 state threading through blocks, but it's still state threading. We accepted the compiler complexity because the DevEx payoff is large — self.value := self value + 1 vs threading with*: through every conditional and early return.

Best Argument for Alternative 2: Process-Per-Object

CohortTheir strongest argument
Smalltalk purist"This is the only alternative that actually preserves Smalltalk's object model. Every object has identity, state, and behavior. Alan Kay said it's about the messages — and this is the only design where every message is actually a message." — Philosophically correct, practically catastrophic. A `#(1, 2, 3) collect: [:x
Language designer"Erlang proved millions of lightweight processes work. The overhead argument is about today's BEAM, not tomorrow's." — Processes scale in count, not in churn. Erlang's "millions of processes" are long-lived (TCP connections, session state, supervisors) — not ephemeral arithmetic intermediates. A tight loop creating and discarding Integer processes would GC-thrash the BEAM scheduler. This isn't premature optimisation; it's recognising that 1 + 2 returning a process is a category error. Values are data, not services. The Actor/Value split maps directly to BEAM's natural grain: processes for things with identity and lifecycle, plain terms for everything else.

Note on prior art: No production Smalltalk makes every object a process — not GemStone/S, not Pharo, not VisualWorks. GemStone/S solves shared mutable state through transactions (optimistic concurrency, object locks, MVCC) — objects are database rows, not processes. Inside a single GemStone Gem VM, 1 + 2 is a direct function call, same as any Smalltalk. Every production Smalltalk optimises the hot path (arithmetic, collections, data) into direct dispatch. Beamtalk's Actor/Value split simply makes explicit what every production Smalltalk does implicitly: values are data, actors are services.

Why This ADR's Approach Wins Despite the Steelmans

The core insight: Beamtalk's Actor/Value split surfaces BEAM's fundamental process/term distinction at declaration time rather than letting developers discover it through runtime failures. Every BEAM developer must learn this distinction. On raw Erlang, you learn it by reading OTP docs or debugging production. In Beamtalk, you learn it by choosing Actor subclass: or Value subclass: — and the compiler guides you from there. This is strictly better.

Against Alternative 1 (Status Quo): Every cohort's concern has been addressed — but not by continuing with pure state threading. Instead, self.slot := (this ADR) and local rebinding (ADR 0041) give developers imperative-feeling syntax that compiles to functional state threading. The BEAM veteran was right that ADR 0041 works; what this ADR adds is the Actor/Value classification that bounds the problem. ADR 0041 no longer needs to solve unbounded cross-method state threading — just local rebindings and actor slots through blocks, which is the bounded problem it was designed for. The Kotlin/Swift analogy still breaks down on BEAM (every "mutation" is a copy), but self.slot := sugar means developers rarely need to think about that.

Against Alternative 3 (Full Immutability, No Rebinding): These are valid arguments — for a different language. Beamtalk's identity is "Smalltalk on BEAM," not "Gleam with objects." Full immutability optimises for purity at the cost of Smalltalk familiarity. The REPL must allow rebinding for interactive exploration; compiled code must match. Elixir faced this exact choice and chose rebinding — it was essential to the Ruby-to-BEAM transition. Additionally, ADR 0041's state-threading infrastructure is already implemented, making local rebinding through blocks a solved problem with near-zero marginal cost.

Against Alternative 4 (with*:-only actors): This alternative's concerns were partially validated — the underlying model IS uniform (self.slot := compiles to the same operation as with*:), and the compiler complexity IS real. We accepted these as trade-offs because the DevEx payoff is large. The two models reflect a genuine semantic difference: value types ARE immutable data, actors ARE mutable processes. self.slot := eliminates the silent data-loss footgun where uncaptured with*: intermediates discard state updates. The BEAM veteran's "leaky abstraction" concern applies equally to with*: — both compile to state variable renaming in Core Erlang.

Against Alternative 2 (Process-Per-Object): Philosophically compelling, practically catastrophic. No production Smalltalk — not GemStone/S, not Pharo, not VisualWorks — makes every object a process. GemStone/S solves shared mutable state through transactions (MVCC), not processes; inside a Gem VM, 1 + 2 is a direct function call. Every production Smalltalk optimises values into direct dispatch. The Actor/Value split makes explicit what they all do implicitly. Processes scale in count (millions of long-lived connections), not in churn (ephemeral arithmetic intermediates). 1 + 2 returning a process is a category error, not a performance trade-off.

Tension Points

Alternatives Considered

Alternative 1: Continue with Compile-Time State Threading (Status Quo)

Maintain the current compiler strategy. ADR 0041's universal state-threading protocol is largely implemented, and block composability for HOMs is working. Continue extending state threading to cover remaining edge cases (self-send chains, conditional mutations, deep call chains).

Rejected because: While ADR 0041 solved block composability, the remaining state-threading problems are fundamentally harder. Self-send dependency analysis, conditional mutation reconciliation, and deep call-chain propagation have produced a steady stream of edge cases (BT-868, BT-900, BT-904, BT-894). These aren't implementation bugs — they're inherent complexity from emulating mutable semantics on an immutable platform. The ADR 0041 infrastructure remains valuable (and is retained), but extending it to cover full mutable-object semantics would be an open-ended commitment.

Alternative 2: Process-Per-Object

Every object is a BEAM process. Mutation is process state. Method calls become gen_server calls.

Rejected because: Catastrophic overhead for fine-grained objects. A Point shouldn't spawn a process. Loses synchronous method composition. Makes 2 + 3 into two gen_server calls.

Alternative 3: Immutable Objects, No Local Rebinding (Gleam-Style Full Immutability)

Keep objects immutable and prohibit local variable rebinding. Accumulation patterns require functional combinators only:

"The only way to accumulate:"
result := items inject: 0 into: [:sum :each | sum + each].

"This would be a compiler error:"
result := 0.
items do: [:each | result := result + each].   "ERROR — cannot rebind local"

Rejected because: This creates an inconsistency with the REPL, which must allow rebinding for interactive exploration. Code that works in the REPL would break when moved to a class definition — a pedagogical cliff that Elixir deliberately avoided and that BeamTalk cannot afford. Additionally, ADR 0041's state-threading infrastructure is already implemented, making local rebinding through blocks a solved problem with near-zero additional compiler cost. The purity gain is not worth the consistency loss.

Alternative 4: Functional-Only Actor State (with*: chains, no self.slot :=)

Require actors to express all state transitions through auto-generated with*: functional setters. Prohibit self.slot := inside actor methods — all state updates use the same with*: pattern as value types:

"with*: only — multi-step update requires intermediate variable:"
deposit: amount =>
  | s1 |
  s1 := self withBalance: self balance + amount.
  s1 withTransactions: (s1 transactions add: amount)

Rejected because: The with*: chain pattern for actors has a silent data-loss footgun: forgetting to capture an intermediate variable (self withBalance: ... instead of s1 := self withBalance: ...) silently discards the state update with no compile-time or runtime error. This violates the goal of eliminating hidden complexity and surprising edge cases. Additionally, self withSlot: expr on an actor is a self-send through dispatch — it returns {reply, Result, NewState} and requires tuple unpacking — which is slower than self.slot := expr compiling to a direct maps:put. The functional purity buys nothing for actors: the gen_server already serializes state transitions, so there is no concurrency benefit from treating methods as pure functions. Direct slot assignment (self.slot :=) is both cheaper (no dispatch) and safer (impossible to lose an update) than with*: chains for actor-internal state mutation.

Alternative 5: Trait/Protocol-Based Mutability

Use a trait or protocol to opt into mutability:

Value subclass: Point
  uses: Mutable
  state: x = 0
  state: y = 0

Rejected because: This is just Alternative 4 with extra syntax. The compiler still needs to handle mutable and immutable paths. The complexity isn't eliminated, it's disguised.

Alternative 6: Immutable by Default with mutable Class Modifier

Allow specific value classes to opt into mutable instance variables via a declaration modifier:

mutable Object subclass: Builder
  state: parts = List new

  addPart: part => self.parts := self.parts add: part

This would provide a migration path for Smalltalk patterns that genuinely require mutable state without making all value types mutable.

Rejected because: Any class using mutable reintroduces the full state-threading problem for that class — self-send dependency analysis, conditional mutations, block slot writes. The compiler must maintain dual codegen paths. Composability suffers: a mutable value type passed to a function expecting an immutable value creates a Liskov substitution violation — the caller assumes immutability, but the value can change under it. The clean dichotomy (value = immutable, actor = mutable process) is lost, and the complexity budget returns to the status quo for any codebase that uses mutable classes.

Alternative 7: Frozen/Thawed Builder Pattern

Allow a mutable builder phase followed by immutable freeze:

builder := Point thaw.
builder x: 1.
builder y: 2.
point := builder freeze.   "=> immutable Point x: 1 y: 2"

This would be more ergonomic than chained with*: calls for constructing objects with many slots.

Rejected because: The primary construction path is new: #{...} (map-based, any subset of fields) plus the all-fields keyword constructor (Point x: 1 y: 2), which together handle the multi-field case directly. For modification of existing objects, with*: chains or cascades work and are idiomatic in the functional world (Erlang records, Gleam records, Swift structs). The freeze/thaw pattern adds a new concept (mutable transient objects) that exists only during construction — a narrow use case that doesn't justify the conceptual overhead. If deep nested updates prove painful, lens-like update*: methods (noted as a future enhancement) are a better solution.

Consequences

Positive

Negative

Neutral

Implementation

Phase 0: Wire Check (Proof of Concept)

Phase 1: Language Model Changes (M)

Lexer/Parser:

Parser:

Semantic Analysis:

Phase 2: Codegen Changes (L)

Value Object Codegen:

Actor Codegen:

Phase 3: Runtime Changes (M)

Phase 4: Migration and Testing (L)

Migration Path

Existing Value Types

Current value types (Point, Color, etc.) already behave mostly as immutable values. Migration involves:

  1. Change class declaration from Object subclass: to Value subclass:
  2. Remove any setter methods (replaced by auto-generated with*:)
  3. Replace self x := val with return of self withX: val

Existing Actors

Current actors already use gen_server. Migration is minimal:

  1. Change class declaration to Actor subclass: with state:
  2. Existing self.slot := patterns continue to work — no rewrite needed

Existing Tests

Value type tests using instance variable assignment (self.slot := value) must be rewritten. Actor tests using self.slot := are unaffected. Local variable rebinding continues to work, so many test patterns are unaffected. A migration guide with common pattern translations should accompany this change:

Current PatternNew PatternNotes
self.count := self.count + 1 (in Value)self withCount: self count + 1Value type → functional setter
self.x := val. self.y := val2 (in Value)(self withX: val) withY: val2Value type sequential → chain
self.value := self.value + delta (in Actor)Unchanged — self.slot := allowed in actorsNo migration needed
Object subclass: PointValue subclass: PointSuperclass declaration change
result := 0. items do: [:each | result := result + each]Unchanged — local rebinding still worksOr use inject:into: for idiomatic style

References

Related ADRs

External References

Related Issues