ADR 0095: Rich Navigable Inspector — Drillable, Live-Refreshing Object Views

Status

Implemented (2026-06-11)

Implementation Tracking

Epic: BT-2501 — Rich Navigable Inspector v1 (ADR 0095) Design issue: BT-2397 Status: Implemented (2026-06-11)

PhaseIssueTitleSizeStatus
1BT-2502Inspector core — Inspector/InspectorField classes + navigation (#value/#actor, drill, cycle guard, render)LDone (#2520)
2BT-2503Collection + foreign kinds, windowing, value evaluate:MDone (#2521)
3BT-2504Repurpose inspectInspector — migration, lint, MCP/REPL surfacesLDone (#2522)
4BT-2505Language docs, BUnit, REPL e2e btscript; flip ADR → ImplementedMDone
Wave 1: BT-2502  (#2520, merged)
Wave 2: BT-2503  (#2521, merged) → BT-2504 (#2522, merged)   (serialised — both touch the Inspector class)
Wave 3: BT-2505                   (epic closer → flips Status to Implemented)

Deferred follow-ups (ADR §7 — not in this epic): actor evaluate-in-context (snapshot-proxy / live routing, ADR 0091 gated), per-class inspectorFields custom views, sealedFromInspection (parser-free class-side method), and push live updates (BT-2489). Browser consumer: Cockpit BT-2486 / BT-2492.

Context

The problem

Beamtalk can render an object (ADR 0094: printStringPoint(x: 3, y: 4) / Actor(Counter, 0.123.0)) and it can enumerate the running system (ADR 0092 ProcessNavigation; ADR 0093 AnnouncementNavigation). What it cannot do is let a developer navigate into a single object the way a Pharo Inspector does:

Today there are two impoverished paths and nothing in between:

  1. Agent-only typed JSON. The REPL "op": "inspect" (beamtalk_repl_ops_actors.erl:57) does a guarded sys:get_state(Pid, 5000) and returns the actor's user fields as a flat JSON map. It is one-shot, flat (no drill), actor-only, and per the surface-parity doc is deliberately agent-only (docs/development/surface-parity.md:86).
  2. Humans get printString. Transcript show: anObject gives the one-line structural string from ADR 0094 — legible, but a dead end: you cannot click a field, cannot watch it change, cannot evaluate against the object.

The data needed already exists — Value field slots, actor state behind sys:get_state, erlang:process_info, the metaclass tower (ADR 0036), and the runtime structural renderer (ADR 0094). What is missing is the language-level API that composes them into a navigable object model that every surface (REPL text tree, MCP/browser tree) renders from one source.

This is the surface the Cockpit LiveView IDE's Inspector pane (BT-2486, BT-2492) and a future Pharo-style debugger sit on top of. This ADR designs that API, scoped to a lean v1 — drill, render, refresh — with the heavier capabilities (evaluate-in-context for actors, per-class custom views, sealing, push updates) deferred behind documented seams (§7).

This is the inspect redesign ADR 0094 deferred

ADR 0094 split the string-display protocols (printString = Debug, displayString = Display) but explicitly left inspect untouched — returning a String, delegating to printString — and named its end-state:

"a follow-up ADR, designed against the LiveView live-tooling surface, will repurpose it as the verb that produces or opens a structured inspector."ADR 0094 §1

This is that follow-up. It owns the breaking inspect return-type change and the migration risks ADR 0094 pre-documented (the 14 inspect -> String overrides, the gradual-typing crash audit, the runtimeCalledSelectors entry, the "op": "inspect" naming collision).

Reuses the navigator conventions (without being a fourth navigator)

Beamtalk exposes runtime structure along three axes that share one shape — a navigator (Object) handing back immutable snapshot records (Value): SystemNavigation (static code, ADR 0087), ProcessNavigation (SupervisionNode, ADR 0092), AnnouncementNavigation (SubscriptionNode, ADR 0093). The Inspector is not a fourth navigator — those each enumerate a system-wide collection, while the Inspector is a cursor over one object and drills into it. But it reuses their hard-won conventions verbatim: Object-handle vs Value-record split, lazy/timeout-guarded state capture, snapshot-then-refresh semantics, the read-vs-mutate rule, and a typed asDictionaries wire form.

Constraints

  1. Actor opacity (ADR 0094, ADR 0067). An actor's state lives behind its message boundary. Reading it means a synchronous sys:get_state — racy, blocking on a busy actor, a confidentiality concern. State capture must be lazy, timeout-guarded, and snapshot-based, never on the display hot path — the same discipline ADR 0092 applies to sys:get_status.
  2. No self-deadlock on the walk. Like the three navigators, the traversal runs as plain code in the calling eval worker, never routed through a class-side gen_server — or inspecting the eval worker's own supervisor would deadlock.
  3. Three-kind data model (ADR 0067). Value (immutable field: slots), Actor (state: behind a process), plain Object (foreign handle), plus genuinely foreign OTP processes (package gen_servers) with no Beamtalk wrapper.
  4. No parser/codegen changes in v1. Like ADR 0092/0093, v1 is stdlib + runtime only. (Anything needing a grammar touch — e.g. an inspection-sealing modifier — is deferred, §7.)
  5. Surface parity (CLAUDE.md). One Inspector drives REPL and MCP/browser; the navigation API is shared, only the rendering is per surface.
  6. Bounded by construction. Large collections, deep nesting, and cyclic graphs must never hang or blow memory — reusing ADR 0094's depth/width/length caps, plus pagination and a cycle guard.

Decision

Introduce a single polymorphic Inspector — a sealed typed Object subclass (a live cursor handle, like the three navigators) — returned by anObject inspect and Inspector on: anObject. It exposes a uniform navigation protocol over all subject kinds (Value, Actor, foreign process, collection). Drilling returns a new Inspector cursor; fields are immutable InspectorField Value records. Actor state is a lazy, timeout-guarded snapshot. Live updates are poll-only (refresh re-snapshots). evaluate: is supported for values (pure eval, self = the value); actor expression-eval is deferred (§7) — v1 inspects actor state by drilling, not by evaluating against the live process. The design has no parser/codegen changes.

1. Core type — one Inspector, polymorphic over subject kind

sealed typed Object subclass: Inspector
  // construction
  class on: anObject               -> Inspector       // explicit; `anObject inspect` is the shorthand
  // identity
  subject                          -> Object           // the inspected object (or snapshot for actors)
  kind                             -> Symbol            // #value #actor #foreign #collection
  // navigation
  fields                           -> List(InspectorField)    // drillable field/element records
  at: aName                        -> Result(Inspector, Error) // drill into one field → child cursor
  parent                           -> Inspector | nil    // the cursor we drilled from (nil at root)
  root                             -> Inspector          // the top of this drill chain
  path                             -> List(Symbol)       // breadcrumb from root (for UI)
  // value-only evaluate-in-context (actors deferred, §7)
  evaluate: src :: String          -> Result(Object, Error)
  // poll-based liveness (§5)
  refresh                          -> Inspector          // a cursor on a freshly-captured snapshot
  // rendering
  printString                      -> String             // indented text tree, depth-1 (REPL)
  printStringExpanded: depth :: Integer -> String        // text tree to N levels
  asDictionaries                   -> List(Dictionary)    // typed records → wire form (MCP/browser)

inspect is repurposed (the ADR-0094 deferral): Object >> inspect now returns Inspector on: self, not a String. Inspector on: is the explicit spelling; anObject inspect is the shorthand.

The cursor is immutable. Every navigation message returns a new Inspectorat: returns a child cursor (with parent set), refresh returns a cursor on a freshly-captured snapshot of the same subject, page: (§6) returns a cursor on the next window. Nothing mutates in place. This keeps the model easy to reason about (a cursor is a value-like view) and lets a UI hold several cursors at once.

Why one polymorphic class, not per-class inspectors or a bare protocol. A single Inspector gives newcomers one thing to learn and realises ADR 0094's stated end-state verbatim. The kind-based field derivation (§3) is a switch over a closed, runtime-owned set of four kinds — appropriate to centralise in one place — not over the open set of user classes (which is what per-class inspector subclasses would fan out, §Steelman). Per-class custom views are a natural extension point (inspectorFields, §7) but are not v1: the kind-derived fields cover the common case.

One class with a kind tag — not a subclass per kind. Inspector is not split into ValueInspector/ActorInspector/CollectionInspector/ ForeignInspector. This is the same fork ADR 0092 faced and settled: its SupervisionNode carries five kind values (#beamtalkActor/ #beamtalkSupervisor/#otpSupervisor/#otpProcess/#restarting) in one record, with classification done in the runtime shim — not five subclasses. We mirror that exactly, for three reasons: (1) the kinds barely diverge in public protocol — all answer fields/at:/path/refresh/asDictionaries; the only kind-specific message is collection page: (a Result error: elsewhere), too little to justify a hierarchy; (2) the work that genuinely varies — subject classification and actor state capture — lives in the runtime shim (reusing ADR 0092's kind machinery), so there is no big case in Beamtalk Inspector code, just a tagged InspectorField list coming back; (3) a fat abstract base + four thin leaves overriding ~one method each is more surface (four more generated_builtins.rs/build_stdlib.rs registrations, four files) for no behavioural gain, against the lean-v1 goal. Subclasses would earn their keep only if a kind exposed a different protocol, which none does. Keeping Inspector one class also keeps the user-visible type stable — inspect always returns "an Inspector," never a kind-specific class the caller must not depend on.

2. The field record — InspectorField (Value)

Each drillable thing is an immutable record, mirroring SupervisionNode / SubscriptionNode:

sealed typed Value subclass: InspectorField
  field: name      :: Object  = nil   // Symbol (slot) | Integer (element index) | key
  field: label     :: String  = ""    // display label (e.g. "x", "[0]", "#key →")
  field: value     :: Object  = nil    // the field's value
  field: kind      :: Symbol  = #slot  // #slot #element #association #processInfo
  field: drillable :: Boolean = true   // false for leaf scalars (numbers, atoms, booleans)

fields is the flat record API the acceptance criteria asks for; at: is the navigation layer on top — the same flat/navigable dual ProcessNavigation strikes (tree nodes vs tree root). A surface that just lists renders fields; one that drills calls at:.

Read-vs-mutate rule (ADR 0092 §3a, applied identically). An InspectorField is a frozen fact and read-only. To change something you discovered, cross back to the live object and send it a message. Snapshot facts stay on the record; mutation stays on the live subject.

3. Field derivation by subject kind

Inspector kind classifies the subject (reusing ADR 0092's kind machinery for processes) and derives fields:

kindSubjectfields are…at: drills to…
#valuea Value instanceits field: slots (sorted, ADR 0094 order)the slot's value
#actora Beamtalk Actorstate slots from a lazy snapshot (§4)the slot's value
#collectionList/Array/Set/Dictionary/Bagwindowed elements / associations (§6)the element / value
#foreigna non-Beamtalk OTP process (Pid)best-effort process_info + guarded sys:get_statethe value where derivable

#value is pure structural traversal (no process contact). #actor/#foreign read a snapshot (§4). A drilled scalar (number, atom) is a leaf (drillable: false).

4. Actor & foreign state — lazy, timeout-guarded snapshot

Constructing an Inspector on: anActor does not read state. State is captured the first time fields (or at:) needs it, via a guarded sys:get_state(Pid, Timeout) (the primitive the current "op": "inspect" already uses, beamtalk_repl_ops_actors.erl:71) wrapped in the ADR-0092 guard:

This makes the Inspector safe to open on any actor: a wedged actor degrades to "state unavailable" instead of hanging the REPL, the same guarantee ADR 0094's display path gives. The guard helper is shared with ADR 0092's sys:get_status guard (one place that knows how to safely interrogate a live process).

Foreign processes (#foreign): process_info(Pid, [registered_name, current_function, message_queue_len, …]) plus a guarded sys:get_state when the process is sys-compliant; fields are best-effort, tagged kind: #processInfo.

Snapshot semantics (ADR 0092 §4, inherited). The captured state is a point-in-time truth. Once frozen, drilling never re-contacts the actor, so the walk is internally consistent and cannot deadlock or observe mid-mutation. To see later state, refresh.

5. Live updates — poll-only

The Inspector is snapshot-then-refresh, matching ProcessNavigation (ADR 0092 §4) and Pharo's manual-refresh Inspector:

i := aCounter inspect          // snapshot at T0
aCounter increment             // state changes
i fields                       // still shows T0 (frozen snapshot)
i refresh fields               // a fresh cursor at T1

A LiveView pane that wants a "live" feel re-issues refresh on a timer (cheap — one guarded sys:get_state) and diffs snapshots for a field-flash effect — what LiveDashboard does and what the Cockpit Phase 3 design (BT-2492) calls for.

Push-based live tracking is a documented follow-up (BT-2489), not v1. Publishing on every actor state mutation is expensive, so the eventual design is opt-in: an actor declared observable publishes an ObjectStateChanged announcement on SystemAnnouncer (ADR 0093 substrate) after each committed state write, and a subscribed Inspector refreshes reactively. The schema is specified now so BT-2489 has a target:

// Deferred to BT-2489 — specified here, not shipped in v1.
sealed Announcement subclass: ObjectStateChanged
  field: pid          :: Pid    = nil
  field: actorClass   :: Class  = nil
  field: changedSlots :: List(Symbol) = #()   // #() = "unknown, refresh all"

v1 ships poll-only because it unblocks the Inspector and the Cockpit panes immediately, push's cost/opt-in trade is its own design, and poll is the proven LiveDashboard/Pharo model. This ADR blocks BT-2489 (it defines the seam) without depending on it.

6. Large collections — windowing & pagination

A #collection subject does not materialise every element. fields returns a window (default page size 50, matching ADR 0094's width cap / Elixir's :limit); beyond it the cursor exposes a next page:

big := (1 to: 100000) asList inspect
big size                       // => 100000   (cheap — collection size, not a walk)
big fields size                // => 50        (first window)
big page: 2                    // => a new Inspector cursor on elements [50..99]
big at: 73                     // => Inspector on the 74th element (direct index, no walk)

at: on a collection indexes directly (no full traversal). asDictionaries emits the window plus childCount and the page index, so the MCP/browser layers fetch subsequent pages lazily — the lazy-children pattern the acceptance criteria asks for, reusing ADR 0092's simple_one_for_one child-cap precedent (don't copy a giant child list out in one shot).

7. Cross-surface rendering — one cursor, two renderers

SurfaceRenders viaShapeDrill
REPLprintString (depth-1) / printStringExpanded:indented text treei at: #foo typed at the prompt
MCP / browserasDictionariestyped records serialised to JSON; large nodes carry childCount + page indextool/UI re-issues at: / refresh

REPL default depth is 1 (the object and its immediate fields; deeper on printStringExpanded:), keeping a printString-at-the-prompt terse while ADR 0094's inline structural printString remains the one-line form.

Wire form is the typed records, not an ad-hoc dictionary. asDictionaries serialises the cursor's InspectorField list (each record → a dictionary of its named, typed fields, plus the cursor's kind/path and any window childCount/page index) — the exact pattern SupervisionNode asDictionaries (ADR 0092) and SubscriptionNode (ADR 0093) use, so the schema is pinned by the typed records, not redefined per surface. The current MCP/REPL flat "op": "inspect" JSON is superseded by asDictionaries (a navigable tree of which the flat map is depth-0); a thin compatibility shim can keep the old op during transition.

Deferred to follow-ups (explicitly out of v1 scope)

These are designed-against seams, not v1 deliverables — each carries its own risk that would dilute this ADR's review surface, and v1 (drill + render + refresh) is useful without any of them:

  1. Evaluate-in-context for actors. v1 evaluate: is values-only (self = the value, a pure compiled dispatch — values are real Beamtalk objects, so this is sound). For an actor, "evaluate self count > 10" cannot bind self to a frozen state map: compiled method dispatch (beamtalk_actor:call(Self, …)) needs a live process, and a snapshot is a plain term. Two honest options exist — a snapshot-proxy (re-instantiate the class in an ephemeral isolated process seeded with the snapshot, eval there; mutations discarded, outbound sends to peers are real) and live routing (a reserved guarded debug-eval call into the real actor; mutations real). Both need new runtime machinery and ADR 0091 gating (running arbitrary code in/against an actor). Deferred to its own issue — v1 inspects actor state by drilling (at:), which covers the primary "what's in this field" need.
  2. Per-class custom views (inspectorFields). A class could override the kind-derived fields with a curated view (Pharo's custom-inspector feature), checked via respondsTo: #inspectorFields. Deferred — the generic kind derivation (§3) covers v1; the hook is a clean additive extension later.
  3. Sealing (sealedFromInspection). Refusing inspection for credential-bearing actors. Deferred because it is the only part needing a grammar touch (a new class modifier), and keeping it out preserves v1's "no parser changes." Until it lands, confidentiality on the remote surface is enforced where it matters anyway — by ADR 0091 op-gating of the remote inspect op (the in- image caller is already Owner/eval-privileged and can read state regardless, per ADR 0091/0093's stance). When sealing does land it should be a class-side method (class sealedFromInspection -> Boolean => true), not new syntax, so it stays parser-free; and because it is a class-side method any caller could read, it is honestly a tooling control, not a capability boundary.
  4. Push live updates (BT-2489) — §5.

REPL session

bt> p := Point x: 3 y: 4
Point(x: 3, y: 4)

bt> i := p inspect
Inspector(Point)
  x: 3
  y: 4

bt> i at: #x
Inspector(Integer = 3)

bt> i evaluate: "self dist: (Point x: 0 y: 0)"   "value eval — self = the Point"
Result ok: 5.0

bt> c := Counter spawn
Actor(Counter, 0.123.0)
bt> c increment; increment
bt> ci := c inspect            "lazy snapshot on first `fields`"
Inspector(Counter)
  count: 2

bt> ci at: #count
Inspector(Integer = 2)
bt> c increment                 "state moves on"
bt> ci refresh at: #count       "re-snapshot"
Inspector(Integer = 3)

Error / edge behaviour

bt> (busyActor inspect) fields
// => [ InspectorField(status: #unavailable) ]   "sys:get_state timed out — no hang, no raise"

bt> (aCounter inspect) at: #nonesuch
// => Result error: (beamtalk_error no_such_field)

bt> (aCounter inspect) evaluate: "self increment"
// => Result error: (beamtalk_error actor_eval_unsupported)
//      "evaluate: runs against values; actor evaluate-in-context is not in v1 — drill with at:"

Prior Art

SourceModelWhat we take / leave
Pharo Inspector (Spec2-Inspector)anObject inspect opens a window; per-class custom tabs; doIt pane with self; manual refresh; per-traversal cycle handling.Take: inspect as the verb, manual refresh, the seen-set, drill-into-fields. Adapt: one polymorphic Inspector (window/Model is the surface, not the API); actor state is a guarded snapshot, not a live image read. Defer: the doIt pane (value-only in v1) and custom tabs (inspectorFields, §7).
Newspeak HopscotchComposable navigable presenters; drilling pushes a new presenter onto a path.Take: drill = push a child cursor; path as a first-class breadcrumb. Leave: the presenter-composition UI framework — that's the browser layer.
Smalltalk-80 BrowserClass/protocol/method navigation panes.Take: navigation-by-drilling idiom. Leave: it's a code browser.
IPython _repr_html_Object opts into a rich per-surface representation; frontend picks text vs HTML.Take: one object, per-surface rendering (printString vs asDictionaries). Defer: the per-class opt-in hook (inspectorFields_repr_html_).
Phoenix LiveDashboard (process detail)LiveView render of process_info/sys:get_state, periodic re-poll, lazy detail.Take: poll-refresh as the v1 live model; lazy/guarded state; foreign-process detail. Confirms: push events are not required for a usable live pane.
BEAM :observer (process tab)process_info + sys:get_state on selection; mixes process kinds; lazy state.Take: the exact primitive set; lazy state-on-select; foreign processes. Leave: wx GUI — we expose the data, the IDE renders it.
Elixir IEx i/Inspecti/1 describes a term; Inspect is bounded structural rendering with :limit.Take: bounded rendering and the window size (adopted in ADR 0094).

Net: Pharo's Inspector navigation (drill, refresh, cycle-safety), :observer/LiveDashboard's safe BEAM state capture (guarded snapshot, lazy, poll-refresh, foreign processes), IPython's one-object/per-surface-render split — exposed as one message-passing Inspector, with the heavier doIt/custom-view features deferred (§7).

User Impact

Steelman Analysis

(a) JSON-only — keep "op": "inspect" as the flat typed map (status quo)

(b) Per-class inspector classes (CounterInspector, …) — Pharo's literal model

(c) Protocol-based (InspectableProtocol) — no Inspector class at all

(d) Raw reflection only — surfaces compose everything

Tension points

Alternatives Considered

See Steelman for the four core-type alternatives (a JSON-only, b per-class, c protocol, d raw-reflection). Two further points:

Keep inspect -> String; add a new inspector verb

Leave inspect returning a string and add anObject inspector as the navigable entry.

Ship the full inspector (actor eval, custom views, sealing) in one ADR

Cycle handling — identity seen-set vs depth bound

Cycle handling & bounds

Cycles in an object graph form only through process references — an immutable Value graph is a tree post-ADR 0042, but a Value field (or an actor slot) can hold an Actor/Pid, and actor A's state can reference B whose state references A. So the traversal:

This reuses ADR 0094's existing cycle/bound infrastructure (the structural renderer already tracks these for inline printString) and is honest about the two distinct cycle sources: process refs (pid-keyed) vs depth (capped).

Consequences

Positive

Negative

Neutral

Implementation

High-level, downstream of acceptance (separate issues via /plan-adr). Sequenced so the cheapest proof ships first.

  1. Phase 0 (napkin). Runtime primitive returning the flat List(InspectorField) for #value (pure structural traversal) and #actor (guarded sys:get_state snapshot), plus a minimal Inspector Object subclass (on:/fields/printString depth-1) and InspectorField Value subclass. De-risks the snapshot guard + kind classification. If awkward, the design is wrong.
  2. Navigation core. at: (drill → child cursor, Result), parent/root/ path, the pid-keyed cycle seen-set + ADR-0094 depth bounds, and refresh (cursor on a fresh snapshot). Reuse ADR 0092 kind classification.
  3. Collections + windowing. #collection derivation, page size 50, page:, direct-index at:.
  4. Value evaluate:. Compile the expression with self = the value, eval in the worker, return Result. Actor eval returns actor_eval_unsupported (deferred, §7).
  5. inspect repurpose + migration. Object >> inspectInspector on: self; remove the 14 inspect -> String overrides; the gradual-typing audit + transitional lint; update runtimeCalledSelectors; unify/document the "op": "inspect" op vs the method.
  6. Foreign + surfaces. #foreign (process_info + guarded sys:get_state); MCP asDictionaries (replacing flat "op": "inspect", compat shim); printStringExpanded:; update docs/development/surface-parity.md (inspect → cross-surface navigable view). Browser (Cockpit) consumes asDictionaries/ at:/refresh — BT-2486/2492.
  7. Docs + e2e. Language-features chapter; BUnit (value/actor/collection drill, cycle back-ref, window pagination, busy-actor unavailable, value evaluate:); REPL e2e btscript.

Affected components: stdlib (Inspector, InspectorField, Object >> inspect), runtime (snapshot/derive/window shim; shared sys:get_* guard), MCP/REPL surfaces, docs/tests. No parser/codegen changes.

Migration Path

References