ADR 0130: Class Variables Live in the Class Process During an Invocation

Status

Implemented (2026-10-06); accepted 2026-10-02.

Supersedes ADR 0110 (Class-Variable Shadow Write-Through for Foreign NLR Relay). Amends ADR 0013 §1 (class-variable storage), ADR 0066 (class-side extension fun shape), ADR 0084 (ClassBuilder class-method fun arity), ADR 0109 (what a block running elsewhere may do with class variables), ADR 0111 and ADR 0122 (removes the ClassVars threading family and slot family).

Context

Problem statement

Making class-side self-sends late-bound (BT-3666, 2026-09-30) was correct: a subclass override of a class method must be reached from an inherited method's self foo, which is the template-method pattern. It also made the class-variable implementation unsound in a way that has not converged. In the three days after BT-3666 merged, the follow-ups were:

IssueShapeOutcome
BT-3667Override's write dropped inside blocks, loops, armsFixed by reading the ADR 0110 shadow mid-chain
BT-3675That fix resurrected writes of callees that raised and were caughtReplaced with per-scope tokens (make_ref, commit, export, take, read)
BT-3681Stored closure invoked by a later statement drops the writeNot fixed; compiler warning added
BT-3682Block passed to a class-side higher-order method drops the writeNot fixed; needs a three-way merge, not implemented
BT-3683Write lost when a later iteration's arm is skippedFixed by a scope-read fallback chain
BT-3688False positives and gaps in the BT-3681 warningFixed
BT-3691Sealed class: writing arm plus provably pure arm in a fold loses the writeOpen; four tests pin the wrong answer
BT-3693verify() fails on about 20% of generated class methodsOpen
BT-3694self foo in a whileTrue: condition emits unbound StateOpen
BT-3696Advisory misses sends inside a class sealed method of an open classOpen
BT-3669, 3676, 3690, 3692Open class-side self-send went from about 100-120 ns to 350-470 nsPartly recovered to about 215 ns; target not met

At least 21 BUnit tests in stdlib/test/self_send_override_blocks_test.bt and stdlib/test/self_send_plain_reply_test.bt pin answers that the language documentation says are wrong (3 PIN-BUG BT-3682, 4 PIN-BUG BT-3691, and 14 stored-closure and section-literal limits). ADR 0110 now carries two "known limit" amendments that are dropped writes.

Each fix was correct for the shape in its test and the next nesting broke. This ADR is about why, and about changing the representation so that the bug class cannot be expressed.

How class variables work today

A class's class variables are one flat map, keyed by variable name, in the class_state field of the class's gen_server (beamtalk_object_class, #class_state{}). Between invocations that map is the source of truth, mirrored to the ETS table beamtalk_class_state_snapshot after every change.

During an invocation the map is threaded as a value:

Since BT-3666 the callee's write set is unknown at the call site, so every send must hand its returned map back to the enclosing scope. A scope that can return a value lexically (a straight-line statement, a Letrec loop with a ClassVars parameter, a fold with a {Acc, ClassVars} accumulator) threads it. A scope that cannot (a block literal compiled as a closure, an arm nested in a fold, an on:do: handler, a stored closure, a block given to a user higher-order method) needs an out-of-band channel. Today that channel is:

  1. The ADR 0110 shadow, {'$bt_class_vars_shadow', ClassTag} in the process dictionary, written at every top-frame write and read by invoke_class_method/7 on a foreign non-local-return relay.
  2. The BT-3675 commit map, '$bt_class_vars_commit', a map from a per-scope make_ref() token to a ClassVars value, with class_var_scope_commit/3, read/3, take/3 and export/3.
  3. A pre-call sync before each confined send, class_var_scope_read(ClassSelf, [innermost..outermost tokens], ClassVarsN), minted as a real Bind.
  4. A post-scope refresh after each confined scope, class_var_scope_take with a class_var_scope_read fallback (BT-3683).
  5. A conditional commit that only commits a class_var_result reply (BT-3690), except when an argument contains a block literal.
  6. A purity analysis (compute_class_var_mutating_selectors) that lets a sealed class skip tokens for provably pure sends.

An open class's self foo inside a do: arm now emits, per iteration: a make_ref, a scope read over two tokens, the class_self_direct_ok guard (three persistent_term reads), the call, two cases on the reply, a conditional commit, a take with a nested read fallback, and a commit to the enclosing token. The surveys for this ADR counted about 2,500 to 3,500 lines of codegen source (roughly half comments) and about 170 runtime lines that exist to serve this, with 69 scope call sites across 11 codegen files.

Why it does not converge

The class variables have four representations during one invocation: the lexical ClassVarsN, the shadow, the commit map, and the gen_server state. Correctness requires that, at every read, the newest of them wins. The commit map has no recency order between a fold's returned accumulator, a scope commit, and the lexical copy (BT-3691 is exactly that: the fold's stale accumulator is committed over a newer export). Every new nesting of scope kinds is a new pairwise merge case, and the number of cases grows with the product of the scope kinds.

The ThreadedIr verifier cannot help. It checks that versions are bound and linear (UnboundVersion, NonLinearVersion), not that the value threaded is the newest one. A stale-but-well-formed thread is valid Core Erlang that computes a wrong answer, which is what most of the bugs above are.

ADR 0110 considered the alternative that removes the problem: keep class variables in one place during the call (its Option B). It rejected B because "the real cost of B is the codegen migration ... an L-sized refactor of working, tested code for the same observable fix". That reasoning held when self-sends were statically bound and the only gap was a foreign non-local return. It no longer holds: the threading is no longer working, more than an L has been spent patching it since, and ADR 0110's own steelman conceded that "revert-on-error would be nearly free under Option B too".

Facts that make a single home cheap

From the runtime and codegen surveys done for this ADR:

Constraints

Decision

1. One home

During a class-method invocation, a class's class variables live in exactly one place: the dictionary of the class's own process, under the key {'$bt_class_vars', ClassTag}. There is no lexical copy, no shadow, and no commit map.

invoke_class_method/7 and invoke_class_extension/7, the two entry points every class-method call reaches (the class_method_call and metaclass_method_call messages, and class new:, all funnel through handle_class_method_call/6 to one of them), install the stored map on entry and read it back on exit:

invoke_class_method(Selector, Args, ClassName, _Module, DefiningClass, DefiningModule, ClassVars) ->
    Key = beamtalk_class_vars:key(ClassName),
    beamtalk_class_vars:assert_absent(Key),      %% check first: a present key, or any live home entry, is a nested invocation
    beamtalk_class_vars:install(Key, ClassVars), %% put(Key, ClassVars), put('$bt_class_vars_home', Key)
    try apply_class_method_in_context(Selector, Args, ClassName, DefiningClass, DefiningModule) of
        test_spawn -> test_spawn;
        {ok, Result} -> {reply, {ok, Result}, get(Key)};
        {nlr_relay, Nlr, _ST} -> {reply, {error, Nlr}, get(Key)};   %% writes before a foreign ^ are kept
        {error, Error} -> {reply, {error, Error}, ClassVars}       %% escaping error: pre-call map
    after
        beamtalk_class_vars:uninstall(Key)       %% erase(Key), erase('$bt_class_vars_home')
    end.

install/2 and uninstall/1 are the only writers of the key and the home entry together, and invoke_class_extension/7 uses the same pair. Invariant: the key is absent on entry, checked before anything is installed. Today nothing nests invoke_class_method/7 for one class (it is reached only from the class's handle_call, own-class sends from inside raise dispatch_error, and new/spawn short-circuit through handle_self_instantiation). assert_absent/1 raises a structured internal error when the class's key is already present or when any '$bt_class_vars_home' entry is present (a live invocation of any class in this process), before put/2 runs, so a future refactor that makes nesting reachable, same class or not, fails loudly with the outer map and home entry intact instead of overwriting them; with the invariant checked first, after can simply erase. (beamtalk_actor:restore_dispatch_pdict/1 saves and restores instead because actor self-dispatch does nest; class invocations must not.)

Between invocations nothing changes: the gen_server state holds the map and the ETS snapshot mirrors it.

2. Access is a call into one runtime module

A new runtime module, beamtalk_class_vars, owns the key and every access. Codegen emits calls to it and never builds the key itself, so no rule crosses the Rust and Erlang boundary.

BeamtalkCore Erlang emitted
self.ncall 'beamtalk_class_vars':'get'(ClassSelf, 'n')
self.n where n is late classState: (ADR 0124)call 'beamtalk_class_vars':'get_late'(ClassSelf, 'n'), raising uninitialized_state_error as the guarded maps:find does today
self.n := vcall 'beamtalk_class_vars':'put'(ClassSelf, 'n', V)
self clearField: #ncall 'beamtalk_class_vars':'clear'(ClassSelf, 'n')
self hasField: #ncall 'beamtalk_class_vars':'has'(ClassSelf, 'n')

Each helper derives the key from ClassSelf's class tag and looks it up in the current process. Key presence is the "at home" test, not pid equality. A foreign process never has the home class's key (the home process is blocked in the gen_server:call that carried the block away), the class process outside an invocation has none either, and a self-send within the invocation has it. So get/1 answering undefined raises the structured error in §5, and nothing else is compared. This keeps a closure that captured a ClassSelf before a class-process restart working at home, and makes the direct-called path's ClassSelf = nil trivially safe (it has no class variables to access). Phase 0 measures whether the helpers should be inlined as erlang:get/1 plus maps:get/2 instead.

3. Class methods neither take nor return class variables

The compiled calling convention becomes class_<sel>(ClassSelf, Args...), returning the bare result. {class_var_result, _, _} is deleted from codegen and runtime. A class-side self-send of any kind (sealed or open, direct or walked, super, own-class Base foo) passes nothing and rebinds nothing. Sealing a class changes how a send is dispatched and nothing about class variables.

Codegen no longer has a ClassVars threading family. Loops, folds, arms, handler arms, blocks and stored closures need nothing for class variables, because a write is a put wherever it happens.

Class-side extension methods (ADR 0066) follow the same convention. Today a class-side extension on an Actor subclass is compiled in actor context as fun(Args, Self, State) -> {Result, NewState}, invoke_class_extension/7 passes the class-variable map as that State and stores the returned map, and unwrap_self_dispatch_extension_outcome/3 turns the tuple into a class_var_result for in-process self-sends: a second copy of the map, which is exactly the two-representations shape this ADR removes. Under this ADR every class-side extension fun, whatever the target class's kind, is compiled in class-method context as fun(Args, ClassSelf) -> Result and reads and writes through beamtalk_class_vars; the 3-arity class-side path and the class_var_result arm of unwrap_self_dispatch_extension_outcome/3 are deleted (§6). ClassBuilder class-method funs (ADR 0084) likewise become fun(ClassSelf, Args...).

4. Error semantics: a boundary discards what happened inside it

A class-variable write takes effect when it is made, and an error that crosses a boundary discards every write made inside that boundary. There are two kinds of boundary, and the rule is the same at both. This is Erlang's try semantics applied to class variables: a try yields only its body's value, never the body's bindings, so state threaded through a body that raises is gone, and an error that escapes the callback leaves the gen_server's state as it was.

The mechanism is a snapshot and a restore around the protected region, keyed by the invocation, not by whoever wrote the catch. The two entry points record the key they install under '$bt_class_vars_home' and erase both together; nothing else ever writes that entry. beamtalk_class_vars:snapshot() reads it: with no home entry in this process (a foreign process, a direct-called stateless method outside any invocation, a supervisor or performLocally: snapshot region with no live invocation) it answers none; otherwise it answers {Key, get(Key)}, a pointer to the immutable map. restore(none) does nothing and plants nothing; restore({Key, Map}) is one put. Every compiled on:do: is a catch boundary: codegen emits let Snap = snapshot() in before the existing try, and restore(Snap) as the first statement of the catch arm's non-NLR branch, that is, after both existing $bt_nlr pass-through arms that on_do_catch_preamble (exception_handling.rs) emits, the 3-tuple {'$bt_nlr', Tok, Val} and the actor-shaped 4-tuple {'$bt_nlr', Tok, Val, State}, and before the handler's class filter runs, in every method context, class-side or instance-side, direct-called or not. A ^ therefore crosses the arm without touching the map, and every other exception, matched by the filter or not, restores before anything else happens. The try body itself is untouched, so the actor State and value-type Self lowering of on:do: (exception_handling.rs) does not change and no closure is allocated. For Erlang catchers that take a block, protect(Fun) is the same pair around a fun: Result tryDo:'s native implementation (beamtalk_result:'tryDo:'/1) and any other catch in the runtime or stdlib that invokes a block argument use it, and a hand-written Erlang catcher that runs Beamtalk blocks must use it (the FFI rule in docs/development/erlang-guidelines.md); beamtalk_exception_handler only classifies exceptions and runs no block, so it is not a catcher in this sense. Because the home entry is the invocation's, every catcher inside one invocation protects the same map whatever class wrote the catch: a block from X with its own on:do: invoked inside Y's invocation protects Y's map, the only one present (X's accesses raise there anyway), and Result tryDo: in the same position does the same, so "one rule at one kind of place" holds across classes. The restore happens on any error crossing the protected block, including one the on:do: class filter then declines and re-raises; an ensure: cleanup between an inner non-matching on:do: and the outer catch therefore sees the restored map, and the final state is the same either way. A $bt_nlr throw is not an error and restores nothing: the compiled arm orders both tuple shapes before the restore, and protect/1 tests for both shapes before restoring, so the two agree. with_snapshot/2 installs a class's key and a read-only marker ({'$bt_class_vars_ro', ClassName}, checked by put and clear, never by reads) only when that key is absent, erases exactly what it installed in an after (a raising Fun is the designed path, since a write through the snapshot raises, and the hosting process is long-lived) and nothing when the key was already present (a live map, or an outer snapshot of the same class), and does not touch the home entry, so a performLocally: on Y from inside X's live invocation leaves X's home in place and X's restore still works afterwards; a snapshot never captures a read-only map, because the home entry is only ever an invocation's live key. Outside an invocation snapshot/0 is one get answering none; Phase 0 measures that cost on an instance-side on:do: loop rather than exempting it. The obligation is checked, not trusted: the ThreadedIr verifier gains a CatchWithoutClassVarRestore error for an on:do: node in any method context whose catch arm's non-NLR branch does not begin with the restore, or whose two NLR pass-through arms are not both ordered before it (guideline point 3 in docs/agents/expanded.md). Because the snapshot is taken from the live map and restored into it, there is still one home; the snapshot is scoped to the region and never read by anything else.

Implementation note (BT-3763): the OnDoCatch node owns both halves, rendering let Snap = snapshot() in try ... catch ... as one unit, and CatchWithoutClassVarRestore also fires when the entry let is missing; since the node's single builder verifies and renders it at once, the check guards against a second constructor rather than firing on today's lowering.

What this means for the two places that catch today. Outside the class process, each class-method call is its own transaction: in [X bump. X failingBump] on: Error do: [:e | nil] evaluated from the REPL or an actor, bump's write is committed when its call returns and only failingBump's is discarded. Inside one of X's class methods, the protected region is the transaction: the same expression with self discards bump's write too, because it was made inside the region. That is an asymmetry, in the direction Erlang's try has (a region is one unit), and it is named under Consequences. The failing send itself reverts in both places, as today, and actor state (whose safe_dispatch/3 returns the pre-call state on error, and whose on:do: continues with the state from before the protected block) behaves the same way. The alternative of letting writes survive a caught error, as Pharo does, is discussed under Alternatives.

5. A block writes its home class's variables only at home, and reads what it captured elsewhere

A block literal written in a class method reads and writes the live class variables whenever it runs in its home class's process. That covers loops, conditionals, do:/collect: and every other instance-side collection method, ensure:/on:do:, Result tryDo:, stored closures, a block passed to a user-defined class-side higher-order method of the same class, and a block passed to a direct-called (class sealed, stateless) class method of another class.

A block that runs outside its home invocation cannot reach the live class variables. That is a block carried to another process (the home process is blocked in the gen_server:call that carried it away), and it is also an escaping closure: a block returned or stored by a class method (a sort block, a formatter, a callback, a Future or Timer body) that runs after the invocation that created it has ended and outside any later invocation of its class. A stored closure invoked from a later invocation of the same class is at home again: the key is present, so it reads live and may write, deliberately, since the class process is the one place its variables can be consistent. The rule for such a block is Erlang's rule for a fun: it reads the values the class variables had when it was created, and it cannot write them. At home (the key is present) a read is live; anywhere else it is the captured value; a write anywhere but home raises. When a block is carried synchronously to another class's method or an actor, the home invocation is blocked for the duration, so the captured value is the live value and nothing changes for that code. When it is carried asynchronously (a cast, a Future, a Timer) or stored and run later outside any invocation of its class, the captured value is the one from creation time, which is what an Erlang closure gives and what today's compiler gives too. The fun analogy is deliberately partial: the same stored block run from a later invocation of its own class is at home, reads live and may write, so where a block runs decides which it gets. Today the compiler rejects a direct class-variable write in any block that is not inlined (FieldAssignmentInUnsupportedBlock); under this ADR that rejection goes away, because a block that runs at home can now write, and a write outside a live invocation of the home class raises at run time:

#beamtalk_error{kind = class_state_unreachable, class = 'Counter', selector = bump,
                message = <<"Counter's class variable n cannot be written from this process">>,
                details = #{class_variable => n},
                hint = <<"A block that writes Counter's class variables ran outside any Counter class "
                         "method (it was passed to another class's class method or an actor, or "
                         "stored or returned and run later outside Counter's own methods). A block "
                         "can read Counter's class "
                         "variables anywhere, as the values they had when the block was made, but "
                         "can only write them from Counter's own method: return the value and "
                         "assign it there.">>}

Runtime-owned out-of-process invocations get a read-only snapshot. Four runtime paths deliberately run a class method outside the class process: supervisor definition (beamtalk_supervisor:static_init/2, dynamic_init/2, the withClassMethod: factory) in the supervisor process; the user's class initialize: hook (beamtalk_supervisor:run_initialize/1) in whatever process called supervise, which may be another class mid-invocation; and performLocally:withArguments: (beamtalk_object_class:local_call/3), which today calls the method with ClassSelf = nil and must instead pass the receiver class object it already holds. All wrap the call in beamtalk_class_vars:with_snapshot(ClassSelf, Fun): if the key is already present (the caller is the class process mid-invocation) Fun runs against the live map; otherwise the ETS snapshot (beamtalk_class_state_snapshot, the map as of the last completed invocation) is installed under the key, marked read-only, for the duration of Fun, and removed after. The snapshot is resolved by class name through the registry, never through the pid in ClassSelf, which may predate a restart (the ETS table is keyed by pid today; with_snapshot/2 goes through whereis_class first); a name with no live class raises class_state_unreachable. Reads see what class children needs to see today. A write through a read-only snapshot raises class_state_read_only with a hint naming the entry point, instead of breaking supervisor startup (static_init/dynamic_init), being silently discarded (the factory, initialize:) or failing with badkey (performLocally:) as today. This is Alternative E′'s mechanism (the ETS mirror) kept for runtime-owned call sites only, which run with no invocation live and need the committed state; it is the one deliberate exception to "key presence means at home": inside one of these regions a read is a mirror read by design, including for a block from X carried into a process that then calls X performLocally:. User blocks never read the mirror; abroad they read their creation-time capture.

local_call/3's doc contract changes from "class-variable mutations are discarded" to: reads see the snapshot, writes raise, and a performLocally: reached from inside the class's own method mid-invocation reads and writes the live map. The access helpers (get, get_late, put, clear, has) never accept nil: a nil receiver there is an internal error, not a reachable-state question; get and has on a name that is not a declared class variable of the class raise a structured error rather than a raw badkey. snapshot/0, restore/1 and protect/1 take no receiver and are pass-throughs when no invocation is live; with_snapshot/2 is only reached from runtime sites that always have a real ClassSelf.

The mechanism is a capture at block creation. Codegen binds let CVSnap = beamtalk_class_vars:capture(ClassSelf, Outer) in where any block literal that reads a class variable is created (Outer is the enclosing block's capture, or none at method level), which answers the live map when the key is present and Outer otherwise, so a block created abroad inherits its parent's capture; reads inside the block lower to get(ClassSelf, 'n', CVSnap) (live when the key is present, otherwise maps:get on the capture), has and get_late likewise; writes lower to the ordinary put, which raises when the key is absent. The capture is a pointer to an immutable map, costs one get per block creation, is never written, and is consulted only when the key is absent, so there is no second home and nothing to merge: it is a read-only fallback with no recency question, the same way a captured variable in an Erlang fun has none.

Where the compiler can see the case, it says what the capture means instead of leaving it to run time: a block literal that reads class variables and is passed to an asynchronous send, stored in a variable or class variable, or returned, and a block literal that writes class variables and is passed to a class-side send whose receiver is statically another class that has class state or whose method is not class sealed, each get a class-state-abroad warning at the block (reads: "outside an invocation of this class, reads the values captured at creation"; writes: "outside an invocation of this class, raises"), suppressible with @expect class_state_abroad. This is a lint, not a guarantee; a block passed through a variable or an instance method is only caught at run time, and only for writes.

6. What is deleted

Actor state, value-type Self threading, local-variable threading and non-local return are unchanged. The ThreadedIr keeps every family except ClassVars.

REPL session

Object subclass: Counter
  classState: n = 0
  class bump -> Integer => self.n := self.n + 1
  class hook => nil
  class run =>
    b := [self hook]
    #(1, 2, 3) do: [:i | i > 1 ifTrue: [self bump]]
    b value
    self.n

Counter subclass: LoudCounter
  classState: n = 0          // class variables are per class, not inherited (ADR 0013)
  class hook => self bump

Counter run        // => 2
LoudCounter run    // => 3

On today's compiler (main at 14799bd), this class does not compile: the loop body is rejected with "Cannot send 'bump' to self inside this block ... this block has no way to thread such a mutation back", and b := [self hook] gets the stored-closure warning. With the documented workaround (an unused local mutated in the loop body), it compiles and LoudCounter run answers 2, because the stored closure's write is dropped (run on main at 14799bd; the same shape is pinned by testStoredClosureInvokedLaterLosesWrite).

Error examples

Object subclass: Tally
  classState: total = 0
  class add: x => self.total := self.total + x
  class addAll: items =>
    Batch each: items do: [:x | self add: x]
    self.total

Object subclass: Batch
  classState: runs = 0
  class each: items do: aBlock =>
    self.runs := self.runs + 1
    items do: aBlock
    nil

Tally addAll: #(1, 2)
// => ERROR: class_state_unreachable: a block that reads or writes Tally's class variables ran in another process ...

Today this compiles without a warning and answers 0: the block runs in Batch's process, self add: x runs Tally's method there against a copy of the map, and the writes are silently lost (run on main at 14799bd; a direct self.total := ... in the block is instead rejected at compile time). Under this ADR the class-state-abroad lint warns at the block, and the write raises when the block runs in Batch's process. A block that only read self.total there would answer the captured value, which is the live one since Tally is blocked in the call, as today.

Object subclass: Ids
  classState: next = 0
  class take =>
    self.next := self.next + 1
    self error: "boom"
  class tryTake =>
    [self take] on: Error do: [:e | nil]
    self.next

Ids tryTake        // => 0

The write made inside the protected block is discarded when the error crosses the catch, so tryTake answers 0 today (run on main at 14799bd) and under this ADR. Had tryTake written a class variable before entering the protected block, that write would be kept: boundaries discard what happened inside them and nothing else.

Prior Art

Pharo / Squeak / GNU Smalltalk. A class variable or class-instance variable is a slot of a shared object. Assignment takes effect immediately and nothing is rolled back by an exception, caught or not. This ADR adopts the single mutable home and the immediacy of writes, and rejects the "nothing is rolled back" half (see Alternatives): Beamtalk's class is a gen_server, and a gen_server has boundaries that Smalltalk's image does not.

Erlang/OTP. A gen_server has no rollback mechanism; state is a value threaded through the callback, and "rollback" is continuing with the value you had. That gives two boundaries, and both discard what happened inside them: try yields only its body's value, so the idiom try Body catch _ -> {reply, {error, R}, State} discards the body's updates for free, and an escaping exception discards the whole callback's (by killing the process). §4 is those two boundaries applied to class variables, with the softer "reply with the pre-call map" standing in for the crash. Inside a callback, the process dictionary is the idiomatic process-local store for data a call chain needs without threading it ($ancestors, logger metadata, rand seeds); this ADR uses it the same way, scoped to one callback and erased in after. Mnesia is the nearest precedent for "process-local transaction context, discarded at a boundary": it keeps the active transaction's context in the process dictionary (mnesia_activity_state) and throws the transaction's pending writes away when it aborts.

Beamtalk actors. Actors already hold '$bt_actor_state' in the process dictionary during a call for re-entrant self-dispatch, and an actor self-send that raises returns the pre-call state (safe_dispatch/3's error arm), so an actor's on:do: continues with the state from before the protected block. §4 gives class variables the same behaviour, so the two kinds of mutable state agree on what a caught error does.

Ruby and Kotlin. Ruby class instance variables and Kotlin companion-object fields are plain mutable fields. Writes are immediate and survive caught exceptions, the Smalltalk half this ADR does not adopt.

Clojure refs and Haskell STM. Transactional memory gives each transaction a consistent snapshot and discards a failed transaction's writes. That is the model the current token design approximates per scope. It is rejected as a model for class variables because class methods run serially in one process, so there is no concurrency for transactions to resolve, only error rollback, and the escaping-error case already has a boundary.

User Impact

Newcomer. Class variables behave like variables: a write is visible to the next line, inside a loop, inside a block and after a helper method returns. The stored-closure warning and the "give the loop body a local to thread alongside the class variable" advice disappear. A block can read class variables anywhere, and the one new error appears only when a block carried to another process, or run after its method returned, tries to write one; its hint says what to do.

Smalltalk developer. Reads and writes are the Pharo model: the template-method pattern works with class-side state in every position, and refactoring a write into a helper method no longer changes whether it survives. The one difference from Pharo is that an error crossing a catch or escaping the call discards the writes made inside it, which is the behaviour the language has documented since BT-3675 and the one they will expect from a language whose classes are processes.

Erlang/BEAM developer. The generated code is simpler: a class method is a plain function of ClassSelf and its arguments, and self foo is a plain call. The process dictionary is used inside one gen_server callback, checked absent on entry and erased in after; the actor's '$bt_actor_state' saves and restores instead because actor self-dispatch nests. An Erlang-implemented class method can read and write class variables through beamtalk_class_vars instead of producing class_var_result tuples.

Production operator. The class variables are inspectable mid-call with erlang:process_info(Pid, dictionary) and between calls with sys:get_state/1, as today. Open class-side self-sends lose the class-variable half of their BT-3666 regression here, with the late-binding guard tracked separately; Phase 0 measures both. A module compiled with the older calling convention is refused at load with abi_mismatch, and upgrading across this release needs a node restart (see Implementation).

Tooling developer. The type checker is unaffected: class-variable reads and writes keep the same AST and types. The LSP's diagnostic set changes: a whole class of codegen diagnostics disappears and one lint appears, on every surface equally (docs/development/surface-parity.md). The new runtime errors are class_state_unreachable and class_state_read_only.

Steelman Analysis

Alternative A: Keep the current design and fix the open bugs

CohortStrongest argument
Newcomer"The open bugs are corner cases: stored closures, sealed folds with pure arms. I will never hit them."
Smalltalk purist"The current semantics roll back a send that raised. That is more careful than Pharo."
BEAM veteran"Functional threading is the Erlang way. A process dictionary hides data flow from the reader and from tools."
Operator"The current code is in production and tested by hundreds of BUnit cases. A rewrite resets that."
Language designer"The verifier and token design are general machinery; finishing them is cheaper than replacing them."

Why not. The chain has produced a new bug per nesting for three days, and the remaining open shapes (BT-3691, BT-3693, BT-3694, BT-3682) need new merge rules, not fixes. The verifier cannot see stale-but-well-formed values, so the tests are the only defence, and generated programs find failures in about 20% of class methods.

Alternative B: Make class variables a first-class State family on the ADR 0041 block protocol

Treat a class method's class variables exactly like an actor's State: every stateful block, including stored closures and blocks passed to user higher-order methods, takes and returns them (fun(Args..., StateAcc) -> {Result, NewStateAcc}), and every late-bound class-side self-send returns {Result, NewClassVars} the way safe_dispatch/3 returns NewState.

CohortStrongest argument
Newcomer"Class variables would behave exactly like actor state. One model to learn."
Smalltalk purist"Message sends stay pure functions of their inputs; no hidden global."
BEAM veteran"No process dictionary. Everything is visible in the generated code and checked by the verifier. This is the general fix in ThreadedIr that the project's own guidelines ask for."
Operator"Behaviour stays transactional per send, so no existing test changes its answer."
Language designer"It unifies two threading pipelines into one instead of adding a third representation."

Why not. It keeps the property that causes the bug class: every scope kind must return the newest map, so correctness still depends on every scope kind being threaded, and the actor pipeline has needed its own fixes for the same shapes (BT-3580, BT-912). It is the most expensive option, because every class method body moves onto the actor StateAcc protocol. It is the right question to ask about actor state too, which is why this ADR records it as a follow-up rather than a reason to keep class variables threaded.

Alternative C: One home, plus per-send rollback

Adopt §1 to §3 and §5, but make the send the boundary: each class-side send snapshots the map before the call and restores it if the callee raises.

CohortStrongest argument
Newcomer"A failed message leaves no trace, wherever I catch it."
Smalltalk purist"Message sends are the unit of meaning; a send that fails should be as if it never happened."
BEAM veteran"A try around a call is a few nanoseconds. The snapshot is one get."
Operator"It is the most conservative: no answer changes."
Language designer"It is local to the send, not to the construct that catches."

Why not. Three costs. (a) Per-send rollback has to be emitted by codegen at every class-side self-send, direct call and hierarchy walk alike, which is exactly the layer this ADR deletes; the runtime cannot do it, since self-sends never pass through invoke_class_method/7. (b) The snapshot is a second representation of the map at every send, with the non-local-return pass-through recency question reintroduced at each. (c) Erlang has no send boundary; its boundaries are try and the callback, and a rule that restores at sends but not at catches makes a write in a block followed by a caught raise behave differently from the same write in a helper method.

Alternative S: Smalltalk semantics, writes survive a caught error

Adopt §1 to §3 and §5, with no catch boundary: a write takes effect when made and only an error that escapes the invocation discards it.

CohortStrongest argument
Newcomer"A variable is a variable. If I wrote it, it is written."
Smalltalk purist"This is exactly Pharo. Rollback is a database idea, not a Smalltalk one."
BEAM veteran"No snapshot, no restore, no helper. The cheapest possible implementation."
Operator"One fewer mechanism to reason about in an incident."
Language designer"Fewest rules: one home, one invocation boundary, nothing else."

Why not. It is the one option that does not fit the platform. Erlang's try discards the protected body's state by construction, and actor state already behaves that way through safe_dispatch/3, so S would make class variables the only state in the language that survives a caught error. Its inside/outside difference is also the larger one: under S the failing send itself reverts outside the class process (the gen_server reply carries the pre-call map) and keeps its write inside, so the same single send answers differently by process; under §4 the failing send reverts in both places and only the treatment of earlier completed sends in the region differs. And S changes the answer of about 30 existing tests that pin discard-on-caught-raise. The steelman's cost argument is real but small: the catch boundary costs one get on entry and one put on the error path.

Alternative D: ETS-backed class variables (ADR 0013's deferred option)

Store class variables in a per-class ETS table read and written directly from any process.

CohortStrongest argument
Newcomer"A block passed anywhere can read and write them. No new error."
BEAM veteran"Concurrent reads at ~100 ns and no gen_server bottleneck; ADR 0013 already listed this as the scaling path."
Operator"Visible in observer without attaching to a process."
Language designer"Location-independent state is simpler than process-scoped state."

Why not. It gives up the escaping-error rollback (an ETS write is visible immediately to everyone), so a partially failed class method leaves torn state visible to other processes. It turns class methods from serialized to concurrent, which is a language change of its own. It is a scaling decision for ADR 0013 to revisit, not a fix for this bug class.

Alternative R: Raise on any access outside a live invocation

Adopt this ADR, but make a block that runs outside its home invocation raise on a class-variable read as well as a write, so the only way to use a class variable abroad is to copy it into a local first.

CohortStrongest argument
Newcomer"One rule: class variables only exist inside the class's own methods. No hidden capture to learn."
Smalltalk purist"A read that silently answers an old value is the worst of both worlds; an error is honest."
BEAM veteran"It finds the async-staleness bug at the first run instead of in production."
Operator"No capture at block creation, so nothing to measure."
Language designer"Fewest mechanisms: the key is present or it is not."

Why not. It breaks correct code. A block carried synchronously to another class's method or an actor reads a value that cannot have changed, because the home is blocked for the duration, and that is the common case (Batch each: items do: [:x | x * self.factor]); raising there protects nothing. For the asynchronous and stored cases the captured value is the one an Erlang fun would carry, a defined and explainable semantic, not a bug, and the lint names those shapes. The capture is not a second representation in the sense that failed before: it is never written and never merged, only read when the key is absent. R's real merit, surfacing async staleness early, is kept as the lint.

Alternative E′: Snapshot reads from the ETS mirror

Let a block abroad read the class's ETS snapshot (the map as of the last completed invocation) instead of its creation-time capture.

CohortStrongest argument
BEAM veteran"ETS is where observers already look; one source for every out-of-process read."
Operator"No per-block capture; a stored callback sees the newest committed values."

Why not. The ETS mirror lags the live map by exactly the home invocation's own uncommitted writes, so a block carried synchronously would read older values than the capture does, and a stored block would read values from a different time than the one its author could reason about. The runtime-owned snapshot sites (§5) use the mirror because they run with no invocation live and need the committed state; user blocks get the creation-time capture, which is the Erlang rule.

Tension points

Alternatives Considered

The seven options are described with their steelmen above. In short:

Consequences

Positive

Negative

Neutral

Implementation

Effort: L to XL overall (Phase 3 alone deletes several thousand lines across 11 codegen files and regenerates snapshots). Phase 0 gates the rest.

Phase 0: prove it (S).

Phase 0 census result (BT-3703). Tooling: BEAMTALK_CLASS_VAR_PROBE=1 makes codegen precede every class-variable read or write with a beamtalk_class_var_probe:report/6 call (docs/development/debugging.md § Class-variable probe); with it off the generated .core is byte-identical to main over the whole stdlib, bootstrap and BUnit corpus (just core-diff). The static count is beamtalk_core::class_var_census (test-support, not a shipped surface). Corpora: stdlib/test/** (including fixtures/), test-package-compiler/cases/**, tests/repl-protocol/cases and the tests/repl-protocol/fixtures the scripts :load; just test-bunit, just test-stdlib and just test-repl-protocol were run with the probe on.

The runtime probe (beamtalk_class_var_probe and its codegen hooks) was removed after the census (BT-3765), since every class-variable access now goes through beamtalk_class_vars or an inlined get that raises abroad; the static query beamtalk_core::class_var_census remains, test-support only (cargo test -p beamtalk-core --lib census_over -- --nocapture).

ShapeStatic (escaping closures)Runtime hitsNotes
Returned block reading a class variable33 reads abroad, 3 at homemakeReader in CvsOpen, CvsOverrideBase, CvsSealed (BT-3704 matrix fixtures); the abroad read is the closure called after the method returned
Stored block (local, class variable, literal)3 (all local)3 reads at homerowStoredReadWithin, same three fixtures; the other stored rows write through a self-send (next rows)
Self-send write run abroad with no live home invocationn/a4 writes, 4 reads (method level)CvsOpen/CvsSealed class bump, home_live: false; reached from the BT-3704 stored-closure, async-write and performLocally: rows
Synchronous carry to another classn/a5 writes, 5 method-level reads, 3 in-block readsCvsOpen/CvsSealed class bump through a driver, CvsOpen/CvsSealed/CvsOverrideBase rowSyncReadOpenDriver reads in-block, and ShadowCrossClassOwner class bump (1 write, 1 read) inside CollectionDriver from class_var_nlr_shadow_test.bt testCrossClassMutationDoesNotCorruptForeignProcessShadow
Synchronous carry to an actorn/a0
Asynchronous carry (rowAsyncRead)n/a3 reads in-blockhome_live: true, so the home is blocked in the send that carried the block
performLocally:n/a2 readsCvsOpen/CvsSealed class n, home_live: false
Supervisor definitionn/a1 readClassStateSupApp class children via static_init/2
class initialize:n/a2 writes, 2 readsCvsInitOpen/CvsInitSealed class bump, home_live: false
Class-variable read inside a non-inlined block, at homen/a9 readsthe 3 makeReader, 3 rowStoredReadWithin and 3 rowSyncReadSealedDriver reads (at_home: true; the sealed driver is direct-called in the caller)

Gate. The one supervisor-definition read is one of the with_snapshot/2 sites. Every write except one is made by a row of the BT-3704 matrix (class_var_semantics_*_test.bt), each of which pins today's behaviour with an @expect marker or is a documented PIN-BUG. The exception, ShadowCrossClassOwner class bump, is not a with_snapshot/2 site and pins no silent loss: a block that does self bump (a self-send, so the FieldAssignmentInUnsupportedBlock check, which only sees a direct self.n := in the block, does not apply) is handed to another class's mutating class method, and the test asserts that the write is kept (bumpCount is 1) through ADR 0110's shadow relay. Under §5 that write raises class_state_unreachable, and the test flips; it is listed in the Migration Path below. The static count is dominated by the BT-3704 fixtures themselves, which were added to pin these shapes, so it measures the matrix, not user code: outside it no corpus file builds an escaping closure that reads a class variable.

Known blind spots. (1) The probe sees a method-level access only when it executes outside its home process, so a write through a self-send in a stored closure is visible only when the closure runs abroad. (2) The compiler's hasField: fast path (self hasField: #x in a class method, a maps:is_key on the class variables) was a read the probe did not see during the census; BT-3709's beamtalk_class_vars:has/2 lowering is now probed as a read. (3) The static query scans class methods inside class bodies and standalone Foo class >> sel => definitions in parsed files; .btscript files (54 of them, none an escaping-closure source in the corpus) do not parse as modules, so definitions typed there are not seen. The stored-closure tests (for example SsoStaleBase class storedBump) reach class variables through a self-send (b := [self bump]), whose access sits in the callee at its own method level, so they are not lexical reads: the class-state-abroad lint (BT-3712), which works on the block's own reads and writes, will not fire on them by shape.

Phase 1: tests that pin the semantics (M, in parallel with Phase 2).

Phase 2: runtime (M). Lands as one change with Phase 3, on one branch merged together, not merely in the same release: its helpers are inert until codegen emits the §2 calls, but its removals (class_var_result handling, the n+2 ClassBuilder arity, the 3-arity class-side extension path) break the convention today's codegen still produces, so neither phase can be on main without the other. The ABI gate is the last item of Phase 3.

Phase 3: codegen (L).

Phase 3 invariants for capture and the catch boundary (BT-3711). The guideline in docs/agents/expanded.md § State-Threading Codegen asks for the invariant to be stated for every scope kind. Neither mechanism threads a versioned variable, so the invariant is about what an access and a catch guarantee, and it is the same in every scope:

Scope kindClass-variable accessCatch boundary
Top-level statementInlined hit on the live map; miss is the 2-arity helper (no capture at method level)n/a
Conditional armSame as the enclosing scope (an arm is not a closure)n/a
Letrec loop body, Foldl loop bodySame as the enclosing scope; the loop is inlined and runs at homen/a
on:do: try body, handler armSame as the enclosing scopeThe OnDoCatch node: snapshot/0 before the try; in the catch, both $bt_nlr arms, then restore/1, then the wrap and class filter; the handler arm therefore runs on the restored map
ensure:Same as the enclosing scopeNone: it does not catch
Bare block, stored closureBound at creation as let CVCapture = capture(ClassSelf, Outer) in fun ... when the block (or a block nested in it) reads a class variable; every read inside, in any inlined scope, falls back to get/3, get_late/3 or has/3 with it. A block that reads none binds nothingA closure body is one of the scopes above; an on:do: inside it is a boundary like any other
Nested closureBinds its own capture with the enclosing block's capture as Outer, so a block created abroad inherits its parent'sSame
NLR boundaryA ^ is not an accessThe catch passes both $bt_nlr tuple shapes through before the restore; the verifier checks the order

Capture is generator state for the lexical extent of the closure (CoreErlangGenerator::class_var_capture), set and restored by with_class_var_capture around the two closure emitters (generate_block, generate_block_stateful), so no per-call-site rule exists. The catch boundary is built once, as a ThreadedStmt::OnDoCatch node by on_do_catch_boundary, and verify() reports VerifyError::CatchWithoutClassVarRestore for a node with no entry snapshot let, or whose non-NLR clause does not begin with its restore or whose NLR arms are not both before it. ClassBuilder funs reset the capture (a builder fun runs as a method's top frame, never as a block).

Phase 4: docs and cleanup (S).

Affected components: runtime (beamtalk_class_dispatch, beamtalk_object_class, beamtalk_supervisor, beamtalk_extensions, beamtalk_result, new beamtalk_class_vars), codegen (core_erlang class-method, dispatch, control-flow, block and exception lowering, threaded_ir), semantic analysis (the stored-closure validator removed, the class-state-abroad lint added), docs. Parser, AST and type checker are unaffected; the LSP, REPL, CLI and MCP surface the lint and the new runtime errors through their existing diagnostic and error paths.

Migration Path

Open Questions

  1. Actor state. Caught errors now agree across both kinds of state. Should actor state also move to a single home, so that stored closures and blocks given to higher-order methods behave the same for actors as §5 makes them for classes (BT-3580 is the actor twin of this bug class)? That needs its own ADR.
  2. Verifier visibility in release builds. Resolved by BT-3724 (ADR 0111 Addendum 17). The internal: verifier finding was produced in release builds but dropped by every driver, because they called generate_module. It is now surfaced by the CLI build path and the compiler-port handlers as a warning (category InternalVerifier) that never fails a build. Phase 1's property test therefore fails on a release-build finding at the CLI too.

Implementation Tracking

Epic: BT-3701 Issues: BT-3702, BT-3703 (Phase 0); BT-3704, BT-3705 (Phase 1); BT-3706, BT-3707, BT-3708 (Phase 2); BT-3709, BT-3710, BT-3711, BT-3712, BT-3713 (Phase 3); BT-3714, BT-3715 (Phase 4) Status: Done (Phases 0 to 3: PR #4169; Phase 4 docs sweep: BT-3714)

Follow-ups: BT-3716 and BT-3717 (class-state-abroad lint false negatives), BT-3719 (class-variable key bound once per method; gate 3 and hierarchy-walk perf), BT-3728 (Phase 2 protect/1 catch audit).

References