ADR 0105: Live Image Re-Checking on Hot Reload

Status

Proposed (2026-07-05)

Implementation Tracking

Epic: BT-2775 Issues:

PhaseIssueTitleSizeBlocked by
0BT-2776Walking skeleton — one method → one caller → one LSP diagnostic (proves the 3 assumptions)M
1BT-2777Per-selector signature-generation store + capture plumbingMBT-2776
1BT-2778Re-check orchestration (xref lookup + receiver filter + batched port re-check + findings)MBT-2777
1BT-2779Publish on all surfaces + clearing-by-replacement semanticsMBT-2778
2BT-2780Shape-change re-check (state:/field:), integrated with ADR 0104 spawnWith:/accessor checkingMBT-2778, BT-2779
2BT-2781Fan-out benchmarks + xref receiver-type-key decisionSBT-2778
3BT-2782Pre-save advisory + :recheck image commandSBT-2778
4BT-2783E2E killer-demo test + docs + status flipSBT-2779, BT-2780, BT-2782

Deferred / recorded follow-ups: xref receiver-type-key extension (decided by BT-2781, filed as BT-2798), transitive re-check refinement, proxy meta-dependent tracking, single-method check API. Enabling ADRs (all landed): 0087 (xref), 0082 (edit/save), 0100 (severity), 0022 (compiler port), 0104 (actor/spawnWith: checking).

Status: Planned

Context

Problem statement

Beamtalk's premise is code that changes while it runs — method-level edit & save (ADR 0082), live patching as a message send (principle 11), hot reload as core (principle 2). But the type checker only sees code at compile time, one compilation unit at a time. When a method's signature changes live, every existing caller in the image is now potentially stale — and today nothing tells anyone until the stale call misbehaves at runtime.

This is also the ecosystem's open problem, and Beamtalk's clearest opportunity (docs/internal/positioning.md, Seed 3): Gleam's FAQ concedes upgrades cannot be type-checked ("the usual Erlang amount of safety"); Elixir's inference has no live image to re-check; Dialyzer is post-hoc by construction. Nobody can demo: change a running method's signature and watch the stale callers light up. The infrastructure to do it is unusually cheap for Beamtalk, because the hard part already shipped:

One honest caveat up front: hot-patching currently discards type metadata. put_method/4 deliberately clears the patched selector's signature and return type (beamtalk_object_class.erl — "return types are cleared for hot-patched methods; the compiler treats them as dynamic", confirmed by ADR 0050), and __beamtalk_meta/0 is stale after live patches by design. So the interface delta this ADR needs is new plumbing captured at patch time, not a read of existing state — see Mechanism step 1.

What "stale" means

A live redefinition can invalidate callers three ways:

  1. Signature change — return type or parameter types changed: getValue -> Integer becomes getValue -> String; caller does self getValue + 1.
  2. Removal — the selector no longer exists on the class; callers are now DNU-at-runtime.
  3. Shape changestate:/field: slots added, removed, or retyped; spawnWith: sites and field accessors are affected (with ADR 0104's checking).

Constraints

Decision

On every live definition change (method save, class rebuild, module reload), run an incremental re-check of known dependents and publish the findings as live diagnostics on all surfaces (LSP, workspace UI, REPL notification).

Mechanism

  1. Signature diff — captured at patch time, not read back. The compiler already computes the new method's signature when it compiles the edit; the compile response must carry it, and the workspace must record it per selector before put_method/4 installs the patch (which clears type metadata — see Context caveat). The previous generation's signature is whatever was recorded at the last patch (or the original __beamtalk_meta for never-patched methods). Capturing at every patch is what keeps diffs working across repeated edits to the same method — read-back from class state cannot, because the pre-patch type is wiped on install. This is new plumbing through the compile-request/response protocol and a small new signature-generation store in the workspace.
  2. Dependent lookup — selector query, then receiver filter. beamtalk_xref is keyed by selector only (ADR 0087's schema has no receiver-class component; recv_kind is self/super/ffi/other). So the lookup is: query sites for the changed selector, then filter candidates by inferred receiver type during re-check — a site calling size on a List is not a dependent of Counter>>size. For common selectors this filter is what keeps fan-out bounded; a numeric cap on re-checked callers per reload (with a "N more not checked" note) is part of the design, and extending the xref schema with a receiver-type key is the recorded follow-up if fan-out proves hot (see Alternatives).
  3. Re-check at file granularity, batched. The checker's entry points are whole-Module (check_module / infer_types); there is no single-method check today. The unit of re-check is therefore the caller's enclosing compilation unit, batched into one compiler-port request per reload (the port is a separate OS process, ADR 0022 — ~2ms overhead plus compile time per request; per-caller round-trips would not meet the latency constraint). Sources come from the live image, never disk: ADR 0082 makes in-memory method_source authoritative until Workspace flush, and a flagged caller may itself carry unflushed edits. A single-method check API is a possible later optimisation, not a prerequisite. One level of fan-out only — with an honest caveat: the cutoff is exact for callers with declared signatures, but a caller whose inferred return type silently shifts (it delegates to the changed method, no annotation) can propagate breakage to its callers unchecked. That gap is accepted in this ADR; comparing re-inferred signatures at the cutoff is the recorded refinement.
  4. Publish. Findings appear as diagnostics attributed to the caller's source location, tagged as reload-induced, on every surface (surface-parity applies). Clearing is replacement, not just agreement: every re-check of a caller replaces all of that caller's reload-induced findings with the new result — clean or different — so findings can never refer to a stale interface generation. This one rule covers the two otherwise-missed paths: (a) reload-fixes-reload — reload B restores or adapts the callee, the dependent re-check runs again (Mechanism steps 1–3 fire on every reload), and the caller's finding clears without anyone editing the caller; (b) supersession — back-to-back reloads of the same method re-check the caller against generation B and replace any generation-A finding rather than accumulating contradictory diagnostics. Findings also clear on Workspace changes revert: (ADR 0082) and on workspace restart (session state, never persisted). The Phase 0 napkin must validate the supersession case explicitly. Site-level @expect markers keep their ADR 0100 Rule 3 precedence — an expected DNU stays silent even when reload-induced.

The demo (REPL/workspace session)

bt> :load counter.bt
bt> counter := Counter spawn
bt> counter getCount + 1          // => 1

// ... in the editor: change `getCount -> Integer` to `getCount -> String`,
//     save (method-level hot reload, ADR 0082) ...

⚠ reload check: Counter>>getCount signature changed (IntegerString);
   2 callers re-checked, 1 stale
   Dashboard>>refresh (dashboard.bt:14): `self counter getCount + 1`
     — `+ 1` expects a number, `getCount` now returns String

Only genuinely-affected callers surface. StatsView>>render was the second re-checked caller — it passes getCount to Transcript show:, which expects a String, so it re-checks clean against the new signature and is silently suppressed (the header's "2 re-checked, 1 stale" is the only trace of it).

Error example (removal)

ℹ reload check: Counter>>reset was removed; 1 caller remains
   AdminPanel>>onClick (admin.bt:9): `counter reset` will raise
   does_not_understand at runtime
   (Hint severity per ADR 0100 Rule 1 — single closed receiver)

Severity

ADR 0100 governs — and it must not be silently escalated. Its Rule 1 caps an unresolved selector on a single closed-complete receiver at Hint (its steelman explicitly rejects Warning there to avoid converting quiet hints into warnings overnight). Accordingly:

Reload-induced findings never block the reload (it already happened) and never fail a build (they live in the workspace session, not the artifact).

Prior Art

SystemApproachTake / leave
Erlang/OTPHot reload with code_change/3; type-blindThe substrate; we add the missing feedback loop on top of unchanged mechanics.
GleamFAQ: upgrades cannot be type-checkedThe conceded gap this ADR fills.
TypeScript LSP / watch modeIncremental re-check on file change with a dependency graphThe playbook (principle 12) — but tsc re-checks files against files; Beamtalk re-checks the running image against its own next generation.
Smalltalk (Pharo)Live redefinition, no static re-check; browser marks broken senders only syntacticallyThe liveness model; we add typed feedback Pharo never had.
Rust/hot-patching research (e.g. Erlang's relups, dynamic software update literature)Update-safety proofs, typestate for upgradesSound update-checking is research-grade; deliberately out of scope — we inform, not verify.

User Impact

Steelman Analysis

Tension point

One-level vs transitive re-checking. Transitive is more complete but unbounded in a big image; one-level is fast and catches the direct damage. Start one-level; measure; ADR 0087's benchmarking precedent (BT-2299) applies.

Alternatives Considered

Whole-image re-check on every reload

Simple and complete; unbounded latency as images grow. Rejected as the default; may become an explicit command (:recheck image).

Per-caller (single-method) re-check

The ideal granularity — but no single-method check API exists (the checker's entry points are whole-Module). Building one is real infrastructure. Adopted middle ground instead: re-check the affected caller's whole enclosing compilation unit, batched into one port request (Mechanism step 3). Per-file is coarser than per-method but bounded, and reuses check_module unchanged; the single-method API is a later optimisation if file-granularity latency disappoints.

Extend xref with a receiver-type key

Would make dependent lookup precise instead of selector-wide (Mechanism step 2's filter). Not chosen now — it changes ADR 0087's shipped schema and generation lifecycle for a cost that only matters if common-selector fan-out proves hot in practice. Recorded as the follow-up, with the per-reload caller cap as the interim guard.

Phase 2 benchmark decision (BT-2781, full data in docs/development/benchmarks.md "Reload re-check fan-out"): today's real stdlib fan-out does not yet justify the extension — the worst measured selector (delegate) has 23 distinct caller classes, barely over the default cap of 20, and every other selector is comfortably under it. But a controlled fan-out benchmark against the real beamtalk_recheck:trigger/4 orchestration confirmed the interim cap's known limitation is severe once fan-out does grow past it: at 10x the cap, the alphabetic (non-relevance- ranked) cap silently drops 90% of genuine stale-caller findings. Per- check cost is flat regardless of fan-out size, so the extension would be both a latency and a completeness win once that threshold is crossed. Decision: not implemented now, filed as a proactive (non-blocking) follow-up — BT-2798.

Static-only (no runtime trigger)

Let the LSP re-check open files on edit, ignore the live image. Rejected: the whole point is the image — callers in files nobody has open are exactly the ones people forget.

Sound upgrade verification

Prove reload safety before install. Rejected (see steelman): research-grade, anti-liveness, and off-positioning.

Consequences

Positive

Negative

Neutral

Implementation

  1. Phase 0 (napkin, ~M — this is the risky part, not an ~S): one case end-to-end, proving the three assumptions the reviewers flagged: (a) signature captured from the compile response at patch time (before put_method/4 clears type metadata) and retained across generations; (b) selector-keyed xref lookup + receiver-type filter finds the right caller; (c) a batched whole-file re-check through the compiler port returns a diagnostic within interactive latency. One method, one caller, one LSP diagnostic — reading the caller's live method_source, not disk.
  2. Phase 1 (~M): generalise: per-selector signature store with generation handling (incl. repeated edits to the same method); removed-selector and signature-change findings at ADR 0100 severities; caller cap + "N more not checked" note; surface-parity plumbing (LSP + workspace UI + REPL notice); clearing on re-edit / Workspace changes revert: / restart.
  3. Phase 2 (~M): shape changes (state:/field:) integrated with ADR 0104's spawnWith:/accessor checking (once 0104 lands); benchmarks per the BT-2299 precedent — including the common-selector fan-out case that decides whether the xref receiver-key extension is needed.
  4. Phase 3 (~S): pre-save advisory in the editor (check before install); explicit :recheck image command (surface-parity entry required).

Affected: workspace runtime (beamtalk_workspace, reload/ClassBuilder hooks), beamtalk_xref queries, language service (beamtalk-core), LSP/REPL surfaces. Not: codegen output, production reload mechanics, the artifact build.

References