ADR 0123: Versioned State Migration as a Language Surface

Status

Implemented (2026-09-19) — via Epic BT-3533 (Phases 0–5, BT-3531…BT-3540).

Context

Problem statement

"State of shape N must become state of shape N+1" recurs in four places in Beamtalk's roadmap, and today only one of them has any mechanism at all — and that one has no user-facing hook:

BoundaryState crosses…Status today
Hot reloadcode versions, in one processbeamtalk_hot_reload.erl does structural migration (add defaulted fields, drop removed ones) inside code_change/3. No way for the author to compute a new field from old ones.
Persistencetime (a Kephri-style object store)Planned. Unsafe without a versioned on-disk shape.
Distributionnodes running different code versionsPlanned — BT-3527. Needs a versioned wire shape.
OTP release upgradesappup/relup driving code_change/3 in productionPlanned — BT-3528. Consumes whatever hook this ADR defines.

beamtalk_actor.erl § Code Hot Reload says "Generated actors can override this to migrate state schemas", but no language surface exists to do so. The only sketch in the corpus is ADR 0004's codeChange:state:extra: — stale syntax (trailing end, no =>), never implemented.

Current state

beamtalk_repl_loader:hot_reload_class/2 runs on every class reload:

IVars = fetch_instance_vars(ClassName),          % new field list, from the class registry
Extra = {IVars, ModuleName},
beamtalk_runtime_api:trigger_code_change(ModuleName, Pids, Extra)

and beamtalk_hot_reload:code_change/3, when Extra is {NewInstanceVars, Module}, calls Module:init(#{}) to obtain the new defaults, keeps every old field that is still declared, fills new fields from defaults, and drops the rest with a ?LOG_WARNING. OldVsn is ignored (the loader passes undefined). Five properties of this path matter for the design — and two of them are pre-existing bugs that this ADR's review found and that are worth fixing whether or not the rest of the ADR ships:

  1. It is already the right fallback. Additive, defaulted changes — the common case — need no author involvement, and Squeak/Pharo's ClassBuilder does exactly this (match instance variables by name, default the rest). This ADR layers on top of it; it does not replace it.
  2. Nothing records what version a live state map is. The loader migrates all instances on every reload, so in the happy path every instance is at the current shape. But if one code_change fails (it is caught and collected — trigger_code_change/3 returns {ok, Upgraded, Failures}), that instance is left with an old-shaped map and the next reload has no way to know.
  3. The load/suspend ordering has a window. activate_module loads the new BEAM before trigger_hot_reload suspends each instance. A gen_server calls its callback module fully qualified on every message, so a message that lands in that window runs new code on old-shaped state. OTP's release_handler avoids this by suspending first ({update, Mod, {advanced, Extra}}: suspend → load → code_change → resume) — but see §3 for why that ordering is not adopted here.
  4. Bug: the field list the reload path passes is local-only, so inherited fields are dropped. fetch_instance_vars/1 → beamtalk_runtime_api:instance_variables/1 → beamtalk_object_class's #class_state.fields, which is populated from __beamtalk_meta's 'fields' key — and class_meta.rs builds that from class.state, this class's own declarations only (the same split the stdlib names explicitly: fieldNames is "not inherited", allFieldNames is "including inherited"). But a subclass instance's state map does carry inherited fields, because init/1 calls the parent's init and merges. So migrate_fields/3's NewVarSet = sets:from_list(NewInstanceVars) does not contain the inherited names, and every inherited field is dropped — with a "Hot reload dropped fields" warning — on any reload of a subclass with live instances. beamtalk_hot_reload_tests has no inherited-field case. The flattened list already exists as classAllFieldNames/1 ("Combined field names via superclass chain", beamtalk_behaviour_intrinsics).
  5. Bug: init(#{}) returns a 3-tuple for most real classes, so field migration silently no-ops for them. migrate_fields/3 matches only {ok, NewDefaults}. But the generated init/1 (gen_server/callbacks.rs, init_initialize_guarded_doc) returns {ok, State, {continue, initialize}} — and fires lifecycle start telemetry — whenever the class chain defines initialize or has any typed-no-default field (ADR 0078), unless InitArgs carries '__skip_initialize__' => true, in which case it returns the 2-tuple and skips both. So for every such class the _ -> OldState clause fires and the reload preserves the old map unchanged: new fields are not added, removed fields are not dropped, and nothing is logged. The class registry's field_defaults is no help — it is empty for compiled classes by design ("defaults are baked into the generated init"). The fix is one map key: call Module:init(#{'__skip_initialize__' => true}).

Values (Value subclass: with field:) have no process, so hot reload never touches a live Value; they are only ever re-created by new code. Their versioning problem is entirely the persistence/distribution one: a Value written to disk under shape N read back under shape N+1.

Constraints

Decision

Two additions to the language, one runtime contract, and one tooling rule — on top of two bug fixes (Current state ¶4–¶5) that ship first and stand on their own.

1. shapeVersion: — an explicit, declared version

A class declares its shape version with a class-header clause, exactly as handleScope: is (ADR 0103): parsed after the header and before the class body, conventionally on its own line before any state:/field: lines:

Actor subclass: Cart
  shapeVersion: 2
  state: items :: List = #()
  state: total :: Integer = 0

Explicit, not derived. A fingerprint of the field list cannot be the version: migrations must be named by a number a human can chain (v1 → v2 → v3), and two different field lists can legitimately share a version (a purely additive step). The fingerprint is used — but only by tooling, to detect a shape that changed without a bump (§4).

Why "shape", not "state". stateVersion: reads wrong on a Value (ADR 0067 made state: actor-only vocabulary), version: collides with package versions (Beamtalk version), and ADR 0105 already named the state:/field: slot set "shape". shapeVersion: is the one word that is already in the corpus and kind-neutral.

2. class migrateFromVN: — one class-side method per step

A migration from shape N to shape N+1 is a class-side method named migrateFromVN:, taking the old fields as a Dictionary and returning the new fields as a Dictionary:

Actor subclass: Cart
  shapeVersion: 2
  state: items :: List = #()
  state: total :: Integer = 0

  /// v1 had only `items`; v2 caches their sum.
  class migrateFromV1: old -> Dictionary =>
    old at: #total put: (old at: #items) sum

A rename, which the structural fallback cannot express (it would drop owner and default ownerName):

Actor subclass: Account
  shapeVersion: 3
  state: balance :: Integer = 0
  state: ownerName :: String = ""

  class migrateFromV2: old -> Dictionary =>
    (old at: #ownerName put: (old at: #owner)) removeKey: #owner

A Value, retyping a field (integer cents → float amount) and adding one:

Value subclass: Money
  shapeVersion: 2
  field: amount :: Float = 0.0
  field: currency :: Symbol = #USD

  class migrateFromV1: old -> Dictionary =>
    (old at: #amount put: (old at: #cents) / 100.0) removeKey: #cents

A three-step chain where two steps were purely additive and need no method — the structural fallback covers them at the end of the chain. The one hook must not assume those earlier steps have run: a hook sees the raw accumulated dictionary, and a v1 instance reaching migrateFromV3: has never had #tags (it was added in v3 and only the final shape is known to the runtime), so the hook guards:

Actor subclass: Session
  shapeVersion: 4
  state: user :: String = ""
  state: startedAt = nil        // added in v2 — defaulted at reconcile, no method needed
  state: tags :: List = #()     // added in v3 as a comma string; v4 makes it a List

  class migrateFromV3: old -> Dictionary =>
    (old includesKey: #tags)
      ifTrue: [old at: #tags put: ((old at: #tags) splitOn: ",")]
      ifFalse: [old]              // pre-v3 instance: reconcile defaults #tags to #()

Rules, all checked statically by the class validators:

The naming convention is Beamtalk's equivalent of Erlang's per-version code_change clauses — one selector per version rather than one function with pattern-matched heads — and, unlike a single dispatcher method or a table of blocks, each step is an ordinary method: it has a doc comment, appears in senders/xref, and is callable on its own at the REPL. It is also individually editable — with one caveat that follows from the table being authoritative: a migration installed by a path that recompiles the class (Cart class >> migrateFromV1: …, which recompiles the recorded source; compile:source:) regenerates __beamtalk_meta and participates in the chain; one installed as a bare fun with no recompile (a ClassBuilder addClassMethod:body: after register, or any future fun-only patch path) is invisible to the table and to local_call/3, and does not run. The runtime emits a warning when a class-method fun whose selector matches migrateFromV* exists outside the table — the one place the runtime looks at a selector's name, and only to warn.

Reusing an ancestor's step. Base migrateFromV1: old inside a migration body is an ordinary class send: it goes through Base's class gen_server (class_send/3), not through local_call/3. That is safe during a reload of Cart — class processes are updated synchronously by register_class/0 at load time, before any migration runs, and are never suspended — but it is serialised through Base's process and subject to the class-call timeout, and inside Base's process class variables are visible, unlike in the chain proper. Authors who want the exact chain semantics can write Base performLocally: #migrateFromV1: withArguments: #(old) (ProtoObject >> performLocally:withArguments:, the Beamtalk surface of local_call/3). Lowering migrateFromVN: sends inside migration bodies to local_call/3 automatically is the recorded refinement; it is not done here because it is precisely the call-site interception ADR 0109 chose not to generalise.

3. Runtime contract

Two new runtime modules, split so the shared-leaf rule is actually honoured rather than merely invoked:

(Neither is named beamtalk_shape: beamtalk_shape_diff, beamtalk_workspace_shape_store, beamtalk_workspace_shape_recheck_worker and beamtalk_workspace_reshape already exist with ADR 0105's meaning of "shape" — the declared slot set — and a bare beamtalk_shape meaning "versioned migration" would confuse every future reader.)

%% beamtalk_shape_migration
%% Run the chain from FromVersion to the class's current shapeVersion, then
%% reconcile against the flattened declared fields. Fields is user fields
%% only (no internal keys). Runs in the calling process.
-spec migrate(Class :: atom(), FromVersion :: pos_integer(), Fields :: map()) ->
    {ok, NewFields :: map(), ToVersion :: pos_integer()} | {error, #beamtalk_error{}}.

%% Versioned envelope for disk and wire.
-spec pack(Instance :: map()) -> {ok, envelope()} | {error, #beamtalk_error{}}.
-spec unpack(envelope()) -> {ok, Instance :: map()} | {error, #beamtalk_error{}}.
-type envelope() :: {beamtalk_shape, Class :: atom(), ShapeVersion :: pos_integer(), Fields :: map()}.

Chain semantics (migrate/3), in order. migrate/3 takes a class, not a module: beamtalk_shape_migration resolves Module from Class through the class registry (beamtalk_class_metadata:lookup_module/1 — class_name() -> {ok, module()} | not_found — the same mapping beamtalk_actor, beamtalk_class_dispatch, beamtalk_class_instantiation and beamtalk_supervisor already use for atom→module resolution), so the envelope — which carries only the class atom — and hot reload call the same function.

  1. V = FromVersion, T = Cart shapeVersion (read from __beamtalk_meta).
  2. For each K in V, V+1, …, T-1: if 'shape_migrations' has K, apply migrateFromVK: to the current dictionary; otherwise the step is a no-op — nothing is defaulted or dropped between hooks, because the runtime knows only the final declared shape, not what shape K+1 was. The result of a hook must be a Dictionary, else the step fails.
  3. Reconcile against the declared field list — the flattened one (classAllFieldNames/1 semantics, walking the superclass chain), not today's local-only instance_variables/1 list (Current state ¶4). Defaults come from Module:init(#{'__skip_initialize__' => true}) — the 2-tuple, no-telemetry branch (Current state ¶5) — never from bare init(#{}). A declared field present in the dictionary is kept; absent → its declared default; absent with no default → nil on an untyped class, failure on a typed class (the same post-initialize validation ADR 0078 runs — a migration may not leave a typed slot unset); an undeclared key → dropped with ?LOG_WARNING, as today.
  4. Return {ok, NewFields, T}.

T < V (a downgrade, OTP's {down, Vsn}) runs step 3 only and logs a warning. Downgrade hooks (migrateToVN:) are reserved, not defined; BT-3528 decides whether relups need them.

V =:= T runs step 3 only, so migrate/3 is idempotent on already-current state. A single hook need not be idempotent — it runs exactly once per instance per step, because the version is written with the state.

Failure is a #beamtalk_error{kind = shape_migration_failed} carrying the class, the step (from/to), and the underlying error, with a hint naming the method ("Cart class >> migrateFromV1: raised …").

Migrations run in the migrating process, not the class's gen_server — and the mechanism already exists: beamtalk_object_class:local_call/3 ("Execute a class method in the caller's process… calls Module:class_<Selector>(nil, #{}, Args) directly — bypassing the class object's gen_server"; its Beamtalk surface is performLocally:withArguments:). That matters for more than latency: it keeps a migration from being serialised through, or deadlocking on, the class process.

local_call/3's existing contract is also why the class-variable rule is a compile error rather than a convention. It passes nil as ClassSelf and #{} as the class variables, and discards any {class_var_result, Value, NewClassVars} mutation the method returns. A migration that read a class variable would therefore silently see nil, and one that wrote would have the write silently dropped — wrong data, no error. The validator must reject both at compile time so that contract is never reached by accident — the same lesson ADR 0109's BT-3047 amendment drew for class identity (never read it from the executing process; it must be supplied). local_call/3 also does not walk the superclass chain, which matches "chains are per concrete class" exactly.

Not a general "class methods run in the caller" rule: ADR 0109's Scope explicitly excludes generalising its call-site interception, and this ADR does not widen it. local_call/3 is an existing, narrowly-contracted entry point for exactly the "method does not touch class state" case, which the validator enforces.

Hot reload (beamtalk_hot_reload + beamtalk_repl_loader):

Envelope (pack/1, unpack/1) — the contract BT-3527 and the persistence ADR consume:

4. Tooling

Compile time (class validators, beamtalk-core): the literal/duplicate rules for shapeVersion:, the arity/class-variable rules for migrateFromVN:, and the unreachable-migration warning (§2).

Reload time — built on ADR 0105's shape machinery where it actually lives: in Erlang, in beamtalk_workspace, not in the language service. beamtalk_workspace_shape_store:capture/1 already stores a per-generation shape() (#{FieldName => TypeName}) per class, and beamtalk_shape_diff:diff/2 already classifies each field as {added, F} | {removed, F} | {retyped, F, Old, New}. That is the fingerprint §4 needs; the additions are (i) capturing the flattened shape, so an ancestor's change shows up in every concrete subclass's diff (the diff is in a class the author may not be looking at, which is exactly the case worth catching), (ii) joining the diff with the class's 'shape_version' across generations, and (iii) the migration outcome from trigger_code_change/3. The language service's part is LSP completion and hover only. Findings go through the existing reload-findings channel on every surface (LSP, workspace UI, REPL), as a structured payload (class, kind, fields, counts, pids) that each surface renders — the REPL text below is one rendering, per docs/development/surface-parity.md:

Reload observedFinding
Field removed or retyped, shapeVersion: unchangedWarning — shape of Cart changed (dropped: #discount) but shapeVersion is still 2; 3 live instances lost #discount — bump shapeVersion: and add class migrateFromV2:
Fields only added with defaults, version unchangedHint — structural fallback applies; nothing to do
Version bumped N→N+1, no migrateFromVN:Hint — Cart shapeVersion 2 → 3 has no migrateFromV2:; structural fallback applies
Version decreasedWarning
Any instance left suspended by a failed migrationError — it records a failure that has happened, not a prediction: 2 instances of Cart suspended: migrateFromV1: raised … (Cart class >> migrateFromV1:) — fix the hook and reload again, or Workspace actorAt: kill

All advisory (ADR 0100); the reload proceeds. Behaviour >> reload keeps its return value; the finding is the report.

Why advisory, when ADR 0113 gates file deletion behind confirmDestructive. Dropping a field from live instances is irreversible data loss and, unlike a .bt file, not recoverable from git — so the asymmetry needs stating. Two reasons it is nonetheless advisory: a reload is an action that has already happened when the re-check runs (ADR 0105's governing constraint — a post-hoc gate cannot veto it), and the place a consent-like gate genuinely can live is before the install: ADR 0105's pre-save advisory (precheck, Phase 3) sees the edit before the reload, so the "dropped without bump" warning fires there too, in the editor, while the author can still add the migration. A hard tier-2 gate on reload of a class with live instances losing fields — ADR 0113's model — is a plausible later addition and is recorded, not decided.

REPL-level testing — the pieces are ordinary methods, so a migration is exercised before any instance depends on it:

bt> Cart shapeVersion
=> 2
bt> Cart migrateFromV1: #{#items => #(3, 4)}
=> #{#items => #(3, 4), #total => 7}
bt> Cart migrateShape: #{#items => #(3, 4)} from: 1      // whole chain + reconcile
=> #{#items => #(3, 4), #total => 7}
bt> Cart reload
=> Cart
ℹ reload check: Cart shape v1 → v2; 3 instances migrated

migrateShape:from: is a sealed Behaviour class-side method over beamtalk_shape_migration:migrate/3 (a beamtalk_behaviour_intrinsics entry, like every other Behaviour reader); the shown reload notice is illustrative and is confirmed with the REPL's existing display conventions at implementation.

Error examples

Actor subclass: Cart
  shapeVersion: "two"
// ⛔ error: shapeVersion: expects a positive integer literal, got a String

Actor subclass: Cart
  shapeVersion: 2
  shapeVersion: 3
// ⛔ error: duplicate shapeVersion: declaration (already 2)

Actor subclass: Cart
  shapeVersion: 2
  class migrateFromV7: old => old
// ⚠️ warning: migrateFromV7: is unreachable — Cart's shapeVersion is 2

Actor subclass: Cart
  shapeVersion: 2
  state: items = #()
  classState: taxRate = 0.2
  class migrateFromV1: old => old at: #tax put: (old at: #total) * self.taxRate
// ⛔ error: migration methods may not access class variables (`self.taxRate`);
//    they run outside the class process, where class variables are not available

At reload, a hook that raises:

bt> Cart reload
=> Cart
⛔ reload: 1 instance of Cart left suspended — migrateFromV1: raised
   does_not_understand: List>>summ (Cart class >> migrateFromV1:, cart.bt:9)
   state intact at v1; fix the hook and `Cart reload` again, or `Workspace actorAt: kill`

Prior Art

SystemMechanismTake / leave
Erlang/OTPcode_change(OldVsn, State, Extra) written by hand, one clause per OldVsn; -vsn attribute; appup {advanced, Extra}; release_handler suspends before loading; a failed code_change fails the upgrade, not the processTake the per-version clause idea (one selector per step), Extra as the loader's channel, and "a failed upgrade is a failed upgrade" (the suspended-on-failure rule). Leave OldVsn as the version source: it is a module attribute, and method-only changes must not bump a shape version. The state carries its own version. Suspend-all-first is deferred to BT-3528, not taken for the workspace (§3).
Elixir / EctoSame code_change; Ecto migrations are numbered, ordered, one function each (change/0)Take the "numbered, ordered, one function per step, gaps are fine" model. Ecto versions a database, not process state — the step shape transfers, the timestamp naming does not.
Squeak / PharoClassBuilder migrates live instances on shape change by matching ivars by name, defaulting the rest (updateInstancesFrom:); serialised objects use Object>>convertToCurrentVersion:refStream: with a varDict of old ivars; Fuel has FLMigrationTake name-matching structural migration as the always-on fallback (it already is), and the "dictionary of old ivars" argument shape. Adapt: Pharo's hook is instance-side on a half-built object; on the BEAM a class-side function needing no process is REPL-callable and works for Values too.
GemStone/SVersioned classes coexist (ClassHistory); migrateFrom:instVarMap: on the new instance; migrateInstances walks the repositoryTake explicit versions and the instance-map hook. Leave class coexistence: BEAM has exactly two module versions, and reload migrates every instance eagerly.
GleamFAQ: hot reload is "the usual Erlang amount of safety"; no schema hookThe gap.
Akka PersistenceSchema evolution via EventAdapters and serializer manifests; "additive changes free, everything else explicit"Take the additive-is-free rule (our structural fallback + defaults). Akka versions events; we version state, which is the simpler problem.
Protobuf / AvroField numbers/defaults make additive evolution free; renames and retypes are explicitSame rule, confirms it from the wire side — and why pack/1 needs a version, not just a field list.
Livebook / JupyterNo state migration: re-evaluate, re-deriveThe "no image" persona; explains why the workspace must never block a reload on a shape change.

User Impact

Steelman Analysis

Version: header clause (chosen) vs class shapeVersion => 3 vs derived fingerprint

CohortFor class shapeVersion => 3For a derived fingerprint
🧑‍💻 Newcomer"It's just a method — same as supervisionPolicy; I don't learn a keyword.""I never have to remember to bump anything."
🎩 Smalltalk purist"Class-side version is a forty-year convention; declarations belong in methods."— (Pharo has no shape version either)
⚙️ BEAM veteran"Overridable, so a subclass can compute it.""Erlang already derives vsn from an MD5 when you don't declare one."
🏭 Operator"Cart shapeVersion works either way.""Impossible to forget to bump."
🎨 Language designer"One fewer keyword.""Zero syntax."

Why the header clause still wins: the compiler needs the value statically (meta, fingerprint, unreachable lint), which for a method means a "must return an integer literal" rule — a method that isn't really a method. The fingerprint fails the chaining requirement outright: you cannot write migrateFrom<hash>:, and a purely additive change would produce a new version with nothing to migrate. Its real strength — catching the forgotten bump — is kept as the §4 warning, which ADR 0105's beamtalk_shape_diff already mostly computes.

Hook: per-version methods (chosen) vs one table vs one dispatcher vs instance-side

Cohortclass shapeMigrations => #{1 => [:old | …]}class migrateShape: old from: vinstance-side migrateFromV1: old
🧑‍💻 Newcomer"All the history in one place, in order.""One method to find.""I can just write self.total := …."
🎩 Smalltalk purist"Blocks are the Smalltalk way to defer code."—"This is convertToCurrentVersion:; migration belongs to the instance."
⚙️ BEAM veteran"Reads like an appup.""This is code_change/3 with case."—
🏭 Operator"One diff hunk per release.""Grep one selector."—
🎨 Language designer"Data, not naming convention.""No magic in selector names.""Uniform with initialize."

Why per-version methods still win: the table and the dispatcher put every version's logic in one method body, so doc comments, xref, and per-method edit all lose their unit; the dispatcher additionally forces version = 1 ifTrue: [^…] chains. The instance-side form is the strongest rejected option — but it needs a process (no REPL test on a Value), runs on a half-migrated self, and gives actors (self.x :=) and values (self withX:) two idioms for one feature. The convention cost ("magic name") is contained: the compiler emits the table, so the only runtime code that looks at a selector name is the warning for a fun installed outside it.

Failure: leave suspended (chosen) vs stop the actor vs resume with old state

CohortStop the actorResume with old state
⚙️ BEAM veteran"Let it crash; the supervisor is the policy.""It's what today's code does; the next message's error is diagnosable."
🧑‍💻 Newcomer—"Don't freeze my actor; at least it answers."
🏭 Operator"A dead process is a clear signal.""No timeouts cascading into callers."
🎨 Language designer"Fail fast, fail loud.""Least mechanism."

Why suspended wins: "stop" is unachievable from inside code_change/3 (sys catches it) and, driven from outside, is unrecoverable under the default #temporary policy and silently lossy under #permanent (fresh init/1 state reads as success). "Resume with old state" — today's behaviour — is new code on an old-shaped map; the errors surface later, far from the cause, and a handler that writes state in that window leaves a hybrid map the retried migration cannot reason about. Suspended is the only outcome that loses nothing and retries cleanly, and it is what OTP's own release handling does. Its cost — callers time out until the author acts — is real and is in Consequences.

Tension points

Alternatives Considered

Do nothing

Keep the structural fallback and add no surface. Genuinely worth stating, because three of the four motivating boundaries are unbuilt and the fourth already handles additive change. Rejected, but with two things carried over from it: the two pre-existing bugs (Current state ¶4–¶5) are fixed first and alone — they are causing data loss today and need no new surface — and the envelope and hook are phased so each ships value independently (Implementation). What "do nothing" cannot give is rename, retype, or derive, which are the three cases the persistence and distribution ADRs need and which block BT-3527/BT-3528 on this ADR.

Bug fixes only (the incremental baseline)

Fix the inherited-field drop and the init(#{}) 3-tuple no-op; add '__shape_version__'; stop there. This is the first implementation phase of the chosen design, shipped on its own — so it is not so much rejected as sequenced first. It is the honest baseline against which the rest of the ADR's value should be measured.

Erlang-style manual code_change (@native actor or an Erlang override)

Write code_change/3 by hand in a backing module. Rejected: not a language surface, actor-only (no Values, no persistence), and it is exactly the "can override" promise that has gone unused because it forces the author out of Beamtalk. (It remains the only route for native: classes, §3.)

Ecto-style migration files

Separate migrations/ files keyed by timestamp. Rejected: Beamtalk's unit of code is the class (ADR 0040); a migration that lives away from the state: lines it explains is the Pharo "senders of" problem in reverse. The numbered-step model is kept, in the class.

Pharo ClassRedefinition / instVarNamed: (structural only)

Keep only name-matching migration, add nothing. Rejected: it is already the fallback, and it cannot express rename, retype, or derive — the three cases the persistence and distribution ADRs need.

GemStone-style class versioning (old and new classes coexist)

Rejected: BEAM holds at most two versions of a module, and the workspace reloads every instance eagerly; coexistence would need a second class registry entry per version for no benefit the eager chain does not give.

Storing '__shape_version__' on Value instances too

Would let a live reload of a Value class find stale embedded values. Rejected for v1: a per-instance key on every Value costs memory and breaks structural equality between a pre- and post-reload Point with equal fields. Values are immutable and re-created by code; their versioning need is the envelope. Recorded as a deferred follow-up (Consequences).

Ship the migration chain now, defer the envelope (narrower scope)

Hot reload — the only consumer that exists today — migrates a live map in place and never serialises anything, so a narrower ADR could ship §1–§2 plus migrate/3 and leave pack/unpack to the ADRs that consume them. Rejected for one reason: BT-3527 and BT-3528 are blocked on this ADR precisely for the envelope, and defining it separately in each would duplicate the version/tier rule across two documents — the exact shared-leaf failure this project's architecture principles name. The envelope also constrains the design above it in a useful way: migrate/3 takes a version and a plain field map because an envelope must be able to call it with no OldVsn and no live process. It stays — as its own phase, shippable alone, with EUnit but no production consumer.

Single dispatcher, table of blocks, instance-side hook

See Steelman Analysis.

Consequences

Positive

Negative

Neutral

Migration Path

No .bt source changes are required, anywhere. An absent shapeVersion: is v1 and the structural fallback is unchanged, so every existing class in the stdlib, the tests, and downstream projects keeps compiling and reloading exactly as today. There is no deprecation period because nothing is deprecated.

Three internal changes need coordinated updates, all inside this repo:

ChangeWho updatesWhen
Reconcile uses the flattened field list and init(#{'__skip_initialize__' => true}) (the two bug fixes)beamtalk_hot_reload, hot_reload_class/2, beamtalk_hot_reload_testsPhase 0
Extra becomes #{module := atom()} ({NewInstanceVars, Module} → the field list is derived, never passed)same threePhase 0, one commit — no transitional tuple clause, since the only caller is in-tree
Failed migration leaves the instance suspended instead of being resumed on old statetrigger_code_change/3's resume path; workspace reload reporting; any test asserting resume-on-failurePhase 0

The two bug fixes are behaviour changes users will notice and want: a subclass with live instances stops silently losing its inherited fields, and classes with initialize start getting field migration at all. They are called out here rather than buried because anything that came to depend on the old behaviour (nothing in-tree does) would see different state after reload.

Implementation

Phases sized for /plan-adr, ordered so that each ships value on its own and the riskiest decision (the language surface) comes after the cheap wins are banked:

  1. Phase 0 — bug fixes + version key (M, ships alone): flattened field list via classAllFieldNames/1 semantics; init(#{'__skip_initialize__' => true}) as the defaults source; '__shape_version__' in internal_fields/0 with read-before-seed ordering and the never-on-a-Value test; Extra reduced to #{module}; descendants walk via direct_subclasses/1; failed code_change leaves the pid suspended (conditional sys:resume). Reads 'shape_version'/'shape_migrations' from __beamtalk_meta with defaults 1/#{}, so it works before the compiler emits them. Tests: beamtalk_hot_reload_tests gains inherited-field preservation, a class with initialize, a class with a typed-no-default field, subclass-on-superclass-reload, and failure-stays-suspended; one REPL-protocol case.
  2. Phase 1 — hook napkin (S, the risky assumption): one hand-written class_migrateFromV1: export in a fixture module, one live instance, through a real Cart reload, invoked from inside code_change/3 via beamtalk_object_class:local_call/3 while that class's own module is being reloaded — the one ordering none of local_call/3's existing callers exercise. No keyword, no validators, no envelope. If this fails, the class-side-function hook is the wrong shape and the ADR is revisited, which is why it is not folded into Phase 3. Tests: one EUnit case, one REPL-protocol case.
  3. Phase 2 — chain + envelope (M, unblocks BT-3527): beamtalk_shape_chain (pure leaf) and beamtalk_shape_migration (migrate/3, pack/1, unpack/1, the tier walk over flattened field types); beamtalk_hot_reload:code_change/3 delegates to migrate/3. Tests: EUnit for chain order, gaps, downgrade, idempotency, typed-slot failure, not_serialisable, nested-Value pack/unpack, builtin-tagged-map pass-through.
  4. Phase 3 — language surface (M): shapeVersion: header clause (parser via the handleScope: path, ClassDefinition, unparser, class_definition_text/7, ClassBuilder shapeVersion:), validators (literal, duplicate, migrateFromVN: arity/class-variable/unreachable, native: refusal), meta emission of 'shape_version' and 'shape_migrations' (compiler and ClassBuilder), Behaviour shapeVersion and migrateShape:from: intrinsics, init/1 writing the version, the fun-outside-table warning. Tests: parser/unparse round-trip incl. flush skeleton, validator diagnostics, codegen meta snapshot, BUnit for migrateShape:from: on a Value.
  5. Phase 4 — findings (M): flattened shape capture in beamtalk_workspace_shape_store; version-aware join over beamtalk_shape_diff:diff/2; migration outcomes from trigger_code_change/3; the five findings as a structured payload on every surface (surface-parity.md entry); pre-save advisory hook; LSP completion for migrateFromV<N-1>: after a bump; hover showing the chain. Tests: one workspace-level test per row of the §4 table; LSP completion test.
  6. Phase 5 — docs and e2e (S): beamtalk-language-features.md § Live Patching gains a Shape Versioning section; REPL-protocol e2e extending the existing hot_counter.bt / hot_counter_v2.bt fixtures to a v1 → v2 → v3 chain with a failing step and a recovery reload; status flip.

Affected: crates/beamtalk-core (parser header-clause path, AST, validators, unparse), crates/beamtalk-codegen (class_meta.rs, init/1), crates/beamtalk-language-service (completion, hover only), runtime/apps/beamtalk_runtime (beamtalk_shape_chain and beamtalk_shape_migration new; beamtalk_hot_reload, beamtalk_tagged_map, beamtalk_behaviour_intrinsics, beamtalk_object_class), runtime/apps/beamtalk_workspace (beamtalk_repl_loader incl. class_definition_text/7, beamtalk_workspace_shape_store, beamtalk_shape_diff, recheck), stdlib/src/behaviour.bt, stdlib/src/class_builder.bt. Not: method dispatch, expression codegen, the artifact build, native: classes.

Deferred: downgrade hooks (BT-3528); suspend-all-first ordering (BT-3528's release_handler); a dispatch-time version guard for the workspace window; Value-instance versioning or deep-reconcile on live reload; cross-node wrapping policy (BT-3527); rename-aware envelope resolution and a migrateFromV* guard in ADR 0114's rename validator; a composed per-ancestor chain; automatic local_call lowering of ancestor reuse; an ADR 0113-style hard gate on lossy reloads.

Implementation Tracking

Epic: BT-3533 Issues:

PhaseIssueTitleSizeBlocked by
0BT-3531Hot reload drops inherited fields / never reaches subclassesM–
0BT-3532Hot reload field migration silently no-ops (init(#{}) 3-tuple)S–
0BT-3534'__shape_version__' tracking + suspend-on-failure semanticsMBT-3531, BT-3532
1BT-3535Hook napkin: prove local_call/3 during a class's own reloadSBT-3534
2BT-3536beamtalk_shape_chain + beamtalk_shape_migration: migrate/3, pack/1, unpack/1MBT-3535
3BT-3537shapeVersion: header clause + migrateFromVN: language surfaceMBT-3536
4BT-3538Shape-change reload findingsMBT-3537
4BT-3539LSP completion + hover for the migration chainSBT-3537
5BT-3540Docs + REPL-protocol e2eSBT-3538, BT-3539

All nine issues shipped; the epic is closed.

References