Skip to content

Tool-Pair — runtime contract for pair-shaped AI tools

Type: Reference The runtime surface re-frame2 commits to so that pair-shaped tools — equivalents of day8/re-frame-pair — can attach to a running re-frame2 application and let an AI agent inspect, dispatch, hot-swap, and time-travel against it.

What this Spec is and isn't

This Spec is the runtime contract — the set of public capabilities re-frame2 exposes that pair-shaped tools rely on. It tells an implementer "ship these capabilities and a pair tool can be built against you."

Audit lineage. Several surfaces below (register-epoch-listener!, the structured :sub-runs / :renders / :effects slots on :rf/epoch-record, :dispatch-id / :parent-dispatch-id correlation, the :origin dispatch opt, app-schemas introspection, and the §Source-mapping helper enumeration) were added to this Spec following a cross-reference audit against day8/re-frame-pair's actual source — the upstream tool consumed surfaces this contract had not yet committed to. The audit is single-sourced here; downstream Specs (009 / 010 / 002 / Spec-Schemas) carry the additive normative text without re-citing the audit.

Source-of-truth note: Tool-Pair.md is the canonical surface contract for the time-travel / epoch-history capabilities and the trace-stream consumption shape. API.md reproduces these signatures (under §Epoch history) for fast lookup, but the normative descriptions live here; if the two drift, this Spec wins. The epoch-history, restore-epoch, and (rf/configure! :epoch-history {:depth N}) surface, plus the :rf.epoch/snapshotted / :rf.epoch/restored / :rf.registry/handler-replaced trace events, are pinned here and referenced from API.md.

This Spec is not the pair tool itself. The actual pair tool — the Claude integration, the prompt design, the nREPL middleware — lives outside the spec, in a separate repository (the upstream is day8/re-frame-pair). re-frame2 ships its half of the contract; the tool ships its half.

The architecture mirrors how re-frame2 relates to re-frame-10x: the spec defines a stable contract (the trace stream, the registrar query API, the public envelope shape); the tool consumes it. Multiple tools can consume the same contract.

What pair-shaped tools do

(Summarising the capability surface of day8/re-frame-pair so the contract makes sense.)

A pair tool is an AI/REPL companion that attaches to a running re-frame2 app. It lets the agent:

  • Inspect — read the current state of any frame's app-db, any subscription, any registration.
  • Trace — observe the trace stream (live or historical), per-event domino-by-domino.
  • Dispatch — fire events into any frame, synchronously or async.
  • Hot-swap — replace a registered handler with new code, observe the effect.
  • Time-travel — walk backward through the epoch history of a frame, snapshot state at each point, restore an earlier state.
  • Stub effects — temporarily redirect a registered fx (e.g., :http) to a stub, run experiments, restore.
  • Map source — given a registration id or a UI event, locate the source coordinates.
  • REPL-eval — execute arbitrary expressions in the runtime's namespace context.
  • Watch / narrate — set up subscriptions to a stream of trace events and report each one as it fires.

The "9-step empirical loop" (observe → inspect → hypothesise → probe → compare → edit) is the dominant interaction shape; pair-shaped tools are designed to make that loop fast.

What re-frame2 commits to (the runtime contract)

Each capability the pair tool needs maps to a re-frame2 surface. The contract has two parts:

  • Existing-surface map (this section). Every capability except time-travel is already specified in other Specs (001 / 002 / 008 / 009). The table below maps capability → surface → source-of-truth. No new commitments here.
  • Time-travel commitments (§Time-travel below). Epoch recording, query, and restore are new in re-frame2 and locked here. This is the only section adding surface; everything else is reproduction.

The two parts together form the consolidated contract — the complete set of surfaces a pair-shaped tool may consume. §How AI tools attach reproduces the same surfaces re-organised by what the tool needs to do (rather than what re-frame2 commits to); it is a view, not additional commitments.

Capability re-frame2 surface Spec
Read app-db (rf/app-db-value frame-id) returns the current app-db value (a plain map) 002 §The public registrar query API
Read sub values (rf/compute-sub query-v db-value) runs a sub against an app-db value 008
Read registry (rf/registrations kind), (rf/handler-meta kind id), (rf/frame-ids), (rf/frame-meta id) 001-Registration, 002
Dispatch (rf/dispatch ev opts), (rf/dispatch-sync ev opts) with :frame opt 002 §Routing
Drive the render flush-render! on the installed substrate adapter — synchronously commits pending renders so a headless dispatch → observe-DOM loop is deterministic 006 §flush-render!, §Driving the render
Trace stream (rf/register-listener! key callback) plus structured trace events 009
Hot-swap handlers Re-registration replaces; emits :rf.registry/handler-replaced trace 001 §Hot-reload semantics
Stub fx :fx-overrides map (id-valued at the pattern level) on dispatch opts or reg-frame metadata 002 §Per-frame and per-call overrides
Source coordinates :ns/:line/:column/:file on every registration's metadata (shape: :rf/source-coord-meta per Spec-Schemas); mandatory data-rf2-source-coord DOM annotation (shape: :rf/source-coord-attr per Spec-Schemas) per Spec 006 001 §Source-coordinate capture, 006 §Source-coord annotation
Inspect registered schemas (rf/app-schemas frame-id), (rf/app-schema-at path opts), (rf/app-schemas-digest opts) 010 §Schemas as a tooling and agent surface
Errors Structured :rf.error/* trace events with category + tags 009 §Error contract

This much is already specified (except the render-driving fn flush-render!, locked in §Driving the render below and in 006 §flush-render!). A pair tool built against re-frame2 (and conforming with day8/re-frame-pair) needs nothing more than these surfaces to do everything in the capability list above except time-travel.

The capabilities requiring new commitments are driving the render (immediately below) and time-travel (after it).

Driving the render (headless view-lifecycle)

A pair tool can drive the re-frame layer (dispatch) but, without a commitment here, cannot reliably drive the substrate render. The reference substrates schedule re-renders through a requestAnimationFrame-style tick that (a) fires after an evaluated dispatch returns and (b) is throttled to ~never in a backgrounded / unfocused tab — exactly the state a headless or remotely-driven browser is usually in. So a tool that dispatches an event and then reads the DOM races the scheduler: the commit may not have happened, and in a backgrounded tab may never happen. Driving the view lifecycle (mount / unmount / re-render) from the MCP was correspondingly unreliable.

The commitment is the optional substrate-adapter contract fn flush-render! (per 006 §flush-render!). It runs through the host's synchronous-commit path (React flushSync; reagent.core/flush for the ratom family) rather than the rAF-scheduled tick, so the commit fires even headless and even when the tab is backgrounded. The 1-arity form runs a thunk and then flushes; the 0-arity form flushes already-pending work. It is no-op-safe (nothing pending → returns nil).

This is the framework primitive the pair MCP's dispatch-and-settle op builds on: dispatch the event, flush-render! to synchronously commit the resulting renders, then observe the settled DOM (the data-rf2-source-coord / data-rf-view annotations per 006 §Source-coord annotation) and the settled epoch (per §Time-travel). flush-render! belongs to the framework, not the pair-runtime: it is substrate-specific, but every re-frame2 app installs an adapter, so the capability is universal. A tool reaches it through the installed adapter's flush-render! (the eval-cljs authority class per §MCP tool authority classes, or a named dispatch-and-settle op that wraps it). Adapters with no live commit (plain-atom / SSR) ship no flush-render!; the call no-ops.

The dispatch-and-settle contract

A conforming pair MCP exposes dispatch-and-settle as one operation that returns the complete epoch in one call — the dispatch tool's :settle mode. The op MUST:

  1. dispatch the event synchronously (the cascade commits app-db before the render can settle against the new state);
  2. call the installed adapter's flush-render! — resolved from the live adapter, never a hardcoded substrate API (the framework knows its own registered adapter; a tool that names reagent.core/flush! is non-conforming and breaks under UIx / Helix);
  3. re-read the epoch the dispatch recorded and return it.

Because flush-render! is synchronous, the post-settle render emits (:rf.view/render) fire inside its extent and the framework back-fills them into the causing epoch (per 009 §post-settle render attribution — a render fires at substrate-commit time, after the cascade settles, and is re-attributed to the most-recently-settled epoch and re-fanned to epoch listeners). The re-read therefore returns an epoch whose :renders projection and :trace-events carry the view-lifecycle signal. The returned envelope carries :settled? true, the assembled :epoch, and the view-render / view-unmount entries (:rf.view/render, :rf.view/unmounted) folded into that epoch — so one call = dispatch → render → complete epoch, retiring the dispatch + setTimeout + flush! + re-read dance and its rAF / tab-focus dependence.

:settle versus :await-render. Both make dispatch → observe one step; they differ in flush primitive and transport. :await-render flushes via re-frame.interop/after-render (the rAF-scheduled, post-commit / pre-paint hook) and is async — it returns a promise the server awaits, and on a backgrounded tab the underlying commit can stall; it resolves to the dispatch consequence (no full epoch). :settle flushes via flush-render! (synchronous-commit) — not rAF-scheduled, so it commits even headless / backgrounded, is fully synchronous (no promise), and returns the settled epoch. When both are set, :settle wins (it is the most complete single-call shape).

Scope. "Settle" is the synchronous cascade + render flush only. Asynchronous effects (HTTP, timers, :dispatch-later) are not settled by this op — they stay observed via watch-epochs (per §watch-epochs / trace-window consumer shape / §Time-travel). Substrate teardown emits (:rf.view/unmounted) ride the substrate's instance-disposal, which some substrates schedule as a passive teardown after the synchronous commit; when that teardown fires, the framework's post-settle back-fill attributes it to the epoch this op returned (the most-recently-settled one). The op's guarantee is therefore: the synchronous renders are present in the returned epoch, and any deferred unmount back-fills into that same epoch and re-fans to listeners.

Time-travel: epoch snapshots and undo

Artefact home. Per rf2-lt4e (the seventh and final per-feature artefact split per rf2-5vjj Strategy B), the time-travel surface — the per-frame :rf/epoch-record ring buffer (epoch-history), the (rf/configure! :epoch-history {:depth N}) knob, the register-epoch-listener! / unregister-epoch-listener! listener API, the restore-epoch rewind with its six documented failure modes, the per-cascade trace-capture buffer, the :rf.epoch/snapshotted / :rf.epoch/restored trace events, and the :sub-runs / :renders / :effects projections — ships in day8/re-frame2-epoch. Apps that consume the pair-tool / time-travel surface add the artefact alongside core and require re-frame.epoch at boot so the namespace's late-bind hook publications fire before the public re-exports in re-frame.core (rf/epoch-history, rf/restore-epoch, rf/register-epoch-listener!, rf/unregister-epoch-listener!, (rf/configure! :epoch-history ...)) reach into the hook table at call time. When the artefact is not on the classpath the re-exports degrade silently (empty vector / false / no-op) — the surface is dev-tier and gated on interop/debug-enabled?, so a release build that omits the artefact must not raise. See Conventions §Packaging conventions and MIGRATION §M-33.

The runtime contract for time-travel:

Recording. Each dequeued event marks an epoch boundary — one :rf/epoch-record per dequeued event, not per drain (per 002 §Drain versus event). The unit is process-event!: every dequeued envelope runs its own full six-domino cascade and yields its own epoch, irrespective of how it arrived — a UI (rf/dispatch …), an :fx [[:dispatch …]] child queued by another handler, or the frame-creation :on-create event each open a fresh epoch. A drain that processes a parent event and the child it :fx-dispatched therefore produces two epoch records, not one, even though both settle inside the same drain. (A machine's :raise sub-events and :always microsteps are intra-macrostep — they ride the triggering event's epoch and do not open a new one, per 005 §Drain semantics.) The runtime records, per frame, an :rf/epoch-record (per Spec-Schemas) consisting of :epoch-id, :frame, :committed-at, :event-id, :trigger-event, :db-before, :db-after, and (optionally) :trace-events, plus the structured per-epoch projections :sub-runs, :renders, and :effects (each pre-derived from :trace-events; see Spec-Schemas §:rf/epoch-record for shapes). Pair tools route diagnostics off the structured slots — cache-hit-vs-rerun analysis (:sub-runs[*].:recomputed?), render-key attribution (:renders[*].:render-key, the tuple [<view-id> <instance-token>] per 004 §Render-tree primitives — Option C /), and fx cascade outcome (:effects[*].:outcome) — without re-folding the raw trace stream each epoch.

Ordering. Epochs within a frame are totally ordered by event settle-order — the order in which dequeued events complete their cascades. (A drain that settles several events back-to-back yields one epoch per event, in dequeue order.) Across frames, ordering is per-frame only — there is no global epoch sequence.

Bounded history. The runtime keeps the last N epochs per frame (default 50, configurable via (rf/configure! :epoch-history {:depth N})). Older epochs are discarded.

Redaction hook. Apps that record sensitive material into app-db (auth tokens, PII, partner secrets) can install a :redact-fn on the :epoch-history configure key — (rf/configure! :epoch-history {:redact-fn (fn [record] …)}). The fn takes an assembled :rf/epoch-record and returns a (possibly-redacted) record that takes its place across every consumer of that record — the per-frame ring buffer, the register-epoch-listener! listener fan-out, and any off-box egress through the projected-record helper (per Security §Epoch privacy posture). The contract: - Build-time placement, one pass per record. The runtime invokes :redact-fn once per assembled record, between build-record and ring-append / listener fan-out. The ring and every listener see the same redacted shape — no later projection re-derives slots the fn erased. - Rollup runs first. The :rf.epoch/sensitive? top-level rollup (per Security §Epoch privacy posture) is computed from the raw record's schema-declared :sensitive? leaves before :redact-fn runs — the rollup reflects the raw signal even when the fn erases the leaves it keyed on. Consumers can branch on :rf.epoch/sensitive? and trust it; they cannot rely on the redacted record carrying the original sensitive values. - Failure isolation. A throwing :redact-fn does not break the drain: the framework catches the throw, emits :rf.warning/epoch-redact-fn-exception (with :tags {:frame <id>, :epoch-id <id>}), and falls back to the raw record for that drain only. The registration stays in place; the next drain re-attempts. - restore-epoch consumes the redacted :db-after. A restore against a record whose :db-after was redacted by the fn rewinds the frame's app-db to that redacted shape — the runtime has no separate copy. Apps that need restore fidelity should leave :db-before / :db-after alone in their :redact-fn and target only :trace-events / :trigger-event / the structured projections. The bead description carries the trade-off in full. - Composition with the projected-record helper. The projected-record helper (per Security §Epoch privacy posture) is idempotent under :rf/redacted sentinels — running it on a record the :redact-fn already touched produces the same shape, so on-box and off-box consumers can compose without re-leaking. - Production elision. :redact-fn shares the universal re-frame.interop/debug-enabled? gate. Production builds (:advanced + goog.DEBUG=false) elide the epoch surface entirely — there is no separate gate and no production-time invocation cost.

Query. (rf/epoch-history frame-id) returns the vector of :rf/epoch-record values for the frame, oldest-first.

Restore. (rf/restore-epoch frame-id epoch-id) rewinds the frame's app-db to the named epoch's :db-after value. Emits :rf.epoch/restored.

Restore failure modes. restore-epoch is a query against a finite per-frame history; the restore can fail for distinct, named reasons. Each is an error trace event with a stable :operation key under the reserved :rf.epoch/* namespace; the call is a no-op on failure (the frame's app-db is unchanged):

Failure :operation When it fires :tags
Unknown frame :rf.error/no-such-handler (kind :frame) frame-id does not name a registered frame. {:kind :frame, :frame <id>}
Unknown epoch :rf.epoch/restore-unknown-epoch epoch-id is not in the frame's current epoch history (either never recorded or aged out by :depth). {:frame <id>, :epoch-id <id>, :history-size <n>}
Schema mismatch :rf.epoch/restore-schema-mismatch The recorded :db-after no longer validates against the currently-registered app-schemas set (a schema was added, tightened, or replaced since the snapshot was taken). {:frame <id>, :epoch-id <id>, :schema-digest-recorded <s>, :schema-digest-current <s>, :failing-paths [<path> ...]}
Missing handler :rf.epoch/restore-missing-handler The recorded app-db references a registered-id (e.g. an active machine at [:rf/runtime :machines :snapshots <id>], a registered route currently in [:rf/runtime :routing :current]) that is no longer present in the registrar. Restoring would leave the frame referencing dangling ids. {:frame <id>, :epoch-id <id>, :missing [{:kind <kind>, :id <id>} ...]}
Version mismatch :rf.epoch/restore-version-mismatch The frame's recorded :rf/snapshot-version (per Spec-Schemas §:rf/machine-snapshot) is incompatible with the currently-loaded machine definition. Hot-reload moved the machine forward; the older snapshot can no longer be interpreted. {:frame <id>, :epoch-id <id>, :machine-id <id>, :version-recorded <int>, :version-current <int>}
Concurrent-drain rejection :rf.epoch/restore-during-drain restore-epoch was called while the frame's run-to-completion drain is still in flight (per 002 §Run-to-completion dispatch). Restore is rejected; the user retries after settle. {:frame <id>, :epoch-id <id>}
Halted-cascade target :rf.epoch/restore-non-ok-record The named epoch's :outcome is not :ok — i.e. the record was committed for a halted cascade (:halted-depth, :halted-destroy, …). Halted records carry partial state for devtools introspection and are not valid restore targets; rewinding would land app-db in a state the cascade never settled to. {:frame <id>, :epoch-id <id>, :outcome <kw>, :halt-reason <any>}

All seven failures have :op-type :error and :recovery :no-recovery. Pair tools display the :operation and :tags to the user; the reserved :rf.epoch/* namespace lets tools route restore failures distinctly from frame-lookup errors. The failure surface is closed for v1 — additional categories require a Spec-ulation increment.

Note on the unknown-frame row. Six of the seven failures fire under the reserved :rf.epoch/* namespace; the remaining one (Unknown frame) rides the framework-wide :rf.error/no-such-handler op-type with :kind :frame because it is a registry-lookup failure that predates the restore call (the same op-type fires for any registrar lookup that names a missing frame). A pair tool routing restore failures should therefore match on either :rf.epoch/* or (:rf.error/no-such-handler ∧ :kind = :frame) to catch the full failure surface; the audit-found drift between the reserved-namespace prose and the table's heterogeneous first row is preserved-by-design, not a contradiction.

Restore caveat. Even a successful restore rewinds app-db only; effects already fired (HTTP requests sent, navigation pushed, localStorage written) are not reversed. Pair-shaped tools surface this caveat in their UI before applying a restore.

Production elision. Per 009 §Production builds and API §Tracing — re-frame.interop/debug-enabled? row, the trace surface, schema validation, registrar trace emit, and epoch-history machinery share a single gate — re-frame.interop/debug-enabled?. On CLJS the gate is an alias of goog.DEBUG and production builds (:advanced + goog.DEBUG=false) elide every gated branch via Closure DCE; on JVM the same name is a ns-load-time def driven by -Dre-frame.debug=false / RE_FRAME_DEBUG=false (per 009 §JVM builds). Setting either disables epoch-history capture and the restore surface entirely. CI's npm run test:elision job (Spec 009 §Production-elision verification) asserts the CLJS DCE contract holds for every gated surface, including the epoch-history primitives once they land; the JVM gate is covered by epoch_jvm_prod_gate_test.clj and interop_debug_gate_test.clj.

Worked example: walking history and restoring

A pair tool that wants to render a per-frame undo affordance walks epoch-history, picks a target epoch (typically by index, by :event-id, or by user click on a visualised list), and calls restore-epoch. The round-trip below covers the dev-shape consumption pattern end-to-end, including the listener wiring that catches restore failure traces:

;; A pair tool's "rewind to before that event" affordance.
;; - Walks the per-frame epoch history (oldest-first vector).
;; - Picks the most recent epoch BEFORE a target event-id.
;; - Calls restore-epoch and listens for either
;;   :rf.epoch/restored (success) or any :rf.epoch/restore-* error.

(defn epoch-before-event
  "Return the epoch-id of the most recent epoch in `frame-id`'s history
   that precedes `target-event-id`, or nil if none exists."
  [frame-id target-event-id]
  (let [history (rf/epoch-history frame-id)            ;; oldest-first vector of :rf/epoch-record
        before  (take-while #(not= target-event-id (:event-id %)) history)]
    (when (seq before)
      (:epoch-id (last before)))))

;; Listener: catch restore success / failure traces and fan out to UI.
(rf/register-listener!
  :my-tool/restore-watcher
  (fn [ev]
    (case (:operation ev)
      :rf.epoch/restored                    (notify-ui :restored ev)
      :rf.epoch/restore-unknown-epoch       (notify-ui :error ev)
      :rf.epoch/restore-schema-mismatch     (notify-ui :error ev)
      :rf.epoch/restore-missing-handler     (notify-ui :error ev)
      :rf.epoch/restore-version-mismatch    (notify-ui :error ev)
      :rf.epoch/restore-during-drain        (notify-ui :error ev)
      :rf.epoch/restore-non-ok-record       (notify-ui :error ev)
      ;; Unknown-frame rides :rf.error/no-such-handler (kind :frame); see note above.
      nil)))

;; Trigger the rewind. restore-epoch returns nil on failure (the failure mode
;; is delivered via the trace stream); on success, app-db has been rewound and
;; :rf.epoch/restored has fired with the new :db-after.
(when-let [target (epoch-before-event :app/main :checkout/submit)]
  (rf/restore-epoch :app/main target))

The walk-history-then-restore shape is the canonical pair-tool gesture; render-tree visualisers, "what did this event do?" probes, and conformance harnesses all build on the same primitives. Tools that want post-restore confirmation without registering a trace listener can re-call (rf/app-db-value :app/main) and diff against the pre-restore snapshot.

Pair-tool writes — state injection

restore-epoch and dispatch cover most of pair-tools' write needs (rewind to a recorded prior state; drive a cascade through the application's own handlers). The remaining case is state injection — replacing a frame's app-db with an arbitrary value that the runtime never recorded and that no event handler need exist to produce.

The committed surface is (rf/reset-frame-db! frame-id new-db). It bypasses the dispatch loop, replaces the frame's app-db container directly, and records a synthetic :rf/epoch-record so restore-epoch can rewind past the injection.

Use cases the surface covers:

  • Evolved-state-shape probes. A pair-tool agent rewrites a sub or handler and needs to seed an app-db shape that the new code expects, without firing a (possibly-failing) cascade through stale handlers. dispatch would re-trigger the broken cascade.
  • Story tools. Fixture-shaped state injection — "render the cart in this state" — without authoring a setup event for every story.
  • Conformance harnesses. Property-test runs that load a known app-db, run a single dispatch, assert post-state. Same shape as a test setup.
  • Time-travel from JSON-loaded bug repros. A user attaches a serialised app-db from a saved bug; the agent loads it. restore-epoch covers this only when the state is in the ring buffer; arbitrary db injection from outside the recorded history needs a write path.

Contract.

  • Replaces the container. (rf/reset-frame-db! frame-id new-db) calls replace-container! on the frame's app-db substrate container. Subscribers route off the post-reset value the same way they do after a restore-epoch happy path or a normal cascade settle.
  • Records a synthetic epoch. A fresh :rf/epoch-record lands in (rf/epoch-history frame-id) carrying :event-id :rf.epoch/db-replaced, :trigger-event [:rf.epoch/db-replaced], :db-before (the pre-reset value), and :db-after (new-db). The :sub-runs / :renders / :effects projections are empty — no cascade ran. restore-epoch of a prior epoch rewinds past the injection; restore-epoch of the synthetic record itself rewinds to new-db (i.e. a round-trip to where the reset already left things).
  • Emits :rf.epoch/db-replaced on success with :tags {:frame <id> :epoch-id <id>}, :op-type :rf.epoch. Pair-tool dashboards filter on the operation to route pair-tool injections distinctly from cascade-driven epochs.
  • Fires register-epoch-listener! listeners. The assembled record is delivered to every registered epoch listener after it lands in the ring buffer — same shape as a cascade-settle delivery.
  • Returns true on success, false on any failure.

Failure modes (each is a no-op on app-db and emits a structured error trace):

Failure :operation When it fires :tags
Unknown frame :rf.error/no-such-handler (kind :frame) frame-id does not name a registered frame. {:kind :frame, :frame <id>}
Drain in flight :rf.epoch/reset-frame-db-during-drain reset-frame-db! was called while the frame's run-to-completion drain is still running (per 002 §Run-to-completion dispatch). The injection is rejected; the caller retries after settle. {:frame <id>}
Schema mismatch :rf.epoch/reset-frame-db-schema-mismatch new-db fails the frame's currently-registered app-schema set (per Spec 010 §Per-frame schemas). When no schemas are registered the validation is a no-op — every new-db is accepted. {:frame <id>, :failing-paths [<path> ...]}

All three failures have :op-type :error and :recovery :no-recovery. The closed-set v1 failure surface mirrors restore-epoch's shape.

Production elision. Per 009 §Production builds reset-frame-db! shares the universal compile-time gate (re-frame.interop/debug-enabled?, alias of goog.DEBUG); production builds (:advanced + goog.DEBUG=false) elide the body via Closure DCE. The surface is dev-only — pair-tool writes do not ship in production binaries. CI's npm run test:elision job asserts the contract holds for the success op (:rf.epoch/db-replaced) and both failure ops.

Artefact home. reset-frame-db! lives in re-frame.epoch (it records a synthetic :rf/epoch-record, so the surface is epoch-adjacent and naturally co-located with restore-epoch / register-epoch-listener!). The core re-export late-binds through the hook table (:epoch/reset-frame-db!); unlike the four read-shaped re-exports (which degrade silently when the artefact is absent), reset-frame-db! raises :rf.error/epoch-artefact-missing — the caller's invariant is "undo works after this call", and a silent no-op would lie about that invariant.

Worked example.

;; A pair-tool agent has just hot-swapped a handler that operates on
;; an evolved app-db shape. Inject the new shape directly so the
;; cascade doesn't re-run through stale handlers, then dispatch a
;; single event to verify the new code works against the seeded state.

(when (rf/reset-frame-db! :app/main {:cart {:items [{:sku "abc" :qty 2}]}
                                     :checkout/state :ready})
  ;; reset-frame-db! has fired :rf.epoch/db-replaced and recorded a
  ;; synthetic epoch. Now drive a dispatch to exercise the new handler.
  (rf/dispatch [:checkout/submit] {:frame :app/main}))

;; To rewind PAST the injection (back to whatever the previous epoch
;; was), pick the epoch BEFORE the synthetic one and restore.
(let [history (rf/epoch-history :app/main)
      pre     (last (filter #(not= :rf.epoch/db-replaced (:event-id %)) history))]
  (when pre
    (rf/restore-epoch :app/main (:epoch-id pre))))

What reset-frame-db! is not. It is not a substitute for dispatch — handlers, interceptors, fx, and the trace stream all stay quiet during a reset. Use it only when bypass-the-cascade is required (the four use cases above); for any change you want the data loop to see, dispatch a real event. The synthetic epoch's empty :sub-runs / :renders / :effects projections are the visible signal that no cascade ran.

Surface behaviour against destroyed frames

The Tool-Pair surfaces above (epoch-history, app-db-value, restore-epoch, reset-frame-db!, register-epoch-listener!) all take a frame-id. A pair tool can call any of them after the frame has been destroyed (per 002 §Destroy) — most often because the tool kept a reference to a frame whose owning component unmounted, or because a teardown sequence interleaved with an in-flight tool gesture. The runtime commits to a closed contract for these races so a tool can route them deterministically without inspecting registrar internals.

Pattern: read-shaped surfaces return an empty shape (so a defensive (when ...) is sufficient); mutating-shaped surfaces raise structurally (so a tool that intended a write learns the write did not happen); listener fan-out emits a one-shot trace when a previously-registered callback is silenced because its observed frame was destroyed.

Surface Shape Behaviour against destroyed (or never-registered) frame
(rf/epoch-history frame-id) read Returns [] (empty vector). Identical to "no epochs yet recorded" — consumers that want to distinguish a destroyed frame from a fresh one must consult (rf/frame-meta frame-id) or (rf/frame-ids) separately.
(rf/app-db-value frame-id) read Returns nil. Consumers that want a destroyed-vs-unknown distinction consult (rf/frame-meta frame-id).
(rf/restore-epoch frame-id epoch-id) mutate Emits :rf.error/no-such-handler (kind :frame, tags {:kind :frame, :frame <id>}) and returns false. Same trace shape as any other registry-lookup miss — already enumerated as the Unknown frame row of §Time-travel's restore-failure table.
(rf/reset-frame-db! frame-id new-db) mutate Emits :rf.error/no-such-handler (kind :frame) and returns false. Identical wording to restore-epoch's frame-miss row — pair-tool writers can match on a single category for both surfaces.
(rf/register-epoch-listener! id callback) (process-global) register The current API is process-global and does not bind a callback to a specific frame; this row therefore does not apply to the existing signature. A future opts-form ({:frame frame-id}) would raise :rf.error/no-such-handler (kind :frame) at registration time when its target is absent — the same shape registration-against-an-absent-target uses elsewhere. The framework reserves the spelling.
Pre-registered register-epoch-listener! callback whose observed frame is later destroyed listener silencing The runtime emits :rf.epoch.cb/silenced-on-frame-destroy (:op-type :rf.epoch.cb) once per (frame, cb-id) pair, with :tags {:frame <id>, :cb-id <id>}, on the destroy-cascade boundary. Subsequent destroys of the same frame do not re-emit. The callback registration remains in place — eviction is the consumer's call (per 009 §register-epoch-listener! invocation rules).

The trace event is enumerated in 009 §:op-type vocabulary; its :tags schema is canonicalised in Spec-Schemas.

Why "silencing" is a trace and not a return value. The register-epoch-listener! callback never sees a record from a destroyed frame — the runtime stops producing records for the frame the moment its destroy walks. A tool that doesn't know its observed frame was destroyed therefore sees a callback that simply stopped firing, with no signal it can route off. The silencing trace closes that gap: pair-tool dashboards, REPL companions, and conformance harnesses all subscribe to the trace stream already (per §How AI tools attach), so the existing channel carries the disambiguation. One-shot semantics keep the stream from accumulating noise on rapid frame churn (test fixtures, story tools).

Listener-silencing trace: implementation note. The runtime tracks, per cb-id, the set of frame-ids that cb has been delivered records for. When a frame is destroyed (per 002 §Destroy), every cb whose observed-frame set contains that frame receives one silencing trace; the cb's entry for that frame-id is then dropped so a re-registration of a same-keyed frame (e.g. reset-frame! :app/main) can re-arm. A cb that was never delivered any record (e.g. registered immediately before destroy) does not see a silencing trace — there is nothing to silence.

Production elision. The destroyed-frame contract surfaces (the registry-lookup error traces, the silencing trace, the read-empty shapes) all share the universal re-frame.interop/debug-enabled? gate; production builds (:advanced + goog.DEBUG=false) elide the trace emit and the dev-only Tool-Pair surfaces themselves (per §Time-travel §Production elision and 009 §Production builds). A shipped binary does not carry the silencing trace string.

Cross-references: 002 §Destroy (the lifecycle event being raced), 009 §Error event catalogue (the :rf.error/no-such-handler row consumed here), §How AI tools attach (the attachment surface that consumes the silencing trace).

Performance API consumption

The Performance API channel (per 009 §Performance instrumentation) is the prod-friendly counterpart to the dev-only trace stream. Pair-shaped tools that want timing data — an in-app perf overlay, an APM forwarder, a custom PerformanceObserver watching for slow renders — read it via the standard browser User Timing surface. No re-frame2 API call is needed; the runtime emits User Timing measure entries and any consumer that knows about performance.getEntriesByType can read them.

Names are stable and namespaced under rf::

rf:event:<event-id>
rf:sub:<sub-id>
rf:fx:<fx-id>
rf:render:<view-id>

Consumer pattern — pull every re-frame entry from the recent run:

performance.getEntriesByType('measure')
  .filter(e => e.name.startsWith('rf:'))
  .forEach(e => {
    const [_rf, bucket, ...idParts] = e.name.split(':');
    const id = idParts.join(':');
    // e: { name, startTime, duration, ... }
    // bucket: 'event' | 'sub' | 'fx' | 'render'
  });

Live: PerformanceObserver fires per emitted entry (the canonical shape for a tool that wants to react in real time):

new PerformanceObserver((list) => {
  for (const e of list.getEntriesByType('measure')) {
    if (e.name.startsWith('rf:')) {
      sendToAPM(e);  // or update an overlay, or buffer for a flush
    }
  }
}).observe({ type: 'measure', buffered: true });

The channel is gated on re-frame.performance/enabled? — a goog-define boolean that defaults to false. Pair tools that depend on the channel MUST document the consumer's responsibility to flip the flag in their build:

;; consumer's shadow-cljs.edn
{:builds {:app {:target           :browser
                :compiler-options {:closure-defines {re-frame.performance/enabled? true}}}}}

When the flag is off (the default), Closure DCE elides every bracket; performance.getEntriesByType('measure') returns no rf:-prefixed entries because none were ever emitted. This is by design: the perf channel is opt-in for prod (timing instrumentation has measurable cost on heavy hot paths and consumers should choose to pay it).

The Performance API surface is CLJS-only. JVM artefacts (SSR, headless tests) emit no perf entries; tools running there use the host's profilers (clj-async-profiler, JFR).

REPL-eval

The pair tool's "execute arbitrary expression" capability is the host's REPL (CLJS: nREPL via cider; Python: IPython; etc.) — re-frame2 doesn't ship an evaluator, just exposes its data structures. An nREPL session attached to a running re-frame2 app can already see re-frame.db/app-db (or its substrate-agnostic equivalent), the registrar, and any namespace-resolvable function.

The CLJS reference's commitment: public APIs (everything in re-frame.core) are stable for pair-tool consumption. Private namespaces (re-frame.db, re-frame.router, re-frame.subs, re-frame.events, re-frame.registrar) are off-contract — they may change between versions. Per MIGRATION §M-1, tools that reach into private namespaces will need to migrate.

The pair tool is encouraged to use only public APIs. If it needs something not public, file a Spec issue.

Source-mapping UI clicks back to code

The "which button is at src/app/profile/view.cljs:84?" capability requires every render-tree node — every registered view, every hiccup tag — to carry source coords. re-frame2's view registrations include :ns/:line/:file. The CLJS reference additionally:

  • Captures source coords at every reg-view macro expansion (:ns / :file / :line / :column).
  • Annotates rendered DOM with a data-rf2-source-coord="<ns>:<sym>:<line>:<col>" attribute pointing back to the registration that produced it. This is mandatory in re-frame2 per Spec 006 §Source-coord annotation — every substrate adapter whose host has a DOM-attribute concept MUST inject the attribute. Annotation is dev-only and gated on interop/debug-enabled? (the CLJS mirror of goog.DEBUG); production builds elide the attribute via dead-code elimination so there is no DOM-bytes cost in shipped bundles.

With the annotation in place, a pair tool can take a click position, read the nearest annotation, and resolve back to a source coordinate. Documented exemption (per Spec 006 §Source-coord annotation): components returning React Fragments, host-component heads (:>), or other non-DOM roots are exempt; pair tools fall back to (rf/handler-meta :view id) for those nodes.

State-machine source-coord stamping

The DOM-attribute annotation above maps clicked DOM nodes to view registration call sites. A complementary surface maps state-machine spec elements (guards / actions / transitions / state-nodes) back to their source positions.

Per Spec 005 §Source-coord stamping, the reg-machine macro walks its literal spec form at expansion time and CO-LOCATES per-element source onto each guard / action / on-spawn-action entry, plus a reference-site :source-coords onto each map node (state-node / transition map) inside the :states tree (rf2-npvsx + rf2-vqja2):

;; Per-element definition coord + source — co-located on the entry:
(get-in (rf/machine-meta :auth/login) [:guards :form-valid?])
;; {:fn <fn> :source-coords {:ns ... :line ... :column ... :file ...} :source-code "(fn ...)"}

;; Reference-site coords — read directly off the map node:
(get-in (rf/machine-meta :auth/login) [:states :form :source-coords])
;; {:ns ... :line ... :column ... :file ...}
(get-in (rf/machine-meta :auth/login) [:states :form :on :submit :source-coords])
;; {:ns ... :line ... :column ... :file ...}

Pair tools use these for two distinct UI gestures:

  • Jump to definition. A click on a guard/action name in the visualisation reads the co-located entry's :source-coords(get-in spec [:guards <id> :source-coords]) / [:actions <id> :source-coords] / [:on-spawn-actions <id> :source-coords] — to find where the fn is implemented.
  • Jump to call site. A click on a transition arrow or state node reads the :source-coords off that node (e.g. (get-in spec [:states :idle :on :submit :source-coords])); for an inline-fn / keyword slot ({:guard :form-valid?}) the slot itself holds a value rather than a map, so the tool walks UP to the nearest enclosing map node's :source-coords, which IS stamped.

The framework commits to the co-located entry shape, the per-node :source-coords shape, and the inline-fn / keyword rule (inline-fn / keyword slots carry no node of their own; the nearest enclosing map node's coord stands in). Pair tools ship their own UI affordance over them. Like data-rf2-source-coord, the dev-only source is gated on interop/debug-enabled? and elides under :advanced + goog.DEBUG=false.

Where the DOM-to-source helpers live (re-frame2 vs tool)

The audit found the upstream pair tool ships dom/source-at, dom/find-by-src, and dom/fire-click-at-src helpers (it currently parses re-com's data-rc-src attribute, but the shape is general). Pair-shaped tools need some DOM-to-source bridge; the question is whether the helpers themselves are part of re-frame2's contract or live in the consuming tool.

re-frame2's commitment is the attribute, not the helpers. Specifically:

  • The runtime emits the data-rf2-source-coord attribute on rendered DOM nodes when source-annotation is enabled. The attribute's value format is a committed public contract (per Spec-Schemas §:rf/source-coord-attr) — a 4-segment colon-separated string <ns>:<sym>:<line>:<col> where <sym> is the registered handler-id (not a file path). Consumers parse the four segments directly to recover <ns> / <handler-id> / <line> / <col>. To recover the full source-coord shape including :file, follow up with (rf/handler-meta :view <handler-id>) — the registration metadata returns :rf/source-coord-meta (per Spec-Schemas) which carries all four keys (:ns, :line, :column, :file).
  • The framework does not ship dom-source-at / find-by-src / fire-click-at-src style helpers. These are tool-side: the pair tool reads the attribute via its own host's DOM access (document.querySelector in CLJS, page.locator in Playwright-driven flows, etc.) and resolves the source coordinate locally — the parse is straightforward against the committed format above.

Why tool-side, not framework-side: the helpers depend on host-specific DOM access that re-frame2 the framework does not assume — a pair tool driving a browser via CDP, a server-rendered diagnostic dump, or a static analyzer all want different "lookup the attribute" implementations. Pinning a single helper signature here would either over-constrain consumers or under-serve them. The framework commits to the attribute (stable, cross-host, parseable); the consuming tool ships the host-appropriate query primitives on top.

A future re-frame2 minor version may introduce framework-side helpers if the ecosystem converges on a single shape; the attribute contract is forward-compatible with that addition.

Reading rendered content + producing entity — the view→content read

The DOM-to-source bridge above answers "where in the code did this come from?" — a gesture/selector → source-coord. The complementary view-plane question is "what does the thing I'm looking at SHOW, and which view produced it?" — given a view-id (or a point / selector), return the rendered subtree plus the producing re-frame2 entity in one read. This rides the same view-id↔DOM map the source bridge does, in the other direction:

  • The framework's commitment is again the attributes, not the helper. Every registered view's rendered root carries both data-rf-view="<id>" (the view-id, per Spec-Schemas §:rf/view-id-attr) and data-rf2-source-coord (the source coord). The view-id attribute is the forward index — given a view-id, [data-rf-view='<id>'] locates the rendered root; given an arbitrary node (a point hit, a sub-match), the nearest [data-rf-view] ancestor is the producing view (the same DOM-containment rule the fallback view-walker uses, per Spec 006 §View tagging contract). A consuming tool needs no app-specific test ids — the substrate adapter already stamps the attribute on every view.
  • The framework does not ship the content-read helper. Like the source bridge, it is tool-side: the pair tool resolves the element via its host's DOM access, reads textContent / attribute strings, and recovers the producing entity (view-id from data-rf-view; :file-complete source coord via (rf/handler-meta :view <id>); the frame's materialised subscriptions via the sub-cache read surface). The CLJS reference's pair tool ships this as a ui/read op (wire name read-ui).
  • Privacy. Rendered DOM text can carry user data, so a content read that egresses off-box MUST route the returned text through re-frame.core/elide-wire-value with off-box defaults — the same posture §Direct-read privacy posture for sub-cache and get-path mandates for app-db reads. Declared-large content collapses to :rf.size/large-elided; raw user DOM text never rides unconditionally.

The view-plane content read is read-only by construction — it reads textContent / attribute strings / element-at-point only; it never dispatches, mutates a node, or triggers a render (the render already happened; this is a no-recompute read of its output).

Editor URI scheme allowlist

Source-mapping clicks open the resolved coord in the developer's editor. The CLJS reference (and any port that ships editor integration) accepts editor templates — URI patterns parameterised by file + line that produce a clickable IDE-protocol URI. Built-in templates cover the common targets (vscode://file/%s:%d, idea://open?file=%s&line=%d, cursor://..., subl://...); custom templates let devs route to JetBrains family editors, Sublime, Emacs / org-tooling, vim, and long-tail editor schemes via a config-level slot:

{:editor {:custom "vim://%s:%d"}}

An attacker-controllable scheme — javascript:, data:, vbscript: — that landed in the editor-template slot would be clicked, opening an attack surface in the dev's browser (script execution, embedded payload navigation). The minimum gate is a scheme rejection list, not an allowlist; everything other than the three known-bad schemes passes through.

Normative contract. Editor-template handling MUST reject URIs whose scheme is javascript:, data:, or vbscript: (case-insensitive). The three are documented XSS vectors; the rejection list is the floor. Everything else — vim:, idea:, subl:, org:, vscode:, cursor:, code-server:, and future editor schemes — passes through with no dev burden. The check fires at editor-template registration time and at click-resolution time (a registered template whose pattern produces a runtime URI on one of the three rejected schemes is also rejected at click time, so a template that interpolates user input into the scheme position is also caught). The rejection surface emits a structured warning; no silent fall-through to a default editor.

Pragmatic stance. Per Security.md §Pragmatic stance proposition 2 ("gate accidents, not theoretical attacks"), the rejection list is the minimum that closes the known-bad cases without forcing a list-maintenance burden onto every dev workflow that wants to use a long-tail editor. The audit posture is: gate javascript: / data: / vbscript: because those are known XSS vectors; do not gate vim: / idea: / subl: / future-editor-N because a long-tail allowlist would be friction without proportionate risk reduction. Per and Security.md §Editor URI scheme allowlist.

Operating frame — multi-frame resolution

re-frame2 is multi-frame (per 002-Frames.md). Every pair-tool surface that names a frame-id (app-db-value, epoch-history, restore-epoch, reset-frame-db!, dispatch, dispatch-sync, subscribe, snapshot-of, app-schemas, sub-cache) is frame-targeted — the tool must resolve a single frame before the call. In a single-frame application the resolution is trivial: every call lands in the lone registered frame. In a multi-frame application the tool needs a deterministic "operating frame" rule so successive calls don't fan out across different frames by accident, and so a user gesture like "show me app-db" has one unambiguous answer.

Pair-shaped tools (re-frame-pair, re-frame2-pair, re-frame-pair-improver, Xray, Story, any future companion that drives a multi-frame app) MUST implement the hybrid-resolution contract below. The contract is normative for pair-shaped tools — applications themselves continue to use the frame-routing rules of 002 §Routing; this section pins how a tool sitting outside the application picks which frame its read or write targets.

Resolution order. Every frame-targeted call resolves the operating frame by walking the following four tiers in order; the first tier that yields a frame-id wins:

Tier Source When it fires
1 Explicit per-call override The caller passes a frame-id with the op (e.g. (rf/app-db-value :stories), (subs-sample [:cart/total] :stories), {:frame :stories} on dispatch opts).
2 Session-pinned selection The tool's session has called select-frame! (or its equivalent) since the last reset; the pinned id is the resolved frame.
3 Sole-registered app frame After excluding :rf/* reserved tool frames (see below), exactly one app frame remains registered. That frame is the resolved frame, regardless of whether it is :rf/default or some other id.
4 Nil (ambiguous) Two or more app frames are registered, the session has not pinned a selection, and the caller did not pass an override. The resolver yields nil; the op routes via the §Ambiguity surface below.

Single-app applications never reach tier 4. A re-frame2 application with only :rf/default registered always resolves at tier 3; the pair tool's UX is identical to single-frame re-frame. The contract is structurally backwards-compatible — a single-app consumer sees no ambiguity prompt.

Reserved tool frames are excluded from the ambiguity count. A pairing session almost always runs against an app that also carries a framework-reserved :rf/* tool frame — Xray's :rf/xray inspector frame (009 §Per-frame trace rings registers it with :rf.trace/frame-no-emit? true), a stories build, an SSR slot. These frames live under the framework-reserved :rf/* root (Conventions.md §Reserved namespaces); they are devtool surfaces the tooling mounted, not the application the operator is pairing against. The resolver is therefore reserved-frame-aware: tiers 3 and 4 count only app frames(rf/frame-ids) with the :rf/* tool frames removed. A single-app session that also carries an :rf/xray frame (the common Xray-instrumented case) has exactly one app frame and resolves at tier 3 — it does not pay a tier-4 :ambiguous-frame tax for a choice that doesn't exist. A two-plus-app-frame session stays genuinely ambiguous at tier 4 regardless of any tool frames present.

The exclusion keys off the reserved-namespace rule (a frame-id whose namespace is rf), never a hardcoded :rf/xray, so it holds for every :rf/* tool frame any project mounts. The sole carve-out is :rf/default: Conventions.md §The single-root reserved set names it "the universal default frame id" — it shares the :rf/* root but is the canonical app frame, so it is never treated as a tool frame and is counted as an app frame at tiers 3 and 4. A frames/list (or get-operating-frame) read surfaces both :frames (all registered) and :app-frames (the reserved-frame-aware view) so a tool UI can show the distinction.

:rf/default is not a special-case fallback. A common naïve implementation would fall back to :rf/default at tier 4 (since it's always pre-registered per 002 §:rf/default). The hybrid contract rejects that fallback: a multi-app application's :rf/default is one app frame among many, and silently landing reads or writes there masks the ambiguity rather than surfacing it. Tier 3 picks :rf/default only when it is the unique app frame.

Session-pin lifecycle. A select-frame! call binds the operating frame for the session and persists across subsequent calls (the "implicit-until-reset" half of the hybrid posture). The selection is cleared by either a reset-operating-frame! (or equivalent) call, a runtime reload (the session sentinel changes, per §How AI tools attach), or destroying the pinned frame (the next resolution falls through to tier 3 or 4). Pair tools that surface a "current operating frame" indicator in their UI read the session pin directly; tools that want to show the resolved frame call the resolver and display its result (or "ambiguous" when nil).

Frame destroyed mid-session. When the session-pinned frame is destroyed (per 002 §Destroy) the pin remains set but resolution at tier 2 yields a frame-id that no longer names a registered frame. Subsequent calls hit the destroyed-frame surface contract of §Surface behaviour against destroyed frames — read-shaped surfaces return empty shapes, mutating-shaped surfaces emit :rf.error/no-such-handler (kind :frame). The pair tool SHOULD surface this state distinctly from the tier-4 ambiguity case so the user knows to call reset-operating-frame! or select-frame! to recover.

Ambiguity surface — tier-4 behaviour

When resolution yields nil, the pair tool refuses the op rather than guessing. The refusal shape is asymmetric by intent — read-shaped and mutating-shaped ops both refuse, but pair tools may relax the read-side refusal for one-shot reads against an explicit override (tier 1), since the override IS the disambiguation.

Op class Examples Behaviour at tier 4
Mutating (writes that drive a cascade or replace app-db) pair-dispatch!, pair-dispatch-sync!, reset-frame-db!, restore-epoch Refuse. Return {:ok? false :reason :ambiguous-frame :hint <message>} (or raise (ex-info "ambiguous frame" {:reason :ambiguous-frame}) for callers that want exceptions). The op MUST NOT silently default to :rf/default — a write that lands in the wrong frame is unrecoverable without restore-epoch, and the cascade may have already fired effects.
Reading (snapshot reads, sub samples, epoch reads, sub-cache reads, per-frame trace-ring reads) app-db-value, snapshot-of, subs-sample, epoch-history, sub-cache, app-schemas, trace-buffer, clear-trace-buffer! Refuse. Same shape as mutating refusal — return {:ok? false :reason :ambiguous-frame :hint <message>}. A silent default to :rf/default would read from the wrong frame, and a multi-frame user is unlikely to want the default frame's data. The :hint SHOULD direct the user at select-frame! or the explicit-override path. Per-frame trace-ring reads ((rf/trace-buffer frame-id) for cascade bundles, (rf/trace-buffer frame-id {:flat true}) for raw events) take the frame-id as a required first argument — cross-frame consumers iterate (rf/frame-ids) and merge by :dispatch-id per 009 §Per-frame trace rings.
Registry-wide (no frame-id needed) (rf/frame-ids), (rf/registrations kind), (rf/machines), (rf/handler-meta kind id) Proceed. These ops query global registry / global registrar state and have no operating-frame concept; they bypass the resolver entirely.

The uniform refusal shape across reads and writes is the resolution committed here (the shipped impl in re-frame2-pair.runtime, landed in rf2-19xl, already follows this stricter posture). A tool MAY relax read-side refusal for ops that take an explicit override at the call site — tier 1 is the disambiguation, so a (app-db-value :stories) call with the explicit frame-id argument MUST NOT refuse even when no session pin is set. The refusal applies to the zero-arg-defaults-to-operating-frame form, where the resolver would have to invent a frame.

Tool-surface obligations

Pair-shaped tools that implement the operating-frame contract MUST expose three operations on their tool surface (names are illustrative; the shape is what's normative):

  • Set the operating frame. A call that pins a frame-id for the session (select-frame!, set-operating-frame!, etc.). The op SHOULD validate that the frame-id names a currently-registered frame at call time — passing an unknown frame returns {:ok? false :reason :no-such-frame}. Pinning a frame that is later destroyed surfaces via the destroyed-frame contract above, not at pin time.
  • Reset the operating frame. A call that clears the session pin (reset-operating-frame!, unselect-frame!, etc.). After reset, subsequent ops resolve at tier 3 or 4 again.
  • Inspect the operating frame. A read returning the resolved operating frame (or nil when ambiguous) plus the pinned selection (when distinct from resolved) plus the all-registered frame list (so callers can pick a target). The reference impl shape:
{:ok?        true
 :frames     [<frame-id> ...]   ;; (rf/frame-ids) — all registered
 :app-frames [<frame-id> ...]   ;; (rf/frame-ids) minus :rf/* tool frames
 :selected   <frame-id|nil>     ;; tier-2 session pin
 :operating  <frame-id|nil>}    ;; result of full resolution (nil = ambiguous)

The shape lets a tool UI render "you have pinned X" (the selection), "writes will go to X" (the resolved frame), and "these are the app frames vs the tool frames present" (:app-frames:frames). When :app-frames holds exactly one id while :frames holds more, the session is single-app-plus-tool-frame and :operating auto-resolved to that lone app frame (no select-frame! was needed). The selection and resolved frame diverge on a tier-1 override on the current call, or a sole-app tier-3 fallthrough the user hasn't explicitly chosen.

MCP / RPC surfacing. Tools that expose pair surfaces over MCP / RPC SHOULD enumerate the three ops in their tool catalogue under stable names (typically set-operating-frame, reset-operating-frame, get-operating-frame). The runtime contract does not pin the wire names — only the semantics — but cross-tool consistency lets a user trained on re-frame2-pair carry the mental model to re-frame-pair-improver, Xray, or Story without relearning the resolver.

Worked example — multi-frame pair session

;; Initial state: app has two frames :rf/default and :stories.
;; Resolution at tier 4 — refuses without a hint.
(subs-sample [:cart/total])
;; => {:ok? false :reason :ambiguous-frame
;;     :hint "Multi-frame session with no selected frame — pass `frame-id` or call `select-frame!` first."}

;; One-shot read with an explicit override (tier 1) — proceeds.
(subs-sample [:cart/total] :stories)
;; => 42

;; Pin :stories for the session (tier 2 from here on).
(select-frame! :stories)
;; => {:ok? true :frame :stories}

;; Subsequent calls resolve at tier 2.
(subs-sample [:cart/total])              ;; => 42
(pair-dispatch-sync! [:cart/clear])      ;; => {:ok? true :epoch-id ... :frame :stories}

;; A different frame for one call — tier 1 wins over the session pin.
(app-db-value :rf/default)               ;; reads default explicitly

;; Clear the pin; back to tier-4 refusal.
(reset-operating-frame!)
(subs-sample [:cart/total])
;; => {:ok? false :reason :ambiguous-frame ...}

The example exercises every tier — explicit override (tier 1), session pin (tier 2 binding and re-use, plus tier-1 supersession), and tier-4 refusal both before and after reset. Single-app apps never reach the refusal path.

;; Reserved-frame-aware resolution: an Xray-instrumented single-app
;; session. Two frames are registered — the app's :rf/default and Xray's
;; :rf/xray tool frame — but only ONE is an app frame, so resolution is
;; unambiguous WITHOUT a select-frame!.
(frames-list)
;; => {:ok? true
;;     :frames     [:rf/default :rf/xray]   ;; both registered
;;     :app-frames [:rf/default]            ;; :rf/xray excluded (a :rf/* tool frame)
;;     :selected   nil
;;     :operating  :rf/default}             ;; tier-3 auto-resolved the sole app frame

;; The first mutating op proceeds against :rf/default — no :ambiguous-frame tax.
(pair-dispatch-sync! [:counter/inc])
;; => {:ok? true :epoch-id ... :frame :rf/default}

;; Targeting the tool frame still works via an explicit override (tier 1).
(app-db-value :rf/xray)                    ;; reads the Xray frame explicitly

How AI tools attach

The runtime contract above is complete for the listed capabilities. A pair-shaped tool — re-frame-pair, a Claude integration, a custom debug panel, a story tool, a future pair-improver — attaches to a running re-frame2 application using only the framework primitives listed below. No re-frame-10x dependency is required, and none should be assumed. Mutating writes (state injection, hot-swap, override, configure) are commited explicitly in the table; the full set is closed at v1 and additional mutating surfaces require a Spec-ulation increment.

The full attachment surface, from the tool's point of view:

Need Surface Spec
Receive live trace events (rf/register-listener! :my-tool callback) 009 §The listener API
Receive per-event assembled epoch records (rf/register-epoch-listener! :my-tool callback) 009 §The listener API
Read recent trace history (cascades that already fired in a frame) (rf/trace-buffer frame-id) returns cascade bundles by default; (rf/trace-buffer frame-id {:flat true}) returns raw trace events; cross-frame consumers merge by :dispatch-id across rings 009 §Per-frame trace rings
Read live stream of frameless trace events (registration, REPL, lifecycle outside any cascade) (rf/register-listener! ...) — frameless emits stream live to listeners only; they are not retained in any ring (per the B3 ruling) 009 §Frameless trace events
Read epoch history per frame (rf/epoch-history frame-id) §Time-travel
Restore an epoch (rf/restore-epoch frame-id epoch-id) §Time-travel
Inject an app-db value (state injection / story / repro) (rf/reset-frame-db! frame-id new-db) §Pair-tool writes
Configure history depth (rf/configure! :epoch-history {:depth N}) and (rf/configure! :trace-buffer {:cascades-retained N}) (process-default); :rf.trace/cascades-retained on reg-frame (per-frame override) API.md, 009 §Per-frame trace rings
Inspect registered app-db schemas (rf/app-schemas frame-id) 010 §Schemas as a tooling and agent surface
Tag dispatches by actor (e.g. tool vs app) :origin opt on (rf/dispatch event opts) 002 §Dispatch origin tagging
Correlate a dispatch cascade :rf.trace/dispatch-id + :rf.trace/parent-dispatch-id on :rf.event/dispatched traces 009 §Dispatch correlation
Enumerate frames (rf/frame-ids), (rf/frame-meta id) 002 §Public registrar query API
Read a frame's app-db (rf/app-db-value frame-id) / (rf/snapshot-of path opts) 002 §Public registrar query API
Inspect the registry (rf/registrations kind), (rf/handler-meta kind id) 001, 002
Enumerate machines (rf/machines), (rf/machine-meta id) 005 §Querying machines
Inspect the sub-cache (CLJS-only) (rf/sub-cache frame-id) 002 §Public registrar query API
Source coords for any registration :ns/:line/:column/:file keys on (handler-meta ...) return; shape :rf/source-coord-meta per Spec-Schemas 001 §Source-coordinate capture
Dispatch (rf/dispatch event opts) / (rf/dispatch-sync event opts) 002 §Routing
Stub fx for an experiment :fx-overrides {:http stub-id} on dispatch opts 002 §Per-frame and per-call overrides
Hot-swap a handler Re-call (rf/reg-event-fx id ...); :rf.registry/handler-replaced trace fires 001 §Hot-reload semantics
REPL eval against the runtime The host's REPL (nREPL+CIDER for CLJS); private namespaces are off-contract §REPL-eval

Platform-availability note. Rows tagged "(CLJS-only)" — (rf/sub-cache frame-id) is the load-bearing example — are CLJS-host-only surfaces; JVM hosts (SSR, headless tests, conformance runners) ship no equivalent. Pair tools driving JVM-side test runs MUST gate the call (e.g. (when (cljs-host?) (rf/sub-cache frame-id))) — JVM-host return shape is not yet specified (tracked separately) and consumers should not assume nil-vs-throw across hosts. Surfaces NOT tagged "(CLJS-only)" are portable by design.

The consumption pattern is therefore:

A pair-shaped tool registers as a trace listener (and/or as an epoch listener for assembled per-cascade records), reads recent history from the per-frame trace rings (cascade-bundle shape by default, :flat opt-in for raw events; cross-frame consumers merge by :dispatch-id), queries the registrar for shape (the source of truth for "what's registered right now" — registry queries replace any need to scan rings for frameless events per the B3+B4 ruling), walks the epoch history for time-travel, and dispatches into frames to drive experiments. That's the entire surface.

Two listener shapes coexist by design: register-listener! is the raw stream — every event the runtime emits, fine-grained — used by tools that need per-emit detail (custom recorders, error-monitor forwarders, timing aggregators). register-epoch-listener! is the assembled stream — one fully-shaped :rf/epoch-record per dequeued event (per 002 §Drain versus event), with the structured :sub-runs / :renders / :effects projections already computed — used by tools that route diagnostics off "what just happened in this cascade" rather than reconstructing it from the raw trace each time. Pair-shaped tools typically prefer the assembled stream for routing and reach for the raw stream only when they need detail the projection drops.

This is dev-only end-to-end — every primitive listed above elides in production builds (per 009 §Production builds). Pair-shaped tools do not ship in production binaries.

Subscribing to a slice of the trace stream

register-listener! callbacks see every trace event. Tools that only care about a single subsystem filter inside the callback by :op-type — the universal discriminator (per 009 §:op-type vocabulary). The pattern is one-key dispatch on the event:

;; A tool (Xray's flow panel, a pair-tool flow inspector,
;; a custom dashboard) subscribes to JUST the flow trace stream.
;; Per Spec 009 §Flow trace events, every flow lifecycle event carries
;; :op-type :flow with the per-event identity in :operation
;; (:rf.flow/registered, :rf.flow/computed, :rf.flow/skip,
;; :rf.flow/cleared, :rf.flow/failed).

(rf/register-listener!
  :my-tool/flow-panel
  (fn [ev]
    (when (= :flow (:op-type ev))
      (case (:operation ev)
        :rf.flow/registered  (track-flow-registration! ev)
        :rf.flow/computed    (record-flow-computation! ev)
        :rf.flow/skip        (note-skip! ev)               ;; (per  value-equal recompute suppression)
        :rf.flow/cleared     (drop-flow-state! ev)
        :rf.flow/failed      (surface-flow-error! ev)
        nil))))

The same pattern works for any subsystem with a dedicated op-type — :rf.machine for state-machine activity, :rf.event for the dispatch / drain stream, :rf.sub for subscription work, :rf.fx for effect handlers. New op-types are additive (per 009 §Open shape; new fields are additive); tools ignore op-types they don't understand.

For per-cascade structured projections (sub-cache hit/miss, render attribution, effect outcome), tools route off register-epoch-listener!'s assembled :rf/epoch-record instead — the §Time-travel projection slots already pre-fold the per-cascade trace into the :sub-runs / :renders / :effects shape. The raw-stream filter pattern above is the right shape for fine-grained per-event consumption.

Subscribing to assembled epoch records

register-epoch-listener! callbacks fire once per dequeued event (one per epoch, per 002 §Drain versus event), with the cascade's :sub-runs / :renders / :effects projections already computed. A drain that settles several events back-to-back therefore fires the callback once per settled event, in dequeue order. Pair-shaped tools, post-mortem dashboards, and "what just happened?" probes typically consume this shape rather than re-folding the raw trace stream:

;; A pair-tool dashboard routing diagnostics off the assembled per-cascade record.
;; - One callback per dequeued event / epoch (NOT per drain, NOT per emitted trace event).
;; - The record is fully shaped: :db-before, :db-after, :sub-runs, :renders, :effects.
;; - The record has already been appended to (rf/epoch-history (:frame ev)).

(rf/register-epoch-listener!
  :my-tool/dashboard
  (fn [{:keys [frame event-id epoch-id sub-runs renders effects] :as record}]
    ;; Cache-hit-vs-rerun: every entry in :sub-runs is a recompute;
    ;; cache-hit subs are absent. Counting :sub-runs answers
    ;; "how many subs moved this cascade?"
    (record-recomputes! frame event-id (count sub-runs))

    ;; Render attribution: :renders[*].:render-key is [<view-id> <instance-token>].
    ;; Aggregate by first slot to count "view X re-rendered N times this cascade."
    (doseq [{:keys [render-key elapsed-ms]} renders]
      (record-render! frame (first render-key) elapsed-ms))

    ;; Fx outcome: every dispatched fx surfaces exactly one :effects entry.
    ;; :outcome ∈ {:ok :error :skipped-on-platform}; route :error entries to UI.
    (doseq [{:keys [fx-id outcome error-trace]} effects]
      (when (= :error outcome)
        (surface-fx-error! frame epoch-id fx-id error-trace)))

    ;; The epoch is already in (rf/epoch-history frame); no need to re-query
    ;; unless the dashboard wants the full vector for context.
    nil))

Edge-case behaviour the example does not exercise but consumers should know about:

  • Listener exceptions are caught. A throw inside the callback does not propagate to the framework or other listeners (per 009 §register-epoch-listener! invocation rules). The framework does not auto-evict the throwing listener — repeated throws keep the registration in place; eviction is the consumer's call.
  • Re-entrant dispatch from a callback. A callback that calls (rf/dispatch …) enqueues the new event; the new dispatch's drain begins on stack-unwind from the current callback fan-out, not before. Other registered epoch listeners still receive the current record before the re-entrant dispatch begins.
  • (rf/configure! :epoch-history {:depth 0}) and listeners. Setting depth to 0 disables the per-frame ring buffer (so (rf/epoch-history frame-id) returns []) but does not stop epoch listeners from firing — register-epoch-listener! callbacks continue to receive the assembled record once per dequeued event. Tools that need the assembled stream without retaining history should set depth 0 and consume via register-epoch-listener! only.
  • Frame-destroyed mid-observation. Tool-Pair surface behaviour against destroyed frames (epoch-history reads, in-flight epoch-cb deliveries, restore against a now-destroyed frame, listener silencing) is closed in §Surface behaviour against destroyed frames. Read-shaped surfaces return empty shapes; mutating-shaped surfaces raise :rf.error/no-such-handler (kind :frame); a previously-firing callback whose observed frame is destroyed receives a one-shot :rf.epoch.cb/silenced-on-frame-destroy trace.

Reading the per-frame trace ring — cascade bundles + :flat opt-in

The per-frame trace ring is the in-process companion to epoch-history — same per-frame model, same cascade-keyed retention, but the trace detail of each cascade rather than the assembled-projection. Tools route off the ring when they need the raw :rf.sub/run / :rf.sub/skip / :rf.fx/handled / :rf.machine/* event stream of recent cascades (per-event timing, full sub-cascade DAG, fine-grained fx ordering) without rebuilding it from a register-listener! archive.

The read surface is per-frame and returns cascade bundles by default:

;; Default — one entry per retained cascade, projection-shape per [009 §Cascade projection]
(rf/trace-buffer :step-deck)
;; → [{:dispatch-id 17
;;     :trace-events [...]
;;     :event [:user/click ...]
;;     :handler {:operation :rf.event/run-start ...}
;;     :fx {:operation :rf.fx/do-fx ...}
;;     :effects [...]
;;     :subs [...]
;;     :renders [...]
;;     :other [...]}
;;    {:dispatch-id 18 :trace-events [...] :event [...] ...}
;;    ...]

;; Opt-in — flatten back to raw trace events (escape hatch for existing flat-stream code)
(rf/trace-buffer :step-deck {:flat true})
;; → [{:operation :rf.event/dispatched :tags {:rf.trace/dispatch-id 17 ...} ...}
;;    {:operation :rf.event/run-start  :tags {:rf.trace/dispatch-id 17 ...} ...}
;;    {:operation :rf.sub/skip         :tags {:rf.trace/dispatch-id 17 ...} ...}
;;    ...]

The default matches the storage unit (one cascade = one slot); the :flat form reads the ring's per-cascade slots and flattens them at read time. Filters compose AND-wise; see Spec 009 §Filter vocabulary.

Cross-frame cascade reconstruction — merge by :dispatch-id

A tool watching multiple frames (a pair-MCP session observing both :rf/xray and the inspected app frame; a story-MCP session walking a multi-frame variant; an off-box dashboard merging trace from N frames) reconstructs a unified timeline by reading each frame's ring and merging entries by :dispatch-id. Each frame retains the traces that EXECUTED IN IT — the framework does not maintain a process-global index — so the merge is the consumer's job, and it is cheap because each ring already keys by :dispatch-id:

(defn watch-all-frames []
  (->> (rf/frame-ids)
       (mapcat (fn [fid] (map #(assoc % :frame fid) (rf/trace-buffer fid))))
       (sort-by :dispatch-id)))
;; → vector of {:dispatch-id N :frame <kw> :trace-events [...] ...} entries,
;;   suitable for rendering as a single timeline; entries with the same
;;   :dispatch-id are the same cascade observed from each frame's ring.

In practice, each cascade lives in exactly ONE frame (re-frame2 does not route a single dispatch across multiple frames per Spec 002), so the multi-frame view is interleaved rather than overlapping — dispatch-id ordering renders the correct turn-by-turn timeline.

watch-epochs / trace-window consumer shape (MCP layer)

The MCP-server surfaces that stream the per-frame ring to off-box agents (re-frame2-pair-mcp's trace-window / watch-epochs / subscribe per tools/re-frame2-pair-mcp/spec/003-Tool-Catalogue.md) MUST deliver complete cascade bundles per stream tick — the storage unit on the wire. Consumers receive one {:dispatch-id N :trace-events [...] :event :handler :fx :effects :subs :renders :other} per tick rather than per-event chunks they would have to re-fold. The in-process zero-arg (rf/trace-buffer frame-id) already returns this shape; off-box delivery mirrors it.

The cascade-bundle wire shape DIFFERS from the in-process :flat true opt-in by intent: in-process callers may need raw events for fine-grained per-emit logic; off-box agents need atomic cascade context per stream tick to stay within token budgets and to reason about cause→effect at a granularity that matches :dispatch-id. The bundle is the better default for LLM-shaped consumers; the :flat form is the escape hatch for in-process callers that already grouped raw streams themselves.

Per cursor-pagination and per-session cache conventions in §Wire-protocol mechanisms compose unchanged: a :epoch-id-keyed cursor identifies the next epoch slot in epoch-history; a :rf.mcp/cursor-stale marker fires when the cursor's :epoch-id has aged out of epoch-history; per-session response cache keys on app-db root identity, so the cascade-bundle shape doesn't change its hit semantics.

Authoritative-ring read — no parallel capture buffer (the read-truth-each-call invariant). The trace-window / watch-epochs surfaces MUST read the runtime's authoritative per-frame ring — (rf/epoch-history frame-id) (and epochs-since over it), the same source an arbitrary eval-cljs reaches directly — NOT a parallel session-side capture buffer that fills only while a listener is attached. A session-side buffer that mirrors the ring is a drift-prone proxy: it returns empty while the ring holds epochs (the buffer was attached after the epochs landed, or a reconnect reset it), producing a silent WRONG read an agent acts on. The pair session is a thin, runtime-direct reader — it reads truth on each call and carries liveness (see §Freshness / liveness token below) rather than maintaining a stateful copy. A tool MAY keep a derived scalar (e.g. an O(1) (hash app-db) cache the precheck consults) but MUST NOT keep a record copy that a read consults in place of the ring. When a time-windowed read (trace-window's :ms) surfaces zero epochs while the ring is non-empty, the tool MUST distinguish "genuinely empty" from "events exist but fell outside the window" with an advisory naming the ring depth — an empty result is never ambiguous between "nothing happened" and "the tool isn't capturing".

Frameless trace events — live channel only

Frameless trace events (registrations, REPL emits, lifecycle outside any cascade) bypass every ring and stream live to listeners only. Consumers that want frameless emits — registration-drift monitors, hot-reload diagnostics, REPL-eval tracers — subscribe to the live stream via register-listener! and filter by :op-type / :operation:

(rf/register-listener!
  :my-tool/registry-monitor
  (fn [ev]
    (when (and (= :rf.registry (:op-type ev))
               (nil? (get-in ev [:tags :rf.trace/dispatch-id])))
      (record-registration-event! ev))))

The framework runs hot-reload dedup by shape (B4 ruling) at the registrar before the trace fires — unchanged re-emits are suppressed, so the live stream a listener observes is already noise-filtered. Tools wanting "the current set of registered handlers" consult (rf/registrations kind) (per 001 §The query API) rather than reconstructing it from live-stream observation. The registry is the source of truth; the live stream is the change-log.

Off-box wire delivery of frameless events MAY ride a separate stream channel from the cascade-bundle stream (since the two carry structurally different payloads — cascade bundles vs single trace events) and consumers MUST subscribe to it explicitly rather than assuming frameless events ride the cascade stream. The split is the framework's; downstream MCP tooling that ships subscribe-style notifications shapes the channel separation in its own surface (see tools/re-frame2-pair-mcp/spec/003-Tool-Catalogue.md for re-frame2-pair-mcp's shape).

Implications for downstream tools

  • re-frame-pair (the upstream nREPL companion) consumes only the surfaces above. It depends on re-frame2; it does not depend on re-frame-10x.
  • Xray (the structural successor to re-frame-10x; Maven coord day8/re-frame2-xray) is built as a renderer of the same surfaces — a registered trace listener, a consumer of epoch-history, a query consumer of the registrar, a UI on top. Xray and pair share the substrate; one does not depend on the other.
  • Custom debug panels, story tools (Spec 007), and pair-improver-style skills consume the same surface. Multi-tool coexistence is the expected default — multiple register-listener! keys, multiple readers of the trace buffer, multiple consumers of the registrar. Listener ordering is not contract (per 009 §Listener ordering).

The framework is infrastructure-complete for AI-tool consumption: data shapes, query APIs, retention policies, configuration knobs, production elision. Downstream tools own presentation and orchestration; they do not need to ship infrastructure that should live in the framework.

The Xray renderer

Xray's host-integration model is the true-inline layout host — the app provides a left-side [data-rf-xray-host] column in its normal layout, and the Xray preload mounts the rendered shell into that host once the substrate adapter is ready. The host owns sizing and layout; Xray owns the rendered shell (so the host's flex/grid rules govern the panel's geometry, and the app stays visible/clickable to the right). The normative contract — layout-host selector, mount lifecycle, hot-reload posture, popout window, missing-host diagnostic, teardown semantics — lives in tools/xray/spec/011-Launch-Modes.md §Mount lifecycle and §Layout host contract.

Resizing the inline panel is a one-property CSS contract: the recommended host snippet reads var(--rf-xray-inline-width, 560px) for its flex-basis, so developers override the panel width by setting --rf-xray-inline-width anywhere up the cascade (:root, an ancestor, the host itself, or a user stylesheet). The same snippet publishes --rf-xray-accent (default #7C5CFF, Xray's brand violet) on :root so host stylesheets can read it to tint their own dev chrome without forking the hex. Both contracts are JS-free and host-owned — Xray does not read or set either property, the host's stylesheet is the single source of truth (per tools/xray/spec/011-Launch-Modes.md §Resizing the inline host and §Brand-accent CSS variable, +).

The shadow-cljs dev-build preload is the canonical wiring: :preloads [day8.re-frame2-xray.preload] runs the foundation's idempotent side-effects (handlers registration, trace + epoch collectors, browser API, keybinding, auto-open) all gated on interop/debug-enabled? so Closure DCE strips them from production bundles. The AI access path is tools/re-frame2-pair-mcp/ — raw nREPL pair-programming companion; it consumes the same Tool-Pair instrumentation as Xray, and the two surfaces are independent.

Wire-protocol mechanisms (MCP-tool layer, not framework)

A pair-shaped tool reaching an AI agent over MCP must shape the payload at the wire boundary — a runtime snapshot, an epoch record, or a trace slice is routinely far larger than the agent's per-call budget can absorb. The mechanisms that solve this (token-budget cap, path slicing, cursor pagination, lazy summary, structural dedup, size-elision wire markers, and — re-frame2-pair-mcp-specific — diff-encoded :db-after and streaming subscribe byte+event budgets) live at the MCP-server layer, not in this Spec. Tool-Pair.md commits to the framework surfaces (data shapes, query APIs, listener APIs); how an MCP tool packages those surfaces for an agent is downstream.

The cross-MCP catalogue is normative and shared across the re-frame2 MCP servers (re-frame2-pair-mcp, story-mcp). Canonical homes:

The framework owns the data; the wire-protocol layer owns the packaging. Findings docs and downstream Specs reaching for the mechanism catalogue link to the MCP-server homes above, not back into this Spec.

MCP-side wire-marker vocabulary

The mechanisms above emit a small family of namespaced wire markers — replacement envelopes that pair-shaped tools wrap a payload in when a budget / dedup / cache rule fires. Every marker rides as a top-level map keyed on a :rf.mcp/* or :rf.size/* keyword (per Conventions.md §Reserved namespaces); agent hosts pattern-match on the key to branch their UI / retry / drill-in flows. Each artefact's own Spec mentions a subset; the family in one place:

Marker key Mechanism Emitted by Shape (top-level keys)
:rf.mcp/overflow Wire-cap Post-eval cap step replaces an over-budget payload. :limit :reached, :token-count, :cap-tokens, :tool, :hint.
:rf.mcp/summary Lazy-summary mode snapshot replaces each :summary-mode rich slice. :type, :keys, :count, :bytes.
:rf.mcp/dedup-table Structural dedup Every epoch / events vector emitter wraps post-encoded payload. :de-dupe.cache/cache-0, :de-dupe.cache/cache-1, … (flat).
:rf.mcp/diff-from Diff-encoded :db-after Each epoch's :db-after is replaced with an intra-record diff projected into path-headed cluster sections. :rf.mcp/diff-from, :sections (vector of {:section-path :section-kind :patches}).
:rf.mcp/cache-hit Per-session response cache — keyed on app-db root identity; cache poisoning by mismatched session is structurally impossible (see §Per-session app-db cache — cache-poisoning posture) Wire-boundary cache replaces a byte-identical re-emit. :hash, :unchanged-since, :tool, :via (:result-hash / :precheck), :hint.
:rf.size/large-elided Size-elision walker rf/elide-wire-value substitutes over-threshold leaves. :path, :bytes, :type, :reason, :hint, :handle ([:rf.elision/at <path>]).
:rf.mcp/cursor-stale Cursor pagination trace-window / watch-epochs refuse a cursor whose :epoch-id aged out of the ring. :reason :rf.mcp/cursor-stale, :tool, :requested-id, :head-id.
:rf.mcp/result Wire fidelity / typed result codec (see §Wire fidelity below) The runtime-side classifier wrap (eval-cljs, handler-meta) tags an eval result so a genuine nil, a thrown eval-error, and an unserializable value are DISTINCT outcomes. :rf.mcp/result (one of :value / :nil / :eval-error / :unserializable); shape-by-tag: :value <v> · (no extra) · :reason :ex :message :ex-data · :type :preview.
:freshness Freshness / liveness token (see §Freshness / liveness token below) discover-app (and any read op that opts in) attaches it so a stale / disconnected / stale-BUILD runtime is obvious up front. :runtime-instance-id, :runtime-loaded-at, :read-at (browser half); :compile-cycle, :build-flushed-at, :runtime-count, :heartbeat-age-ms, :build-id (JVM half); :liveness (one of :fresh / :stale-build / :no-runtime / :unknown); :hint when non-fresh.
:rf/redacted Privacy walker (redact-interceptor, spec/009 §Privacy) The framework-side redact walker replaces named keys at emit time. Scalar sentinel (no map shape).

Agents that learn the family see each new slot as one more case in the same pattern-match — the namespaced first key is the discriminator. New mechanisms add to the table; existing markers are stable (additive: new optional keys, never removed / renamed).

The size markers above (:rf.mcp/overflow, :rf.mcp/summary, :rf.mcp/dedup-table, :rf.mcp/diff-from, :rf.size/large-elided) are wire-SIZE mechanisms — they shrink an over-budget payload. The :rf.mcp/result marker is a wire-FIDELITY mechanism — it types the outcome of an evaluation so distinct results stop collapsing to a bare null. The two layers compose: the fidelity codec tags the outcome, and the size walker still runs over the :value payload it carries — fidelity never bypasses the budget machinery (see §Wire fidelity).

Wire fidelity — typed result envelope, dispatch-consequence, arg echo/validate

The size mechanisms above keep a payload small; three further mechanisms keep the wire honest — they make the boundary speak re-frame2 semantics so an agent reasons about what actually happened instead of inferring it from a lossy null. All three are the §No silent swallow principle applied to the MCP wire.

1. Typed, total result envelope (the :rf.mcp/result marker). A pair-shaped tool that evaluates an expression against the runtime (eval-cljs) or reads runtime metadata (handler-meta) MUST distinguish, at the wire boundary, between a value that is genuinely nil, an evaluation that threw (an unresolved symbol, a compile/resolve warning surfaced as a throw, any runtime exception), and a value that is unserializable (a #object[...] / #js {…} / Function ref that pr-str renders but EDN cannot read back). The pre-fidelity path collapsed all three to a bare null, costing round-trips and producing wrong conclusions (a wrong-namespace guess looked identical to a genuine nil). The codec is a single runtime-side classifier wrap (the live value is the only place serializability and thrown-exception detection are observable) that tags the outcome under the cross-MCP :rf.mcp/result key; the server projects each tag onto the tool's :ok? vocabulary. handler-meta returns the parsed metadata map, never the metadata smuggled as a string under :value. The codec is additive — a legacy untagged value flows through unchanged — and composes with elision: it tags the outcome; the size walker still runs over the :value payload it carries.

2. Dispatch returns the consequence by default. The default dispatch result is the re-frame2 consequence of the dispatch — {:epoch-id :db-changed? :changed-paths :effects-fired :no-op?} — not a transport ACK. The data is already assembled in the epoch the cascade recorded; the tool projects it so a no-op is VISIBLE (:db-changed? false :effects-fired [] :no-op? true) instead of riding back as a success-shaped ack indistinguishable from a real landing. dispatch → verify collapses into one call. An async/queued dispatch whose cascade has not drained returns {:settled? false} so the agent knows to watch-epochs the eventual settlement; the full assembled epoch stays an opt-in trace mode. Two further opt-in modes drive the view layer in one call: :await-render resolves once the substrate has flushed to the DOM and the next paint is scheduled (async, via the rAF-timed after-render hook), and :settle dispatches → synchronously commits pending renders via the installed adapter's flush-render! → returns the settled epoch including its :rf.view/render / :rf.view/unmounted entries (per §Driving the render — The dispatch-and-settle contract).

3. Parse-once, echo, and registry-validate the call-time arguments. Every id / event-vector / sub-vector crosses the wire as a string and is coerced to EDN. The boundary MUST parse that EDN once, echo the resolved value in the result (:resolved <edn>) so the agent sees exactly what the runtime parsed (catching a malformed coercion like a ::rf/xray double-colon before it silently mis-routes), and validate the id against the LIVE registry. An unknown frame / event / sub MUST return a structured error carrying nearest matches ("unknown :event :rf/xrayy; did you mean :rf/xray?") and MUST NOT silently no-op a dispatch against an unregistered id. This is call-time VALUE validation; it complements the descriptor-generation surface that validates tool DESCRIPTORS from the registries at attach time (the two are distinct — descriptor validation pins the shape an agent may call; this pins the value a specific call carries).

Reference impl: re-frame2-pair-mcp. The typed codec lives in tools/re-frame2-pair-mcp/src/re_frame2_pair_mcp/tools/result_envelope.cljs (the runtime-side wrap-form classifier + the server-side envelope->result projection); eval-cljs and handler-meta route through it. The dispatch consequence + call-time validation live runtime-side in skills/re-frame2-pair/preload/re_frame2_pair/runtime.cljs (dispatch-consequence!, validate-event-id / validate-registered with edit-distance nearest-ids), surfaced by the dispatch tool. All three are framework-general — any pair-shaped server consuming this Spec inherits the contract.

Freshness / liveness token (MCP layer)

A pair session reads live runtime state across a chain of moving parts — the MCP server holds an nREPL socket, shadow-cljs (or the equivalent build tool) holds a build worker, a browser tab holds the running heap. Any link can drift from the others without failing loudly: the tab refreshed (the heap and every cached handle reset, but the socket is up); the WebSocket to the runtime dropped (eval round-trips return blank, indistinguishable from a form that genuinely returns nil); or — the silent killer — a stale incremental build is serving OLD code (the runtime is alive and answering, but running code from before the operator's last edit). The third mode cost a real ~30-minute false-alarm debugging detour (a "bug" that was just a stale build).

discover-app (and any read op that opts in) MUST carry a freshness token that makes all three obvious on the first call, so an agent pattern-matches on liveness before trusting a read rather than inferring staleness later from a wrong conclusion. The token is the §No silent swallow principle applied to the connection, not the value. It has two halves:

  • Browser half (only the running heap knows these): :runtime-instance-id — a per-load identity that changes on every full page reload / heap reset, so an agent that cached an id and sees a new one knows the tab refreshed and stale handles must re-discover; :runtime-loaded-at — the wall-clock moment the running code loaded (a load-time def, not a per-call now); :read-at — the wall-clock at the moment of the read (proves the runtime answered, and with :runtime-loaded-at yields uptime).
  • JVM / build-tool half (only the build worker holds these): :compile-cycle — a monotonic build counter that bumps on every successful compile (the "is my edit live?" id); :build-flushed-at — the wall-clock of the build's last flush (the compile that produced the bytes currently on disk); :runtime-count — connected runtimes for the build; :heartbeat-age-ms — ms since the most recent runtime's ping/pong exchange (the WS heartbeat).

The server cross-checks the two halves into a single :liveness verdict: :stale-build when :build-flushed-at is strictly newer than :runtime-loaded-at (the build recompiled after the running code loaded — the tab is serving old code; reload); :no-runtime when zero runtimes are connected or the heartbeat is older than a staleness threshold; :fresh when a runtime is connected, the heartbeat is recent, and the build has not recompiled since the runtime loaded; :unknown when the build-tool half can't be read (degrade to the browser half — never crash). :no-runtime wins over :stale-build (you cannot serve stale code if nothing is connected); a non-fresh verdict carries a :hint naming the recovery. The token is a diagnostic signal, never a hard gate — the tool still runs; the agent just sees the staleness. The monotonic :compile-cycle is the same surface a standalone "is my edit live?" build-id check would expose, so it subsumes a separate compile-id mechanism.

Reference impl: re-frame2-pair-mcp. The browser half is stamped at preload load-time in skills/re-frame2-pair/preload/re_frame2_pair/runtime.cljs (session-id / loaded-at, surfaced by freshness and merged into health); the JVM half + cross-check + :liveness verdict live in tools/re-frame2-pair-mcp/src/re_frame2_pair_mcp/tools/freshness.cljs (reads the shadow-cljs build worker's ::build/build-info :compile-cycle / :flush-complete and the worker :runtimes ping/pong via a JVM eval), attached by discover-app. Any pair-shaped server consuming this Spec inherits the contract — the build-tool half is read from whatever build tool the server drives.

Direct-read privacy posture for sub-cache and get-path

Most Tool-Pair surfaces ride the trace bus (register-listener! / register-epoch-listener!) or the event-emit substrate, where :sensitive? stamps and size markers are applied at the emit boundary. Two surfaces in the attachment table above do not ride that path: (rf/sub-cache frame-id) and the MCP-server get-path tool (a direct read-by-path against (rf/app-db-value frame-id)). Both are synchronous reads of live runtime state — the sub-cache map, an arbitrary path into app-db — and the :sensitive? trace stamp protects only the trace surface. A direct read returns the live value untransformed unless the wire-egress boundary scrubs it.

The framework's wire-egress walker is rf/elide-wire-value (per API.md §Size-elision wire-boundary walker) — the single normative emission site for :rf/redacted (sensitive) and :rf.size/large-elided (oversize) markers. This subsection pins the contract that every MCP-server (or other off-box forwarder) implementing sub-cache or get-path surfaces must honour — re-frame2-pair-mcp rides it today; story-mcp and any third-party server shipping the same tool names inherits the same posture.

Normative contract ( / — the framework-wide MUST; Security.md §Direct-read privacy posture names this surface as Tool-Pair's owned contract).

  • A pair-shaped tool that ships a sub-cache surface (the direct read of (rf/sub-cache frame-id) per §How AI tools attach) MUST route the returned {query-v {:value v :ref-count n}} map through rf/elide-wire-value before the value crosses the wire egress. Off-box defaults apply: :rf.size/include-sensitive? and :rf.size/include-large? both default false, so a sub whose query-v lands on a declared-:sensitive? path (or whose :value is a slot flagged :sensitive? true via [:rf/runtime :elision :declarations]) returns the :rf/redacted sentinel; a :value exceeding :rf.size/threshold-bytes returns the :rf.size/large-elided marker. The composition rule of API.md §Size-elision wire-boundary walker applies — when both predicates match the sensitive drop wins (the size marker is suppressed because it would leak :path / :bytes / :digest).
  • A pair-shaped tool that ships a get-path surface (the direct read of (get-in (rf/app-db-value frame-id) path)) MUST route the resolved value through rf/elide-wire-value before egress, passing :path path and :frame frame-id in the opts so the elision marker's :handle slot carries [:rf.elision/at <path>] and the agent can drill into a non-elided child by re-calling get-path with a deeper segment. Off-box defaults apply identically to sub-cache above. The walker reads the live [:rf/runtime :elision :declarations] and [:rf/runtime :elision :sensitive-declarations] registries from the named frame's app-db — it MUST therefore run app-side (server-side, where the registry is reachable), not in the MCP server's host process.
  • Both surfaces MUST honour the opt-in escape hatches per the cross-MCP convention: :include-sensitive? true on the MCP call opts forwards :sensitive? true slots verbatim, and :elision false (or equivalent) bypasses the walker entirely. The escape hatches are off by default — a tool that omits the opts gets the elided posture. Apps that need the raw value at a sensitive path are responsible for explicitly opting in at the call site, the same posture the trace-stream forwarders take.

Why direct reads need explicit elision. The :sensitive? declaration on a registration (per 009 §Privacy / sensitive data in traces) and the redact-interceptor interceptor both shape what the trace surface emits — :sensitive? stamps the trace event, redact-interceptor overwrites named payload keys with :rf/redacted before the handler chain runs. Neither touches the live app-db or the live sub-cache. A direct read against either bypasses both mechanisms by design: the trust boundary is the trace surface plus the MCP egress, not the app-db itself. Without an explicit elision step at the egress, a sub-cache or get-path call would return the live value verbatim — including any sensitive slot declared :sensitive?, and including a :value larger than the wire budget can absorb. The MUST contract above closes that gap at the MCP-server layer where every direct-read surface lives.

Reference impl: re-frame2-pair-mcp. The tools/re-frame2-pair-mcp server honours every clause of this contract — get-path-tool wraps the resolved value in a (re-frame.core/elide-wire-value v {:path path :frame <fid> ...}) call before returning to the client, and the snapshot tool routes BOTH its :app-db AND :sub-cache slices through the same walker server-side (see the reduce-kv body in tools/re-frame2-pair-mcp/src/re_frame2_pair_mcp/tools/snapshot.cljs that threads each frame's slice through elide-wire-value with :frame fid so the per-frame [:rf/runtime :elision] registry hits the right declarations). Any third-party sub-cache tool MUST land the same wrapper from day one — the contract here is normative, not aspirational. The framework-side guarantee that the walker honours sensitive / large declarations against sub-cache-shape input (rather than only app-db-shape input) is pinned in implementation/core/test/re_frame/elision_test.clj under the sub-cache-shape-walker-* family.

Defence in depth — what this contract does and does not displace. This subsection commits the wire-egress posture for direct reads. It does not displace path-level privacy mechanisms upstream of the read:

  • Apps that need fine-grained app-db privacy continue to use redact-interceptor on the writing handler so the trace stream's :db-after projection is redacted at write time, regardless of subsequent reads.
  • Apps that need stronger guarantees keep sensitive values out of app-db entirely (host-side keychain, IndexedDB with separate-origin access, in-memory only) — the contract above protects the wire, not the in-process value.
  • A future framework-side path-level privacy mechanism (potential post-v1 surface; see 009 §Privacy / sensitive data in traces for the design space) would compose under the same wire-egress contract — elide-wire-value is the single normative emission site, so any future tightening of the path-level surface flows through the same walker without changing the MCP-server obligation here.

Cross-references: Security.md §Trust-boundary matrix (surface-keyed index — see the "Direct app-db read" and "Sub-cache read" rows for the consolidated owner / default / opt-in / impl-hook / conformance / consumers projection), API.md §Size-elision wire-boundary walker (the walker contract), API.md §Privacy (:sensitive? stamp + redact-interceptor interceptor), 009 §Privacy / sensitive data in traces, Conventions.md §Reserved namespaces (framework-owned) (the :rf.size/* / :rf.elision/* wire keys), Security.md §MCP tool authority and isolation (the framework-wide threat-model entry).

MCP tool authority classes — named-mutation vs eval-cljs

Pair-shaped MCP servers ship tools that read and mutate live runtime state. Two qualitatively-different authority classes, each gated differently:

  • Named-mutation tools — no extra approval gate. Invoking a re-frame2-pair-mcp / story-mcp tool that names its operation — dispatch, restore-epoch, reset-frame-db, get-app-db, get-path, snapshot, sub-cache — is itself the explicit consent. The operator launched the server; the operator invoked the tool with a known event id / path / frame id; the framework does not insert a per-call confirmation prompt. The debugging surface MUST be low-friction or programmers route around it (per Security.md §Pragmatic stance proposition 1 — "trust the explicit invoker"). The threat model that gates here is the programmer made an accident, not a remote attacker.
  • Arbitrary-eval tools — launch-flag opt-OUT, default ON. Tools that accept arbitrary host-language source and execute it in the runtime process (e.g., eval-cljs, or the host's equivalent eval surface) are the REPL primitive of a pair-debug session — arbitrary form evaluation against the live runtime is the whole reason an operator installs a pair-MCP. Published builds (re-frame2-pair-mcp) ship the eval surface ENABLED. The operator opts OUT via an explicit launch flag (--no-eval or equivalent) for the rare paranoid case (CI runs, shared dev environments). The threat-model rationale for the inversion: a default-OFF eval gate did not add a protection separable from --allow-writes because eval can express any write the writes-gate would block — the two gates are not independent. So the prior default-OFF eval surface added friction (every operator had to edit their MCP client config and restart Claude Code to access the REPL primitive) without adding a separable protection. The real defence is the localhost-bind default below and don't expose the MCP to untrusted callers.

The split is normative for every pair-shaped MCP server downstream of this Spec; tools that ship an arbitrary-eval surface MAY default it ON (the re-frame2-pair-mcp posture) and SHOULD expose a launch-flag opt-out for the rare paranoid case. Per : defaulting the eval surface OFF does not add a protection separable from --allow-writes, because eval expresses every write the writes-gate blocks; the load-bearing protections are the localhost-bind default below and the operator's trust decision at MCP install time.

MCP servers default localhost-bind

Published MCP servers (re-frame2-pair-mcp, story-mcp, and downstream pair-shaped servers consuming this Spec) MUST bind to a loopback address (127.0.0.1 / [::1] / equivalent) by default. Remote access is an explicit launch-flag opt-in. The default rules out casual cross-network reach for the surface that exposes dispatch, restore-epoch, direct-read tools, and (when explicitly enabled) eval-cljs — none of which is shaped for unauthenticated network-exposed use.

The localhost-bind default composes with the eval-cljs launch-flag posture above: a stock install ships eval-ON + localhost-only (the eval-cljs default flipped to ON; the load-bearing remote-attack protection is the localhost-bind, not a redundant eval-gate that an attacker could bypass via the writes-gate-equivalent path). Operators who want a true read-only debug session compose --no-eval with --allow-writes absent. Per and Security.md §MCP tool authority and isolation.

Per-session app-db cache — cache-poisoning posture

The :rf.mcp/cache-hit wire marker (per the table in §MCP-side wire-marker vocabulary) is emitted by a pair-shaped server's per-session response cache when an identical app-db root produces a byte-identical re-emit. The cache is keyed on the actual app-db root identity (a root hash or equivalent) — not on a session token, not on a client-supplied tag, not on a wall-clock window. Cache invalidation rests on identity equality: if the root hash changes, the entry is invalidated; if it does not change, the cached response is returned.

The security property of this design is that cache poisoning by mismatched session is structurally impossible. A client cannot supply a hash that aliases a different session's app-db root because the hash is computed from the live app-db on the server side, not from client input; a stale session whose app-db has mutated does not hit the cache because the hash has changed. The cache is a token-budget optimisation, not a trust boundary — but the keying rules out the cache-poisoning class by construction.

This composes with the direct-read MUST above (§Direct-read privacy posture for sub-cache and get-path): a cache-hit re-emit is a re-emit of the already-elided response (because the cached response was the post-rf/elide-wire-value envelope), so a cache hit cannot regress the wire-egress posture. Per and Security.md §MCP tool authority and isolation.

What pair-shaped tools NOT to ship as part of re-frame2

  • The Claude integration itself (prompts, retrieval, model selection). Lives in the pair tool.
  • The nREPL middleware that exposes the runtime to the agent. Specific to the host environment.
  • The conversational interface ("Tell me about every :checkout/* event"). The pair tool's job to prompt-engineer; re-frame2 just ships data.
  • Skill-shaped retrospective analysis. That is a separate, post-v1 artefact — a Claude skill (not a runtime tool) that reviews pair sessions and proposes improvements to the pair tool itself. Reference: re-frame-pair-improver.

Future-compat commitments

Per the philosophy of Spec-ulation, the pair-tool runtime contract grows additively:

  • Trace event categories are stable; new categories are added with new :operation keywords.
  • Registry query API signatures are stable; new query functions are additive.
  • Epoch history fields can grow new keys (open map), never remove.
  • The :rf.epoch/* op-types are reserved for re-frame2's epoch machinery.

The pair tool can rely on all of these surviving across re-frame2 minor versions. Major versions will document any changes.

Cross-references


Design notes (non-normative)

The runtime contract above is fixed; the notes below capture open design questions that do not affect the contract but inform tool authors.

  • Snapshot serialisation cost. Persistent data structures share structure; configurable history depth lets users tune. Lazy serialisation is an optimisation.
  • Pair-improver feedback loop. The re-frame-pair-improver skill (post-v1) consumes a structured session log; format is EDN/JSON with a schema in Spec-Schemas.

Resolved decisions

Multi-frame operating-frame resolution — hybrid posture

Resolved: pair-shaped tools resolve a multi-frame application's operating frame via the four-tier rule pinned at §Operating frame — multi-frame resolution. The shipped impl in re-frame2-pair.runtime (per rf2-19xl) is the canonical reference — current-frame walks explicit override → session pin → sole-registered app frame → nil, and both read-shaped (subs-sample, snapshot, epoch-history, …) and mutating-shaped (pair-dispatch-sync!, app-db-reset!, restore-epoch) ops refuse with {:ok? false :reason :ambiguous-frame} when resolution yields nil. The resolver is reserved-frame-aware (rf2-3bu3d.4): tiers 3 and 4 count only app frames — (rf/frame-ids) with :rf/* reserved tool frames (Xray's :rf/xray, SSR slots) removed, the sole carve-out being :rf/default (the universal default app frame). So a single-app session that also carries an :rf/xray frame resolves at tier 3 without a frames/select. :rf/default is not a tier-4 fallback — silently routing to it would mask the ambiguity in a multi-app session. The hybrid posture (explicit-context-set, implicit-until-reset) supersedes the earlier "Lean" note in §Design notes; single-frame applications never reach the refusal path. The tool surface MUST expose set-operating-frame / reset-operating-frame / get-operating-frame ops (names illustrative; semantics normative) so multi-frame users can pin a target, clear the pin, and inspect the resolver's view. See §Operating frame — multi-frame resolution for the full contract.