Skip to content

Spec 009 — Instrumentation, Tracing, and Performance Integration

The trace event stream is a pattern-level primitive — every implementation supplies a structured trace stream from well-defined points in the runtime. Trace events are open maps with stable required keys, consistent with the open-maps-with-schemas principle. The CLJS-specific bit — goog-define for production elision via re-frame.interop/debug-enabled? — is a reference-implementation detail. Other-language implementations resolve elision and listener delivery differently.

For where the trace bus sits in relation to the runtime's other components (registrar, drain loop, sub-cache, substrate adapter), see Runtime-Architecture.

Abstract

re-frame2 emits a stream of trace events describing what's happening at runtime — dispatches, interceptor steps, effect handler calls, subscription updates, frame lifecycle, machine transitions. Tools subscribe to this stream.

The tracing surface is designed to be stable (required fields don't change), extensible (open maps; new fields are additive), cheap on the hot path (near-zero overhead with no listeners), and cross-platform (JVM-runnable for the data).

All tracing is compile-time eliminated in production builds. No exceptions. Production binaries contain zero trace code. Tracing is a dev-time concern only.

The trace event model

A trace event is an immutable map describing one moment of work in the runtime — an event dispatch, a sub recomputation, a render, an fx invocation, a machine transition. Events flow into a single per-application trace stream, and listeners receive them one at a time, never concurrently. Delivery is two-tier: a public emit fans out synchronously, while an internal, drain-owned emit is delivered at the post-drain boundary of the operation that produced it, before that operation returns (see §Subscription / consumption).

The shape is documented below.

Core fields (required on every event)

{:id        <int>            ;; auto-incrementing trace id; unique per process
 :operation <kw>              ;; what's being traced — namespaced keyword identifying
                              ;;   the emit site (e.g. :rf.event/dispatched, :rf.machine/transition,
                              ;;   :rf.error/no-such-sub). The event-id / sub-id / fx-id
                              ;;   that motivates the emit rides under :tags. Per
                              ;;   Spec-Schemas §:rf/trace-event.
 :op-type   <kw>             ;; discriminator: :rf.event, :rf.sub, :rf.fx, :rf.view,
                              ;;   :rf.frame, :rf.machine, :error, :warning, etc.
                              ;;   The full vocabulary is enumerated in §:op-type vocabulary
                              ;;   below and in Spec-Schemas §:rf/trace-event.
 :time      <ms>             ;; emit timestamp (host clock)
 :tags      {...}}           ;; open-ended bag for op-type-specific fields

The runtime emits each trace event at the moment of interest with the host clock time captured in :time. The shape is event-at-a-time, not span-shaped: there is no separate start/end pair, no :duration, and no :child-of parent-id. Tools that need run correlation use the dispatch-id correlation fields documented under §Dispatch correlation instead.

:op-type versus :operation. :op-type is the discriminator a consumer branches on — a small, stable vocabulary of ~20 values (enumerated in §:op-type vocabulary below and in Spec-Schemas §:rf/trace-event). Tools route on :op-type to subscribe to a slice (e.g. :rf.event, :rf.sub, :error). :operation is the specific identity of the emit site within that slice — typically a namespaced keyword like :rf.event/dispatched, :rf.machine/transition, or :rf.error/no-such-sub. A consumer subscribing to all errors filters :op-type :error; a consumer hunting one category branches further on :operation.

Re-frame2 additions (additive, optional)

{:source   :ui              ;; :ui, :after-timer, :http, :machine-action, :repl, … (full enum: Spec-Schemas §:rf/dispatch-envelope) — origin of the trigger
 :recovery :no-recovery}    ;; recovery disposition — always on the error path (default :no-recovery), plus any event whose producer supplies it (e.g. :rf.http/retry-attempt)

:source is hoisted to the top level of every event whose tags carry it; :recovery likewise hoists to the top level of every event whose producer supplies it (e.g. the :info :rf.http/retry-attempt marker), and is additionally always present on the error path (defaulting to :no-recovery). Both are top-level, not under :tags. The :frame field — present on most events — rides under :tags (every emit site that knows the frame includes it there).

Frame identity on the raw event: [:tags :frame], read via the canonical accessor

A raw trace event carries frame identity only under [:tags :frame]. There is no public top-level :frame on the raw trace-event shape — :source and :recovery hoist to the top level (above), but :frame does not. This is the single supported wire shape; the reference producer (re-frame.trace/build-event and every router / cofx / error emit site) stamps :frame under :tags, never at the top level.

Derived / projection records carry :frame at the top level instead. The records the trace surface projects from the raw stream — event bundles ((rf/trace-buffer frame-id), §Event-bundle projection), :rf/epoch-records, dispatch consequences, and cursor / summary records — expose frame identity at top-level :frame (the bare record/projection vocabulary, per Tool-Pair §Identity spellings). The two layers are deliberate: a tool reading a raw trace event reads [:tags :frame]; a tool reading a projected record reads top-level :frame.

The canonical reader. Consumers read a raw trace event's frame through the trace contract's one canonical accessor — re-frame.trace/trace-event-frame (alias frame-of), whose implementation is (get-in trace-event [:tags :frame]) — rather than hardcoding the [:tags :frame] path (or a dual (or (get-in ev [:tags :frame]) (:frame ev)) read) at each call site. It returns the frame-id, or nil for an event emitted outside any frame-qualified run (registry-time / boot-time). This accessor is the one supported way to read frame off the raw event shape (see also §Canonical per-frame routing key, Conventions §The single-root reserved set:frame is the deliberate bare carve-out — and Tool-Pair §Identity spellings). Pre-alpha posture: one shape, one reader, no compatibility ambiguity.

Dispatch correlation: :rf.trace/dispatch-id / :rf.trace/parent-dispatch-id

Pair-shaped tools and per-event diagnostics need to correlate the run a dispatch belongs to — "this trace event fired inside the run started by that dispatch." The runtime maintains two distinct correlation channels. They are cross-cutting — stamped across every domino family — so they live under the trace-channel namespace :rf.trace/*, not under any single domino's :rf.<family>/* (per Conventions §:rf.trace/*):

{:tags {:rf.trace/dispatch-id        <uuid-or-counter>   ;; the run this event belongs to
        :rf.trace/parent-dispatch-id <uuid-or-counter>}  ;; on :rf.event/dispatched only — the run
                                                         ;; that caused THIS dispatch
 ...}

Semantics:

  • :rf.trace/dispatch-id is per dequeued event — the run of one event, not the whole drain. It is allocated by the runtime when a dispatch is enqueued (before routing) — once per dispatch call, so a UI dispatch, each :fx [[:dispatch …]] child, and each frame-creation :initial-events setup step each receive their own — and rides on every trace event emitted inside that one event's pipeline run — :rf.event/dispatched itself, :rf.event/db-changed, :rf.fx/handled, :rf.sub/run, :rf.machine/transition, :rf.flow/*, every :rf.error/*, and any future op-type the runtime adds. It does not span sibling events that merely happen to drain in the same turn: when a handler :fx-dispatches a child, the child is a separate dequeued event with its own :rf.trace/dispatch-id (and its :rf.trace/parent-dispatch-id points back at the parent — see below). The :rf.trace/dispatch-id is therefore the trace-stream face of the epoch unit: one :rf.trace/dispatch-id = one dequeued event = one :rf/epoch-record (per 002 §Drain versus event). A machine's :raise sub-events and :always microsteps are not separate dequeues — they are in-memory microsteps inside the triggering event's macrostep (per 005 §Drain semantics), so every trace they emit carries the triggering event's :rf.trace/dispatch-id and rides its epoch; they do not allocate a new one. Consumers (Story group-by-event, Xray's causality graph, re-frame2-pair's cascade-of, schema-timeline correlation) group raw trace events by :rf.trace/dispatch-id directly — no inference from sequence required. The runtime carries the in-flight run's id through the :rf.trace/dispatch-id slot of the handler-scope record (re-frame.trace/*handler-scope*, per §Handler-scope), bound by router.cljc around each event's processing; emit! reads the slot and merges it into the event's :tags when bound and not already present. Implementations may use a process-monotonic counter, a UUID, or any opaque value with the same uniqueness contract: distinct within a single process for the lifetime of the trace surface. Tools treat it as opaque. Trace events emitted outside any in-flight run (handler registration before any dispatch, REPL evals that don't dispatch) carry no :rf.trace/dispatch-id. (The frame-creation initial event is itself a dequeued event and carries its own :rf.trace/dispatch-id; only emits genuinely outside any event — registry-time, REPL — go uncorrelated.)
  • :rf.trace/parent-dispatch-id is scoped to :rf.event/dispatched only. It documents run-from-run lineage — "this dispatch was emitted as a side-effect of another event's processing" — which is a per-event-dispatch fact, not a per-trace-event fact. Concretely: when an fx handler running inside the do-fx phase of dispatch D₁ invokes (rf/dispatch ...), the runtime records the new dispatch's :rf.trace/parent-dispatch-id as D₁'s :rf.trace/dispatch-id on the new dispatch's :rf.event/dispatched event. If the dispatch was initiated outside any in-flight event (a timer, a UI handler, the REPL, the SSR boot path), :rf.trace/parent-dispatch-id is absent from :rf.event/dispatched. Non-:rf.event/dispatched trace events never carry :rf.trace/parent-dispatch-id — they belong to a single run (their :rf.trace/dispatch-id) and the inter-run lineage hangs off the run's root.
  • Top-level dispatch. An :rf.event/dispatched event with no :rf.trace/parent-dispatch-id is a root of a run tree. Pair-shaped tools draw run trees by walking :rf.trace/parent-dispatch-id upward across :rf.event/dispatched events; the per-run body (every other trace event in that run) is the slice of the trace stream sharing the run's :rf.trace/dispatch-id.
  • The run-correlation primitive. Because the runtime emits event-at-a-time (no :child-of span field), :rf.trace/dispatch-id is the only intra-run correlation channel and :rf.trace/parent-dispatch-id is the only inter-run correlation channel. The pair lets tools both (a) group raw spans by run and (b) walk lineage between runs, without consulting the :rf/epoch-record projection. Tools that prefer structured per-run slices read the assembled :rf/epoch-record (per Tool-Pair §Time-travel) — the raw :rf.trace/dispatch-id channel is the lower-level primitive.
  • Production elision. Both fields ride the trace stream and are elided in production with the rest of the trace surface. The dispatch-id allocation counter and the *handler-scope* Var read sit inside the interop/debug-enabled? gate in emit!, so the whole machinery compiles out.

Tools consume these two channels to build run views: "show me every fx that ran in this run" is a filter on :rf.trace/dispatch-id over the raw stream; "show me all dispatches descended from [:user/login ...]" is a transitive walk over :rf.trace/parent-dispatch-id across :rf.event/dispatched events.

Origin tagging: :rf.event/origin

When a tool (the pair tool, a story runner, the REPL, the SSR boot path) needs its own dispatches distinguishable from application dispatches, it can tag them with an :origin opt at dispatch time (per 002 §Dispatch origin tagging). The runtime lifts the value onto every :rf.event/dispatched trace event under :tags :rf.event/origin (the dispatch opt keeps its ergonomic short name :origin; the trace tag it lifts onto is namespaced to the event family):

{:tags {:rf.event/origin :pair        ;; tag set by the dispatching tool; default :app
        :rf.trace/dispatch-id ...
        ...}
 ...}

:rf.event/origin is unconstrained at the framework level — tools and applications agree on values (:pair, :claude, :story, :test, etc.). The default is :app. User application code typically omits the opt; tool surfaces set it so post-mortem filters like "show me only the dispatches I (the pair tool) issued during this session" become a one-key filter on the trace stream.

:rf.event/origin is distinct from :source: :source describes the trigger kind — the closed-enum "what woke the runtime?" axis (the canonical value set is the :source row of :rf/dispatch-envelope in Spec-Schemas, the SSOT — this section does not re-enumerate it); :rf.event/origin describes the actor identity (which tool or app subsystem emitted the dispatch) and is used for filtering. Tools may set both. The default :source is :unknown; substrate-internal dispatch sites (machine :after timer, machine spawn fx, :dispatch / :dispatch-later fx — discriminating machine vs ordinary parent, routing-internal dispatches, HTTP reply settle, …) stamp the matching specific value.

Dispatch source as the functional-origin axis (:source)

The framework carries one closed-enum axis classifying every dispatch's trigger kind / functional origin: :source on the dispatch envelope. The canonical value set is the :source row of :rf/dispatch-envelope — the single source of the enum. There is no parallel origin axis (no :rf/dispatch-origin).

Substrate-internal stamp sites (canonical inventory):

:source value Stamped by When
:ui UI handler call-site a user button / input handler dispatches
:frame-init make-frame's :initial-events fire site a frame's lifecycle init dispatch
:machine-spawn re-frame.machines.lifecycle_fx/spawn actor bootstrap — the spawned machine's :start (or synthetic [:rf.machine.spawn/spawned]) trigger
:machine-action :dispatch / :dispatch-later fx handler when the parent envelope is :rf.machine/internal? machine-handler-issued dispatch — the actor-message path. Carries :source-detail {:ms <ms>} for the -later variant
:always re-frame.machines.transition :always microstep per-microstep marker on :rf.machine.microstep/transition; reserved closed-set value (intra-macrostep — no envelope)
:after-timer re-frame.machines.timer :after fire site a state-machine :after timer firing
:fx-dispatch :dispatch fx handler (non-machine parent) the :dispatch fx executes — child of an ordinary handler's do-fx
:fx-dispatch-later :dispatch-later fx handler (non-machine parent) the :dispatch-later fx fires after delay — child of an ordinary handler. Carries :source-detail {:ms <ms>}
:http re-frame.http_encoding/dispatch-reply-via-late-bind! managed-HTTP reply settle — :on-success / :on-failure cascade entry
:router re-frame.routing internal dispatches (:route/link click handler, the browser URL-change feed) URL events, route-link clicks
:ssr-hydration the user's hydration boot site (the framework does not auto-detect — see Spec 011) :rf/hydrate cascade or any other SSR-boot-time dispatch
:test test fixtures / harness dispatches (opt-in {:source :test}) test-harness opt-in
:tool tooling adapters (Xray controls, Story play scripts, the pair-MCP write surface) — self-tag at their dispatch site tool-issued dispatch
:websocket application-level websocket adapters (the framework does not ship one) a websocket-frame-arrived dispatch. The closed-enum slot is reserved; apps opt in
:repl REPL eval tests + REPL bodies that want the discriminator
:unknown default — un-stamped dispatch UI / app code that did not opt in. Unstamped paths don't silently misattribute
:other escape hatch reserved for cases the closed set doesn't cover

The default — for both the macro form ((rf/dispatch event) / (rf/dispatch-sync event)) and the plain-fn form (the CLJS value-alias, or re-frame.router/dispatch! directly) — is :unknown. UI handlers stamp :source :ui explicitly; internal callers thread :source into the opts map at their emit site to override the default. The canonical UI call-site path stamps it automatically: the reg-view macro injects {:source :ui …} into the lexically-bound dispatch noun (per §:rf.trace/call-site), so a view's on-click #(dispatch [...]) classifies as :ui without the app having to thread the opt by hand. :source :ui is not dev-only — dispatch! reads it unconditionally — so it survives production elision even where the dev call-site coord does not.

;; default :unknown
(rf/dispatch [:cart/add {:sku "abc"}])

;; explicit UI stamp
(rf/dispatch [:cart/add {:sku "abc"}] {:source :ui})

;; tool-issued dispatch (per-call opt-in)
(rf/dispatch [:order/submit] {:source :tool})

:source is distinct from :origin: - :source is the closed-enum trigger kind / functional origin — what woke the runtime — see Spec-Schemas §:rf/dispatch-envelope for the canonical 17-value enum. Tools branch on it to render the Epoch panel's DISPATCH chrome, the L2 row prefix, and per-source filter pills. - :origin (:app / :pair / :story / :test / …) is the actor identity — which tool or app subsystem emitted the dispatch — and is unconstrained at the framework level.

The closed enum is closed at the spec level. Adding a new value is a framework-level change with substrate-side consumer impact; it is not a per-app extension point.

:source is not inherited through :fx [[:dispatch ...]] child dispatches — each child dispatch's :source reflects its immediate trigger (:fx-dispatch / :fx-dispatch-later / :machine-action), not the originating user event's. Inheritance still applies to :fx-overrides, :interceptor-overrides, :trace-id, :origin, and :frame.

The dispatch envelope's :source slot rides onto every :rf.event/dispatched trace event under the :source tag. Production builds elide the trace surface entirely; the envelope's :source slot remains in the production build (it is plain envelope data, not gated trace tooling) but consumers that read it sit on the dev-only trace stream and DCE alongside the rest of the trace machinery.

The dispatch envelope's :rf.cofx map — the recordable-coeffect record: the framework-stamped :rf/time-ms plus any caller-supplied or (slice B) generated owner-qualified facts (per 002 §Recordable coeffects) — also rides onto every :rf.event/dispatched trace event, under :tags :rf.cofx. This is the trace-stream face of the causal token the lens-side COEFFECTS lens renders. The lens filters the framework-internal coeffects (:db, :event, :rf.frame/id, :rf.db/runtime) and shows the declared recordable leaves — the handler's declared inputs, the most user-relevant facts on the token (:rf/time-ms is always among them; every other leaf follows per-leaf projection). Unlike the envelope's :rf.cofx itself — which is durable causal data stamped unconditionally in build-envelope and present in production — the trace stamp is dev-gated: it is co-located with the dispatched trace via the canonical outermost (if interop/debug-enabled? <stamped> <plain>) shape, so production CLJS bundles DCE it along with the rest of the :rf.event/dispatched emit. It is a diagnostic-surface stamp, not an always-on one.

:op-type vocabulary

Every trace event carries an :op-type — the coarse discriminator a consumer filters on — and a finer :operation. The :op-type values are a small closed set; the :operation values are the open, per-concern vocabulary catalogued below. The core :op-type values are the :rf.<family> domino discriminators plus the three bare severity discriminators:

:op-type Kind Covers
:rf.event domino family event-dispatch + per-event commit signals (the :db-pending pair, the partition-commit signals, the no-op signal, drain-interrupt).
:rf.sub domino family subscription recompute / memo-skip / dispose.
:rf.fx domino family effect dispatch — :rf.fx/handled and the effects-pass marker do-fx (operation :rf.fx/do-fx; it folds into the fx family, it is not a standalone op-type).
:rf.cofx domino family coeffect supplier run (:rf.cofx/run) + the reserved slice-B generation op (:rf.cofx/generated). The cofx skip / error events ride the severity discriminators instead (:rf.cofx/skipped-on-platform:warning; the cofx error family :rf.error/unregistered-cofx / :rf.error/missing-required-cofx / :rf.error/cofx-value-invalid / :rf.error/cofx-name-collision / :rf.error/cofx-registration-invalid / :rf.error/cofx-request-invalid / :rf.error/inject-cofx-removed:error).
:rf.view domino family view render / post-render / unmount.
:rf.registry family registration changes (hot reload).
:rf.frame family frame lifecycle + drain-interrupt.
:rf.machine family state-machine activity (lifecycle, transition, timers, spawn, history, …) and the :rf.machine.* sub-families.
:rf.epoch / :rf.epoch.cb family epoch-history operations + listener-silencing notification.
:rf.cascade family the per-epoch cascade-DAG aggregator.
:rf.route / :rf.route.nav-token family route lifecycle + navigation-token lifecycle.
:flow family the whole flow trace stream (per-flow ops under :rf.flow/*).
:warning severity advisory failures; stays bare (not a domino family — see §Error contract).
:error severity error failures; stays bare. The category identity lives in :operation (e.g. :rf.error/handler-exception).
:info severity informational advisories with no warning/error severity; stays bare.

The per-:operation quick reference below indexes every operation keyword to its :op-type and a one-line meaning; the detailed bullets that follow it carry the full normative contract (payload :tags, suppression rules, redaction sites, and consumer notes) for each. Adding new values is non-breaking — tools ignore operations they don't understand.

Per-:operation quick reference

:operation (family) :op-type One-line meaning
:rf.event/db-pending / :rf.event/db-pending-post-flow :rf.event The (t1, t2) pending-:db snapshot pair — before / after flow transform.
:rf.event/db-changed / :rf.event/frame-state-changed :rf.event The two partition-commit signals — app-db-only vs either-partition.
:rf.event/db-noop :rf.event A :db effect was present but the app-db partition did not change.
:rf.frame/created / :rf.frame/re-registered / :rf.frame/destroyed :rf.frame Frame lifecycle.
:rf.frame/drain-interrupted :rf.frame The ordinary drain observed that its exact frame incarnation had been claimed for destruction and reported the events cut at claim plus any later rejected queued work.
:rf.machine.lifecycle/created / :rf.machine.lifecycle/spawned / :rf.machine.lifecycle/destroyed :rf.machine Machine instance lifecycle — the registrar-substrate triple.
:rf.machine/started :rf.machine The machine's birth signal (initial-entry cascade ran).
:rf.machine/event-received / :rf.machine/transition / :rf.machine/snapshot-updated / :rf.machine/done :rf.machine Machine activity — :transition is the macrostep rollup with the structured :cascade.
:rf.machine.event/unhandled-no-op :rf.machine Benign no-op for an unknown user event (xstate-v5 parity).
:rf.machine.microstep/transition :rf.machine Per-microstep transition for :always-driven cascades.
:rf.machine.history/restored / :rf.machine.history/recorded :rf.machine History pseudo-state restore / record.
:rf.machine.spawn/spawned / :rf.machine/destroyed :rf.machine fx-substrate spawn / destroy (the spawn / destroy fx ran).
:rf.machine/done :rf.machine Machine entered a :final? state, about to auto-destroy.
:rf.machine/system-id-bound / :rf.machine/system-id-released :rf.machine :system-id reverse-index lifecycle.
:rf.machine.timer/scheduled / :rf.machine.timer/fired / :rf.machine.timer/stale-after / :rf.machine.timer/cancelled / :rf.machine.timer/skipped-on-server :rf.machine State-machine :after timer lifecycle.
:rf.machine.spawn-all/started / :rf.machine.spawn-all/all-completed / :rf.machine.spawn-all/some-completed / :rf.machine.spawn-all/any-failed / :rf.machine.spawn-all/child-completed / :rf.machine.spawn-all/stale-completion / :rf.machine.spawn-all/late-completion :rf.machine :spawn-all spawn-and-join lifecycle. child-completed is a NON-DECISIVE child's fold terminal; stale-completion is the exact-attempt / already-closed-attempt suppression — its exact-attempt fence classifies attempt-unverified / attempt-superseded BEFORE the :resolved? check, so those two fire on BOTH sides of resolution, while duplicate-completion is checked AFTER :resolved?, so it fires only on the unresolved side (an exact-current already-closed child in a still-live join) — and late-completion is ONLY the exact-current :resolved?-latched post-resolution straggler (all classified by :rf.reply/stale-reason).
:rf.machine.spawn/cancelled-on-join-resolution :rf.machine A sibling cancelled when a :spawn-all join resolved.
~~:rf.machine.spawn/timed-out~~ RETIRED — use :rf.machine.timer/fired on the :spawn-bearing state's :after.
:rf.route.nav-token/allocated (op-type :rf.event) / :rf.route.nav-token/stale-suppressed (op-type :error — the suppression is the failure mode the consumer needs to see) Navigation-token lifecycle (stale-result suppression).
:rf.route/fragment-changed / :rf.route/navigation-blocked / :rf.route/entry-denied :rf.route / :rf.event Fragment-only URL change emission / leave block / terminal entry denial (both decision traces ride :rf.event).
:rf.route/planned (op-type :rf.event) One per navigation door commit branch — the R0 route-plan diagnostic projection, emitted just before the commit.
:rf.route/prefetched (op-type :rf.event) The single summary trace a warm-mode intent preload emits. NOT an activation trace — no :rf.route/planned / nav-token-allocated / :rf.route/activated accompanies it.
:rf.route/registered / :rf.route/cleared / :rf.route/activated / :rf.route/deactivated :rf.route Route lifecycle.
:rf.registry/handler-registered / :rf.registry/handler-cleared / :rf.registry/handler-replaced :rf.registry Registration changes (hot reload).
:rf.flow/* (:flow stream) :flow Flow lifecycle + evaluation (:rf.flow/registered / -computed / -skip / -cleared / -failed).
:rf.sub/create :rf.sub A sub was registered into the reactive graph (emitted at registration time, not first reference).
:rf.sub/run :rf.sub A sub recompute (input not = last-seen) — carries value-change + cascade attribution.
:rf.sub/skip :rf.sub A sub memo-hit (input = last-seen, body did not re-run).
:rf.sub/dispose :rf.sub A sub cache slot was evicted (:reason enum).
:rf.cofx/run :rf.cofx An ambient coeffect supplier delivered during context assembly.
:rf.cofx/generated :rf.cofx A generator-backed recordable fact was generated at processing-start.
:rf.view/render :rf.view Render START of a registered view.
:rf.view/rendered :rf.view Post-render (capped at 100/run) — cause + per-view ACTION/REASON data.
:rf.view/unmounted :rf.view A registered-view instance tore down.
:rf.cascade/captured :rf.cascade The focused-epoch cascade-DAG aggregator (end-of-epoch).
:error / :warning :error / :warning Universal severity discriminators — category identity lives in :operation.
:info :info Informational advisories (e.g. :rf.http/retry-attempt).
:rf.epoch/snapshotted / :rf.epoch/outcome / :rf.epoch/restored / :rf.epoch/db-replaced :rf.epoch Epoch-history operations (snapshot cause + summary, restore, db-replace).
:rf.epoch.cb/silenced-on-frame-destroy :rf.epoch.cb Listener-silencing notification when an observed frame is destroyed; carries :observed-gen (the generation the silence is attributed to); a consumer decides whether the signal is still current with the single epoch-silence-current? operation, which weighs both registration identity and observation continuum under one ledger snapshot.

Detailed contract for each operation (payload :tags, suppression rules, redaction sites, consumer notes):

  • :rf.event/db-pending / :rf.event/db-pending-post-flow — the (t1, t2) pending-:db snapshot pair. Both under op-type :rf.event. t1 (:rf.event/db-pending) fires inside the framework's outermost flows-after-interceptor BEFORE running flows, carrying the full pending :db the handler returned under :tags :rf.event/db. Fires whenever the handler returned a :db slot; suppressed otherwise (mirrors the :rf.event/db-present? gate on :rf.fx/do-fx). Fires regardless of whether the flows artefact is loaded. t2 (:rf.event/db-pending-post-flow) fires inside the same interceptor AFTER running flows, ONLY when the flow transform changed the value ((not (identical? new-db pending-db))); suppressed when t1 == t2 (no information). Both stamp the full pending :db value under :tags :rf.event/db — same payload-slot posture as :rf.event/fx on :rf.fx/do-fx per Mike's ruling: full reference, no diff, no DEBUG gate; PDS structural sharing makes the cost pointer-sized and the day8/de-dupe wire layer collapses repeated subtrees on egress. The :rf.event/db slot is redacted at the classification chokepoint (re-frame.classification/project-db-tags, which re-frame.trace/build-event runs for every t1 / t2 emit): because the slot carries the FULL pending app-db (not a per-registration payload), it routes through the schema-first wire walker re-frame.elision/elide-wire-value against the FRAME's app-db elision registry — the SAME normative site the epoch off-box projected-record uses for :db-before / :db-after — so schema-:sensitive? slots egress as :rf/redacted and :large? slots get the :rf.size/large-elided marker before the snapshot reaches any trace listener or epoch-capture sink. The walk is gated on the frame having declarations, so a frame with no marks keeps the reference-identity (copy-free) the slot promises. Consumers (Xray's Handler panel, re-frame2-pair's cascade-of) read t1 to render the handler's returned :db value and read (t1, t2) together to render the t1→t2 reshape — the framework does NOT precompute a diff. On a flow-throw abort (Spec 013 §Failure semantics) t1 still fires (it ran before the throw) but t2 does NOT (the pending value was discarded). Both rides interop/debug-enabled? so production CLJS bundles DCE them.
  • :rf.event/db-changed / :rf.event/frame-state-changed — the two partition-commit signals. Both under op-type :rf.event. :rf.event/db-changed stays APP-DB-ONLY — it fires only when the app-db partition changed (the inherited app-db-commit signal; consumers that watch app-db rely on it not firing for framework-only commits). :rf.event/frame-state-changed is the new frame-level signal: it fires when either partition changed, and carries :tags :rf.event/partitions — a set drawn from #{:app-db :runtime-db} naming which partition(s) this commit touched. So a runtime-only commit (a machine snapshot or route-slice write) emits :rf.event/frame-state-changed with :rf.event/partitions #{:runtime-db} and does not emit :rf.event/db-changed; an app-only commit emits both (:rf.event/db-changed plus :rf.event/frame-state-changed #{:app-db}); a commit touching both emits both with #{:app-db :runtime-db}. This keeps a runtime-only change visible to framework route/machine subs and to Xray / pair tooling even when app-db is unchanged, without forcing those tools to infer runtime changes from :rf.event/db-changed alone. Both ride interop/debug-enabled? so production CLJS bundles DCE them.
  • :rf.event/db-noopthe commit-level app-db no-op signal. Op-type :rf.event, APP-DB-ONLY. Fires when a :db effect was present but the app-db partition did NOT change — the handler returned an unchanged db (the common (if cond (assoc db …) db) else-arm), so the commit was a genuine no-op: the identical?-noop short-circuit in commit-frame-transition! skipped the container write entirely rather than re-installing an equal value (identical? is the cheap fast-path for the common no-change branch; = stays the deeper change-detection, so a distinct-object-but-=-value commit still writes and collapses to no change, which also emits :rf.event/db-noop). It is the complement of :rf.event/db-changed: for a :db-bearing commit exactly one of the two fires (changed → db-changed; unchanged → db-noop). Suppressed when no :db effect was returned at all (an :fx-only / runtime-only commit emits neither). Carries :tags {:rf.trace/event-id <id> :rf.event/v <event-vec> :frame <id>} — same routing slots as :rf.event/db-changed, no value payload (the no-op committed nothing). Xray's event / run view renders it as "event returned an unchanged db — nothing committed," so a developer can see an event ran but changed nothing rather than the no-op being silent. Rides interop/debug-enabled? so production CLJS bundles DCE it.
  • :rf.frame/created / :rf.frame/re-registered / :rf.frame/destroyed — frame lifecycle (all under op-type :rf.frame).
  • :rf.machine.lifecycle/created / :rf.machine.lifecycle/spawned / :rf.machine.lifecycle/destroyed — machine instance lifecycle, the registrar-substrate triple (see §Two-axis machine observation below). created fires when a machine handler is registered; spawned fires when a spawned actor's snapshot lands in the registrar (the registrar-substrate partner of the fx-substrate :rf.machine.spawn/spawned); destroyed fires when the frame-exit cascade reaps a handler / snapshot — its sole trigger, always :reason :parent-frame-destroyed; non-frame-exit teardowns signal on the fx-substrate :rf.machine/destroyed instead (see §the channel/reason matrix below). :rf.machine.lifecycle/spawned carries :tags {:frame <id> :machine-id <type-id> :spawned-id <gensym-instance-id> :invoke-id <declarative-invocation-path-or-nil> :system-id <id-or-nil> :parent-id <id-or-nil> :state <initial-state>} (emitted by machines/lifecycle_fx/spawn.cljc immediately after the actor's snapshot is installed). The three machine-identity facts are distinct: :machine-id is the registered TYPE (xor an inline :definition), :spawned-id is the live actor instance address, and :invoke-id is the declarative spawn invocation path (the absolute prefix-path of the :spawn-bearing parent state — was the overloaded :spawn-id).
  • :rf.machine/startedthe machine's BIRTH signal. Emitted at the single creation site — maybe-boot running the initial-entry cascade — fired on BOTH the eager [:machine-id [:rf.machine/start]] kick and the lazy first-real-event path. :tags {:machine-id <id> :frame <id> :state <initial logical state> :data <initial extended state> :cause <:rf.machine.start/cause>}. The :cause enum {:explicit :lazy :spawned} records HOW it came to life — :explicit = singleton, nil snapshot, trigger was the :rf.machine/start marker; :lazy = singleton, nil snapshot, trigger was a real first event (init folded into that event's epoch); :spawned = snapshot pre-seeded :rf/bootstrap-pending? by a spawn fx. Op-type :rf.machine (machine-activity family, not a severity discriminator — never an issue). Emitted ONLY when initial-entry actually runs: a throwing initial-:entry short-circuits to :rf.error/machine-action-exception (no :rf.machine/started), and restoration paths (SSR / restore-epoch! / replace-frame-state!) install a present, non-pending snapshot and emit NONE (the snapshot IS the state; per 005 §The :rf.machine/started trace). Consumer: Xray's epoch panel renders it as a [START] badge.
  • :rf.machine/event-received / :rf.machine/transition / :rf.machine/snapshot-updated / :rf.machine/done — machine activity. (-done fires when the machine enters a :final? state, immediately before the auto-destroy synchronously tears the actor down.) :rf.machine/transition is the macrostep-level rollup; its :tags carry {:actor-id <live-instance-id> :event <event-vec> :before <snapshot> :after <snapshot> :microsteps <count> :cascade <step-vec>} plus the auto-stamped :frame / :dispatch-id / :rf.trace/trigger-handler (per §Dispatch correlation). The addressed id rides under :actor-id — the LIVE actor instance (a singleton's registration id, or a spawned actor's <type>#<n> / fixed instance id) — NOT :machine-id (reserved for the registered TYPE), since :rf.machine/transition / :rf.machine/snapshot-updated / :rf.machine/done address a running actor. :rf.machine/snapshot-updated carries the same :actor-id. :cascade is the structured entry/exit cascade — the ordered step sequence that explains HOW the transition reached its after-state, so tooling renders the cascade rather than only {from}→{to} + {n} microstep(s). It is a vector of self-describing step maps in execution order, following the 005 §Entry/exit cascading along the LCA ordering — exit (deepest-first) → transition :action @ LCA → entry (shallowest-first + initial-descent), then one step per :always microstep. Each step is {:kind <:exit | :action | :entry | :microstep> :state <state-path-vec> :region <region-name-or-nil> :action <action-id-or-nil> :data-delta <changed-:data-keys-map>}; a :microstep step additionally carries {:microstep-index <n> :from <state> :to <state> :steps [<nested step maps>]}. Properties: it is a COMPLETE configuration walk (boundaries with no declared :exit/:entry action are still recorded with :action nil + empty :data-delta); :kind is STRUCTURAL and orthogonal to the per-action :rf.machine/action-ran :phase (driver) dimension; :data-delta is the minimal per-step :data contribution (changed keys only — never the whole :data map, so no large-payload leak); parallel machines carry per-:region steps concatenated in region declaration order; :always microsteps ride as :microstep steps so eventless cascades are explainable alongside the headline transition (composing with the per-microstep :rf.machine.microstep/transition stream below, which stays the per-microstep marker). This removes the need for app-level :data :trail workarounds. The snapshot-shaped :before / :after slots of :rf.machine/transition are redacted per the machine's [:schemas :data] per-slot marks (see 005 §Privacy); :data-delta carries changed keys only — never the whole :data map. A no-op macrostep emits NO :rf.machine/transition (per 005 §Transition resolution — a no-op is single-signalled): when the macrostep changed nothing — :before == :after, an empty :cascade, and zero :always microsteps — the headline emit is suppressed so the benign :rf.machine.event/unhandled-no-op (below) is the sole signal for an unhandled / guard-blocked event. An eager [:rf.machine/start] kick is a PURE init-kick: it runs the initial-entry cascade then STOPS — never re-fed into the transition step — so it emits no :rf.machine/transition at all (its birth is signalled by :rf.machine/started), and a redundant [:rf.machine/start] on an already-alive machine emits neither. A machine's creation therefore never produces a :before == after self-transition row. (On the lazy path the macrostep is the real first event's transition — installing the initial state via a non-empty initial-descent :cascade — which is NOT a no-op and emits its :rf.machine/transition normally.) An internal self-transition carries an :action step, so it too emits its transition normally. Consumer: Xray's epoch panel renders the cascade per Xray Machine Inspector.
  • :rf.machine.event/unhandled-no-opthe canonical benign no-op for an unknown USER event. An event arrived at a machine and no transition matched at any state-node along the active path (nor the root :on fallback, including its :* wildcard). Per 005 §Transition resolution, the snapshot is unchanged and the runtime emits this trace — op-type :rf.machine (machine-activity family, like :rf.machine/transition), NOT :error and NOT :warning. This is xstate-v5 parity: xstate v5 removed the v4 strict flag, so an unhandled event is ignored, not an error. Unlike xstate (which emits nothing), re-frame2 keeps this benign observability trace so a debugger can report that an event arrived and was ignored. Reserved-:rf/* exemption: this trace is NOT emitted for framework lifecycle traffic whose event-id lives in the reserved :rf/* root namespace — the synthetic creation marker [:rf.machine/start] (in its cascade-threaded :event-placeholder role — the eager kick is a pure init that stops before this site), the spawn kick-off [:rf.machine.spawn/spawned] (per 005 §Spawn lifecycle — ordering), the stories runtime's :rf.story.lifecycle/* / :rf.assert/* pings. Those are framework init, not unknown user events, so the runtime does not classify them as a no-op (creation actually RAN the initial-entry cascade and installed the state). This aligns with xstate's own xstate.init, which runs the initial-entry and is not reported as unhandled. It is a labelling distinction only — severity is benign either way (nothing throws); it is a conscious refinement that restores the semantic carve-out without reinstating any error advisory. For a parallel-region machine the trace fires exactly once, only when every region declines (per 005 §Transition broadcast). To "fail loudly on unknown" — the xstate-v5 idiom — declare a :* wildcard whose action throws; that is a real :rf.error/machine-action-exception (below), not an unhandled-event no-op. Because the op-type is :rf.machine (not a severity discriminator), consumers' issue-projection predicates do not classify it as an issue — so it never washes a cascade pink nor enters an issues ribbon, by construction. :tags {:actor-id <live-instance-id> :event <event-vec> :state <pre-event state>} — the actor that received the unknown event is a live INSTANCE (:actor-id), not the registered TYPE (:machine-id). (Older drafts emitted :rf.error/machine-unhandled-event / :rf.warning/machine-unhandled-event; both retired — see the catalogue note below.)
  • :rf.machine.microstep/transition — per-microstep transition emitted alongside the outer :rf.machine/transition for :always-driven cascades; one event per microstep with :tags {:actor-id <live-instance-id> :from <state> :to <state> :microstep-index <n>} (per 005 §Trace events and Spec-Schemas §:rf/trace-event). A microstep belongs to a running actor's macrostep, so it addresses the live INSTANCE under :actor-id (:machine-id is reserved for the registered TYPE) — consistent with the headline :rf.machine/transition it composes with.
  • :rf.machine.history/restored / :rf.machine.history/recorded — history-pseudo-state activity, the reserved :rf.machine.history/* family (per 005 §History states). Documented in full at §History trace events below; they compose with the :rf.machine/transition :cascade field rather than duplicating it.
  • :rf.machine.spawn/spawned / :rf.machine/destroyed — machine instance spawn/destroy events emitted by fx.cljc on the spawn / destroy fx-id paths. Distinct from :rf.machine.lifecycle/spawned / -destroyed (which are emitted on the underlying registrar lifecycle); the fx-substrate spawn observation is :rf.machine.spawn/spawned (the spawn fx ran), the registrar-substrate spawn observation is :rf.machine.lifecycle/spawned (the actor's snapshot landed in the registrar) — see §Two-axis machine observation. Tools that just want "did a machine appear?" can subscribe to either spawn axis (the spawn pair is symmetric — both fire per spawn); tools that want "did a machine disappear?" must subscribe to BOTH destroy channels, which are disjoint (see §the channel/reason matrix below). Tools building causal graphs subscribe to both axes throughout and disambiguate by the naming axis (:rf.machine.spawn/* / :rf.machine/* = fx-substrate; :rf.machine.lifecycle/* = registrar-substrate). Payload :tags carry :frame, :machine-id (the spec-time registered TYPE — xor an inline :definition), :spawned-id (the gensym'd live actor-instance address), :system-id (when set), :parent-id (the parent machine's registration-id, when the spawn came from declarative :spawn), and :invoke-id (the declarative spawn invocation path — the absolute prefix-path of the :spawn-bearing state node, when applicable) — together :parent-id + :invoke-id address the runtime spawn registry slot at [:rf.runtime/machines :spawned <parent-id> <invoke-id>], so tools can map the registry without re-deriving from app-db. The :rf.machine/destroyed event carries the reaped actor's live INSTANCE address under :actor-id (:machine-id reserved for the registered TYPE) and is enriched with a :reason tag — one of :rf.machine/finished (the actor entered a :final? state and auto-destroyed; see :rf.machine/done below), :rf.machine/join-reaped (runtime-authenticated post-terminal cleanup of a current :spawn-all child already in :done ∪ :failed, whether by resolution reap, direct imperative destroy, or parent-exit cascade; see 005 §Spawn-and-join via :spawn-all), :explicit (a parent state-exit cascade, a :spawn-all cancel-on-decision, an imperative [:rf.machine/destroy <id>], or any other runtime-initiated teardown). These three are the fx-substrate's complete :reason vocabulary; the frame-exit cause :parent-frame-destroyed never rides this channel — it is registrar-substrate only (:rf.machine.lifecycle/destroyed, below). Observers that filter on :tags see :reason additively. An :explicit destroy is a CANCELLATION of an in-progress actor work attempt (the actor was torn down before reaching a :final? leaf), so it carries the reply-envelope cancellation facts (cancellation as DATA — Managed-Effects §Cancellation): :rf.reply/work-id (the canonical machine work-id [:rf.work/machine <actor-id> <invoke-id> <generation>]), :rf.reply/work-kind :machine, :rf.reply/status :cancelled, :rf.reply/work-status :cancelled, :rf.reply/cancelled? true, and :rf.reply/cancel-reason :explicit. For an authenticated current in-progress :spawn-all child, the runtime first writes its logical id into the join attempt's durable :cancelled set; an already-queued/delayed exact completion is then suppressed as :rf.machine.spawn-all/duplicate-completion instead of publishing a second terminal. A :rf.machine/finished destroy is NOT a cancellation — the actor already closed its attempt through the :rf.machine/done reply — so it carries NONE of these cancelled facts. A :rf.machine/join-reaped destroy is likewise NOT a cancellation — the reaped child already closed its attempt as a :spawn-all join-child completion — so it too carries NONE of these cancelled facts.
  • :rf.machine/done — machine entered a :final? state; the runtime has invoked the parent's :spawn :on-done (if any) and is about to auto-destroy synchronously. One event per finish. :tags {:actor-id <finishing-actor-instance-id> :output <value-or-nil> :parent-id <parent-registration-id-or-nil>}. The finishing actor's id rides under :actor-id (its live INSTANCE address — a singleton's registration id, or a spawned actor's <type>#<n> / fixed instance id); :machine-id is reserved for the registered TYPE. :output is the child's :data slot named by the final state's :output-key (or nil when the final state has no :output-key). :parent-id is nil for singleton machines that reached :final? (per the singleton-symmetry rule D7 — see 005 §Final states). Pairs with the immediately-following :rf.machine/destroyed event whose :tags :reason is :rf.machine/finished.
  • :rf.machine/system-id-bound / :rf.machine/system-id-released:system-id reverse-index lifecycle (per 005 §Named addressing via :system-id). -bound fires on every :system-id-bound spawn (including the rebound case that also emits the :rf.error/system-id-collision warning); -released fires on the matching destroy. :tags {:frame <id> :system-id <name> :actor-id <live-instance-id>}. The bound/released actor's id rides under :actor-id (the live spawned-instance address); :machine-id is reserved for the registered TYPE.
  • :rf.machine.timer/scheduled / :rf.machine.timer/fired / :rf.machine.timer/stale-after / :rf.machine.timer/cancelled / :rf.machine.timer/skipped-on-server — state-machine :after timer lifecycle (per 005 §Trace events and /). :scheduled fires on initial entry-time scheduling and on every subscription-driven re-resolution; its :tags carry :delay-source <:literal | :sub | :fn> to discriminate the three delay forms (per 005 §Value shape and 005 §Dynamic delay re-resolution). :fired carries :fired? <bool> (false ⇒ guard suppressed the transition; sibling timers continue). :cancelled fires on every cancellation path; :reason discriminates the closed set :on-exit / :on-destroy / :on-resolution / :on-supersede / :on-frame-destroy / :on-restore (:on-restore = epoch restore unwound the bearing epoch and the in-flight host-clock handle was released eagerly; per 005 §Trace events). The :cancelled row additionally carries the reply-envelope cancellation facts (cancellation as DATA — Managed-Effects §Cancellation): :rf.reply/work-id (the canonical timer work-id [:rf.work/timer <declaring-path> <epoch>], matching the :fired / :stale-after rows so the cancel joins the same scheduling attempt), :rf.reply/work-kind :timer, :rf.reply/status :cancelled, :rf.reply/work-status :cancelled, :rf.reply/cancelled? true, and :rf.reply/cancel-reason (the closed :reason set). The :fired and :stale-after rows additionally carry the firing dispatch's causal completion timestamp under :rf.reply/completed-at when the synthetic :after-elapsed dispatch supplied a :rf/time-ms token. (Per Conventions §The naming rules the work identity and completion time ride ONLY under their namespaced :rf.reply/* spelling on these reply-envelope rows — the bare :work/id / :completed-at duplicates were dropped from the reply-envelope row; the bare :work/id remains the durable identity on the work-ledger row (not the reply map, which single-roots it as :rf.reply/work-id) and on non-reply resource-lifecycle rows.) Every :rf.machine.timer/* row — including :fired and :stale-after — carries the timer's owning actor under :actor-id (its live INSTANCE address), NOT :machine-id (reserved for the registered TYPE). A :delay-source :sub (dynamic-delay subscription) row carries the subscription identity under the canonical :rf.sub/id (the sub-id) plus :rf.sub/query-v (the full subscription vector) — the same spelling every other subscription trace uses — never the bare top-level :sub-id. The */stale-* form is the canonical naming for §stale-detection trace events — see also :rf.route.nav-token/stale-suppressed below. :skipped-on-server fires under SSR per 005 §SSR mode.
  • :rf.machine.spawn-all/started / :rf.machine.spawn-all/all-completed / :rf.machine.spawn-all/some-completed / :rf.machine.spawn-all/any-failed — state-machine :spawn-all spawn-and-join lifecycle (per 005 §Spawn-and-join via :spawn-all and). */started fires after all N children have been spawned on entry to the :spawn-all-bearing state. */all-completed fires when :join :all resolves; */some-completed fires when :join :any resolves on the success-side; */any-failed fires when :on-any-failed resolves. :tags {:actor-id <parent-instance-id> :invoke-id <prefix-path> :child-ids ... :done ... :failed ...} (the specific subset of tags depends on which event fires; common to all is :actor-id + :invoke-id). The parent's live INSTANCE address rides under :actor-id and the declarative invocation path under :invoke-id. The resolution traces additionally carry the DECISIVE child completion's reply-envelope facts (the child completion that drove the resolution — Managed-Effects §Status taxonomy): :rf.reply/work-id (the child's canonical machine work-id [:rf.work/machine <spawned-id> <invoke-id> <generation>]), :rf.reply/work-kind :machine, and :rf.reply/status (:ok for the */all-completed / */some-completed success-side resolutions, :error for */any-failed) / :rf.reply/work-status, plus the causal :rf.reply/completed-at when present — so the join-resolving child completion classifies the same way the single-:spawn path does. The companion :rf.machine.spawn-all/late-completion trace (a genuinely stale straggler completing after the join already latched :resolved?, before the survivor's teardown fully propagated — sibling cancellation on the join decision is unconditional, per 005 §Cancel-on-decision) carries the canonical :status :stale / :rf.reply/work-status :suppressed reply facts (:rf.reply/stale-reason :rf.machine.spawn-all/join-resolved). The straggler fires no further parent event, does not fold into the frozen :done / :failed record, and triggers no re-resolution / cancellation; the :resolved? latch stays true.
  • :rf.machine.spawn-all/child-completedthe canonical work terminal for a NON-DECISIVE :spawn-all child fold (rf2-ir4t5v). Fires once per accepted fold that did NOT resolve the join — in an :all join, every child but the decisive one. Without it a non-decisive child's work attempt ended with no terminal status at all (the join published terminals only through the final resolution trace), stranding work-ledger / Xray projections on an open attempt, then reaping it without cancellation. :tags {:actor-id <parent-instance-id> :invoke-id <prefix-path> :child-id <user-id> :spawned-id <gensym'd-id> :kind <:done | :failed> :done #{...} :failed #{...}} plus the child's reply-envelope facts (:rf.reply/work-id [:rf.work/machine <spawned-id> <invoke-id> <generation>], :rf.reply/work-kind :machine, :rf.reply/status :ok for a :done fold / :error for a :failed fold, :rf.reply/work-status, the emitted :rf.reply/correlation {:parent-id … :invoke-id … :child-id … :spawned-id …} — the fold's parent/invoke identity, logical child id, and spawned instance address — and the causal :rf.reply/completed-at when present). The decisive fold's terminal rides the resolution trace (:rf.machine.spawn-all/all-completed / */some-completed / */any-failed) instead — the two emits sit on opposite arms of the fold's :resolved? split, so each child attempt has exactly ONE terminal authority. Per 005 §:spawn-all join-child completion.
  • :rf.machine.spawn-all/stale-completionthe exact-attempt / closed-attempt suppression (rf2-nvxehu). Before ANY fold, a completion carrier's exact-attempt coordinate must EQUAL THIS join attempt: the :rf/join-attempt coordinate the member child's own handler boundary stamped must equal the current join's parent/invoke identity, logical child id, exact current actor id, AND the exact :rf/attempt token minted for this seed. This is a fail-closed fence against accidents, not authentication (single-trust-domain; see 005 §Exact-attempt fold fence). A carrier that fails the fence folds NOTHING — a zero-mutation fail-closed drop — and this trace fires once carrying the :status :stale / :rf.reply/work-status :suppressed reply facts with a precise :rf.reply/stale-reason: :rf.machine.spawn-all/attempt-unverified (no coordinate — a hand-crafted dispatch that never flowed through the member child's boundary), :rf.machine.spawn-all/attempt-superseded (a coordinate bound to a PRIOR attempt / wrong actor after parent re-entry or child respawn — including a :fixed-actor-id respawn where the actor id alone cannot discriminate attempts), or :rf.machine.spawn-all/duplicate-completion (an exact completion of an already-closed child, either already folded into :done / :failed or durably tombstoned in :cancelled by a membership-verified explicit teardown). :tags {:actor-id <parent-instance-id> :invoke-id <prefix-path> :child-id <user-id> :kind <:done | :failed>} plus the reply-envelope facts (:rf.reply/work-id, :rf.reply/work-kind :machine, :rf.reply/status :stale, :rf.reply/work-status :suppressed, :rf.reply/stale-reason, :rf.reply/correlation, and :rf.reply/completed-at when present). The exact-attempt fence runs BEFORE the resolved-vs-unresolved classification (rf2-ixjd48), so an attempt-unverified / attempt-superseded carrier is suppressed by this op on BOTH sides of resolution — it never reaches the post-resolution path — while duplicate-completion catches an exact-current re-completion of an already-closed child in a still-live (unresolved) join. ONLY an exact-current straggler arriving after the :resolved? latch flipped keeps the separate :rf.machine.spawn-all/late-completion trace (below, :rf.reply/stale-reason :rf.machine.spawn-all/join-resolved). Per 005 §:spawn-all join-child completion.
  • :rf.machine.spawn/cancelled-on-join-resolution — fires once per surviving sibling unconditionally cancelled when a :spawn-all join condition resolves and siblings still in flight are torn down (per 005 §Cancel-on-decision and). :tags {:actor-id <parent-instance-id> :invoke-id <prefix-path> :child-id <user-id> :spawned-id <gensym'd-id> :join-event <:on-all-complete | :on-some-complete | :on-any-failed>}. The parent's live INSTANCE address rides under :actor-id and the invocation path under :invoke-id; :spawned-id is the cancelled sibling's instance address. The trace fires per cancelled actor; observers needing one event per join resolution use :rf.machine.spawn-all/*-completed / */any-failed instead. The trace additionally carries the reply-envelope cancellation facts (cancellation as DATA — Managed-Effects §Cancellation): :rf.reply/work-id (the survivor's canonical machine work-id [:rf.work/machine <spawned-id> <invoke-id> <generation>]), :rf.reply/work-kind :machine, :rf.reply/status :cancelled, :rf.reply/work-status :cancelled, :rf.reply/cancelled? true, and :rf.reply/cancel-reason :on-join-resolution, so the survivor cancellation joins the same uniform work/reply row its spawn started.
  • ~~:rf.machine.spawn/timed-out~~ — RETIRED. The :timeout-ms slot on :spawn / :spawn-all is dropped in favour of state-level :after; the trace event with it. Observers wanting "this :spawn-bearing state's wall-clock guard fired" now consume :rf.machine.timer/fired on the :spawn-bearing state's :after entry — same semantic, uniform substrate. Per 005 §Wall-clock timeouts on :spawn — use parent state's :after and MIGRATION §M-44.
  • :rf.route.nav-token/allocated / :rf.route.nav-token/stale-suppressed — navigation-token lifecycle (per 012 §Navigation tokens). *-allocated fires when a navigation cascade begins; *-stale-suppressed fires when an async result arrives carrying a now-superseded token. Same epoch idiom as the machine-:after timer events. *-allocated carries :tags {:route-id <id> :nav-token <token> :frame <navigating-frame>} — the :frame is the in-flight cascade's frame, threaded so the trace enters that frame's epoch trace-events and obeys the frame trace-disable gate (epoch-capture admits only frame-tagged traces).
  • :rf.route/fragment-changed / :rf.route/navigation-blocked — fragment-only URL change emission (per 012 §Fragments; named :rf.route/fragment-changed to disambiguate this op trace from the runtime URL-change events :rf.route/transitioned / :rf.route/handle-url-change) and pending-nav protocol blockage (per 012 §Navigation blocking). :rf.route/fragment-changed fires only on the fragment-only branch (the route-id / params / query did not change); the trace's :tags carry :prev-fragment and :next-fragment and never coincide with a :rf.route.nav-token/allocated event for the same drain — see the routing/fragment-change conformance fixture. :rf.route/navigation-blocked (a :can-leave rejection) and :rf.route/entry-denied (a terminal :can-enter rejection) each carry :tags {:requested-url <url> :rejecting-route <id> :rejecting-guard <sub-id> :cause <door> :phase <:can-leave|:can-enter> :frame <navigating-frame>} — the :frame lets a multi-frame app filter a decision to the frame that caused it (and admits the trace into epoch capture / past the frame trace-disable gate), and the :phase tag is the route-phase taxonomy Xray's routing panel reads. :rf.route/entry-denied fires at most once per navigation attempt.
  • :rf.route/planned — the R0 route-plan diagnostic projection, one per navigation door commit branch (per 012 §Resolved target and the plan diagnostic projection). Emitted just before the commit, so a drain reads :rf.route/planned:rf.route.nav-token/allocated:rf.route/deactivated:rf.route/activated; the non-commit branches (an exact no-op, a fragment-only anchor change) commit no plan and emit none. Carries :tags {:cause <:link|:navigate|:popstate|:initial|:ssr> :route-id <id> :url <redacted-url> :param-keys [<keys>] :query-keys [<keys>] :branch [parent-most … leaf] :leaf-plan-ids [<event-ids>] :frame <navigating-frame>}, plus one CONDITIONAL tag: :branch-error {:kind <:unknown-parent|:parent-cycle> :route-id* <offending id>} rides only when the :parent chain failed to resolve, and is absent on every healthy plan — it is what tells a tool that an empty :branch is a failed chain rather than a root route. Both of its slots are registration-time identifiers, so neither is a carrier; a :parent-cycle's :chain is deliberately left OFF the bus, because the walk builds it out of route-metadata maps and metadata on a trace tag is bulk no consumer branches on. The :cause is what distinguishes the four sub-doors the URL-driven commit branch stands for from the programmatic one. The tag map is deliberately not a carrier: a trace tag is an egress surface the route's :sensitive classification cannot reach (that classification is lowered against runtime-db slice paths), so the :url rides the same URL-carrier redaction as the route-miss diagnostics (path kept, query-string and #fragment values redacted), the resolved :params / :query contribute their key sets rather than their values, and the plan's source address is not carried at all. 012 owns the full carrier rationale and the rejected alternatives.
  • :rf.route/prefetched — the single summary trace a warm-mode intent preload emits, one per [:rf.route/prefetch {address}] that clears both pre-planning gates (per 012 §Route-plan prefetch and 016 §Route-plan prefetch — warm-mode). Op-type :rf.event, emitted by re-frame.routing.prefetch (routing/prefetch.cljc). Carries :tags {:route-id <destination id> :warmed <count> :plan-error <true, when present> :frame <prefetching frame>}. :route-id is the destination as lowered through the ONE ResolvedTarget seam every navigation door lowers to, so the warmed identity is the one an activation would commit (the route's :query-defaults included). :warmed is the count of unique effective-plan requirements handed to Resources in warm mode — 0 when the Resources artefact is absent or the branch plan is empty, which is not an error (prefetch does not make Resources a mandatory routing dependency). :plan-error true rides only when the warm plan could not be built, and is a companion to, not a substitute for, the :rf.error/resource-route-plan diagnostic that fired with :plan-cause :prefetch. :frame always rides: the handler asserts its cascade frame stamp first, and a missing stamp throws the always-on :rf.error/no-frame-context before any planning, so the summary always lands in the emitting frame's epoch. This is NOT an activation trace — warmup is not activation, so no :rf.route/planned, :rf.route.nav-token/allocated, :rf.route/deactivated, or :rf.route/activated accompanies it, and no :can-leave / :can-enter / :on-match runs. It is also the op that does NOT fire on a rejected request: both pre-planning gates (the structural closed-:rf/route-address check and the named-destination resolution check) emit :rf.error/prefetch-bad-address and no summary trace at all, so a consumer counting preloads counts only preloads that planned. The tag map carries no URL and no :params / :query — not even their key sets (contrast :rf.route/planned above, which carries a redacted :url plus both key sets): a preload's diagnostic value is which destination was warmed and how much, so the carrier question does not arise here. The warm plan's own rows ride the ordinary channels beneath it — one :rf.resource/route-plan with :plan-cause :prefetch, and a per-requirement :rf.resource/* ensure family with :cause [:route-prefetch <route-id>].
  • :rf.route/registered / :rf.route/cleared / :rf.route/activated / :rf.route/deactivated — route lifecycle (per 012 §Trace events and). :rf.route/registered fires on first-time reg-route; re-registration rides :rf.registry/handler-replaced. :rf.route/cleared fires on explicit clear-route. :rf.route/activated / :rf.route/deactivated fire on every cross-route navigation commit in that order; same-id navigation emits neither. Both carry :tags {:route-id <id> :frame <navigating-frame>} — the :frame is the in-flight cascade's frame, so the lifecycle pair enters that frame's epoch trace-events and obeys the frame trace-disable gate (epoch-capture admits only frame-tagged traces). Mirrors the flow-lifecycle symmetry (:rf.flow/registered / :rf.flow/cleared / :rf.flow/computed).
  • :rf.registry/handler-registered / :rf.registry/handler-cleared / :rf.registry/handler-replaced — registration changes (hot reload). The canonical trio: -registered for a fresh id, -cleared for an explicit removal, -replaced when re-registration overwrote an existing id (the typical hot-reload case).
  • :flow — flow lifecycle and evaluation events (per 013 §Flow tracing). The op-type for the whole flow trace stream; per-flow events live under :rf.flow/* operations (:rf.flow/registered, :rf.flow/computed, :rf.flow/skip, :rf.flow/cleared, :rf.flow/failed — see §Flow trace events below). Tools filter op-type :flow to subscribe to the whole flow stream.
  • :rf.sub/create — emitted by re-frame.subs at registration time — fired by reg-sub / reg-runtime-sub / reg-frame-state-sub immediately after the registrar write, so tools see when the sub becomes available in the registry. :op-type :rf.sub, :operation :rf.sub/create. One event per registration (a hot-reload re-registration re-emits). :tags {:rf.sub/id <query-id> :rf.sub/input-kind <kind> :rf.sub/input-signals <vec>}. This is the registration-into-the-reactive-graph op — NOT a first-reference / first-deref signal: the runtime emits nothing on first materialisation of a sub's cache slot (that lives in the :rf.sub/run :rf.sub/first-run? tag instead). Consumers: the Xray Epoch panel's SUBSCRIPTIONS section consumes it alongside :rf.sub/run / :rf.sub/skip / :rf.sub/dispose for the full sub-lifecycle view.
  • :rf.sub/run — emitted by the sub-memo wrapper (re-frame.subs.memo) on a true recompute — the input value was NOT = to last-seen, so the user body re-ran (per Spec 006 §Invalidation algorithm and). :op-type :rf.sub, :operation :rf.sub/run. One event per recompute. The base :tags are {:frame <id> :rf.sub/id <query-id> :rf.sub/query-v <vec>}:rf.sub/query-v is RAW BY DESIGN and is never redacted at the classification chokepoint. A query vector is identity (the rf= sub-cache key, the skip-dedup key, the reactive-graph edge endpoint), and a cache key is structurally public to every layer that touches the cache, so single-projection redaction could not contain it; a secret placed in a query argument is the documented positional fail-open one surface over, and it egresses raw here and on every other query-vector-bearing slot (:rf.sub/skip / :rf.sub/dispose, downstream :rf.sub/inputs / :rf.sub/cause-sub, the always-on error :query-v / :event slots, epoch capture rows, :delay-source :sub timer rows). Pass IDENTIFIERS, not secrets — see Spec 015 §A subscription's query vector is identity. (The one narrower backstop is the SCHEMA axis: a :sensitive?-schema'd sub's validation-failure trace whole-slot scrubs :rf.sub/query-vSpec 010.) The pure compute-sub form (the snapshot-against-a-supplied-db form per Spec 008 §Testing) emits the same base shape from re-frame.subs but omits the attribution slots below — it bypasses the per-frame reactive cache so it has neither a prior cached value to diff nor a reactive context to attribute a cascade against.

    Value-change + cascade attribution. On the reactive recompute path the :rf.sub/run :tags carry, additively:

    tag shape meaning privacy
    :rf.sub/value-changed? bool (not= prev-value computed)true on the first recompute (no prior value to compare). Not wire-sensitive. plain
    :rf.sub/first-run? bool true on the run that created this sub's cache slot (the memo wrapper's prev-value was the ::unset sentinel — no prior cached value existed); false on every subsequent recompute against an existing slot. Disambiguates a value-change row from a fresh-cache-entry row — both shapes report :rf.sub/value-changed? true and :rf.sub/prev-value nil, but the former tells a "from → to" story (consumers render an inline ← was X annotation) while the latter tells a "this sub is now alive" story (consumers render :added chrome with no "was"). Without this flag a consumer cannot distinguish "the prior value really was nil" from "there was no prior cache entry at all," and silently drops the change signal for the first-cache-entry case (the Xray Epoch panel's SUBSCRIPTIONS leaf-scalar renderer). The flag is sourced from the cache lookup the memo wrapper already performs (per Spec 006 §Invalidation algorithm). Not wire-sensitive (a boolean). plain
    :rf.sub/prev-value any the prior computed value; nil on the first recompute. redacted at the classification chokepoint; whole-output :large? elided at off-box egress
    :rf.sub/value any the freshly-computed value. redacted at the classification chokepoint; whole-output :large? elided at off-box egress
    :rf.sub/cascade? bool true when an upstream sub drove the recompute (a layer-2+ sub); false for a layer-1 sub (driven by an app-db path change, not a sub). plain
    :rf.sub/cause-sub [query-id args] | nil for a cascade, the upstream :<- query-vector whose value changed; nil for a layer-1 sub OR a layer-2+ first recompute (no prior input to diff). plain
    :rf.sub/inputs [query-v ...] the realized input query-vectors for THIS concrete cache entry — the literal :<- list for a :static sub, the (input-fn query-v) result for a :parametric sub, [] for a layer-1 reader (per Spec 006 §Subscription input producers). The runtime counterpart to the static sub-topology's :inputs :parametric sentinel: it surfaces the concrete parametric edges the static surface cannot enumerate, so the Xray live/run view renders realized parametric edges without fabricating un-materialized ones. Query-vectors (sub-id + args), not computed values — rides raw alongside :rf.sub/query-v / :rf.sub/cause-sub for the identity reason stated on :rf.sub/query-v above; only computed-value slots are redacted at the classification chokepoint. plain
    :rf.sub/cause-event-id event-id keyword (when in-run) the dispatching run's event-id — the head of the event vector that kicked off the in-flight drain ((first event)). Names which event invalidated this sub's reactive input, so the Xray Epoch panel's SUBSCRIPTIONS section can credit each sub-run to the right epoch even when the physical reactive flush deferred into a chained sibling event's drain (the navigate → handle-url-change pattern). Absent outside a run (a post-settle reactive flush against no live drain) or when the optional re-frame.epoch artefact is not on the classpath. Sourced from the in-flight run buffer via the :epoch/run-cause late-bind hook — same source :rf.view/cause-event-id uses (per :rf.view/rendered above). Mirrors the views-side attribution slot; the two together let consumers reconstruct cause→effect graphs across the run. Not wire-sensitive (an event-id keyword). Per Mike-ruled option b — attribution-only fix; the physical reactive flush stays batched at end-of-tick. plain
    :rf.sub/elapsed-ms number wall-clock duration of the sub body recompute for THIS run, in fractional milliseconds (the memo wrapper brackets the body with interop/now-ms inside the debug-enabled? gate). The per-op DURATION the Trace panel's column reads — mirrors :rf.view/elapsed-ms for views. Present on the reactive recompute path in dev builds; absent on the pure compute-sub form (no timing bracket there) and DCE'd in production. plain

    :rf.sub/prev-value and :rf.sub/value are wire-value-sensitive app data and are redacted by the existing per-:rf.sub/run classification chokepoint (re-frame.classification/project-sub-tags, which re-frame.trace/build-event already runs for every :rf.sub/run emit per Spec 015 §Registration-owned transient classification) — a sub whose output carries a registration-declared per-path :sensitive mark egresses BOTH slots as :rf/redacted, and a per-path :large mark substitutes the :rf.size/large-elided marker into the value at those paths. (A sub does not inherit its inputs' classification; classify the sub's own output path.) The WHOLE-OUTPUT {:large? true} registration marker is honoured one seam later, at off-box egress (rf2-irwsq): the chokepoint stamps a bare :large? on the tags and DELIBERATELY leaves the value in place, because the on-box ring must keep the exact value (Xray's diff and restore-epoch! read it). The off-box epoch projector (re-frame.epoch/projected-record) then substitutes the :rf.size/large-elided marker into :rf.sub/value / :rf.sub/prev-value and strips the spent flag — through the same rule that elides the structured :sub-runs row's :value / :prev-value, so the two egress copies of one sub value cannot drift. A marker, never a silent drop: a tool must be able to tell that a value existed and was withheld. :include-large? true is the trusted-local opt-in that keeps the raw value in both. This axis is TOKEN BUDGET, not privacy — EP-0025 dropped the whole-output :sensitive? overload, so sub-output sensitivity is per-path and substituted at emit. :rf.sub/value-changed? stays a plain boolean and is always observable. Why not elide-wire-value at the emit site? The path walker reads the frame's [:rf.runtime/elision …] registry by dereferencing the app-db container; calling it inside a sub's reaction compute fn registers a spurious reactive dependency on app-db, breaking the glitch-free db → layer-1 → layer-2 layering (the sub would recompute on any app-db change). The classification chokepoint resolves sensitivity from the sub's process-scoped registration :sensitive / :large marks — never a reactive read, never input→output propagation — so it is reaction-safe. The whole attribution branch (the enriched tag map) sits inside the shared interop/debug-enabled? gate so Closure DCE folds it out under :advanced + goog.DEBUG=false; the unattributed base tag is emitted on the production path so the op-type vocabulary is unchanged there. Consumers: Xray's Reactive panel reads :rf.sub/value-changed? / :rf.sub/prev-value / :rf.sub/value to populate "SUBS WHOSE VALUE CHANGED" and :rf.sub/cascade? / :rf.sub/cause-sub for "SUBS THAT CASCADED". - :rf.sub/skip — emitted by the sub-memo wrapper (re-frame.subs.memo) on the memo-hit branch — the input value was = to last-seen, so the user body did NOT re-run (per Spec 006 §Invalidation algorithm and). :op-type :rf.sub, :operation :rf.sub/skip. One event per memo hit per recompute attempt. :tags {:frame <id> :rf.sub/id <query-id> :rf.sub/query-v <vec> :rf.sub/reason :input-value-equal :rf.sub/input-paths-unchanged <vec-of-upstream-input-signals-or-empty>}. The cascade-DAG consumer reads this to render the "considered, no recompute" branch of the reactive DAG dimmed alongside the recomputed :rf.sub/run entries. Distinct from :rf.sub/run (recomputed) and from the case where the sub was not considered at all (no upstream change propagated to its input). - :rf.sub/dispose — emitted by the sub-cache (re-frame.subs.cache) at every eviction site — the cache slot was actually removed and the underlying reaction torn down (per Spec 006 §Reference counting and disposal and). :op-type :rf.sub, :operation :rf.sub/dispose. One event per evicted cache slot. :tags {:frame <id> :rf.sub/id <query-id> :rf.sub/query-v <vec> :rf.sub/reason <enum>}. The :rf.sub/reason slot is a closed enum discriminating the eviction path:

    :rf.sub/reason Eviction path Site
    :no-more-derefers The slot's ref-count dropped to 0 and the cache disposed synchronously — last subscriber detached. The dominant production case: a Reagent view unmounted, or a (when X @some-sub) flipped to false, dropping the last derefer. re-frame.subs.cache/dispose-entry-now!
    :hot-reload A :sub re-registration evicted every cached entry for that sub-id across every frame (per Spec 001 §Hot-reload semantics). Fires regardless of ref-count — the cached reaction holds the OLD body via closure and MUST be replaced. re-frame.subs.cache/invalidate-sub-on-replace!
    :cache-clear An explicit clear-sub-cache! call (test fixture, REPL teardown) walked the cache and disposed every slot. Per-slot emit; fires regardless of ref-count. re-frame.subs.cache/clear-sub-cache!
    :frame-destroy re-frame.frame/destroy-frame! tore down the destroyed frame's whole sub-cache as one of its ordered teardown steps (per Spec 002 §Destroy and Spec 006 §Disposal on frame destroy). Per-slot emit; fires regardless of ref-count; carries the destroyed :frame. Discriminates frame teardown from explicit test/REPL :cache-clear. re-frame.subs.cache/dispose-all-for-frame-destroy! (invoked from destroy-frame! via the :subs.cache/dispose-all-for-frame-destroy! late-bind hook)

    The emit is single-fire per actual eviction (gated on the CAS-winner check that already serialises interop/dispose!), so a concurrent sync-dispose + invalidate race emits exactly one :rf.sub/dispose for the winning evictor — not two. Disposal is same-tick as the run that drove the unmount/condition-flip (per Spec 006 §Reference counting and disposal — synchronous on derefer-count → 0): the :rf.trace/dispatch-id rides via *handler-scope* for any eviction that fires inside a run's drain. Eviction fires that land OUTSIDE a run (an explicit clear-sub-cache! from a test) carry no :rf.trace/dispatch-id — the per-frame ring still anchors the event by :frame and consumers fall back to wall-clock ordering. The whole emit sits inside interop/debug-enabled? so production CLJS bundles DCE it.

    Consumers: the Xray Epoch panel's SUBSCRIPTIONS section consumes :rf.sub/dispose to surface the "disposed subs" branch alongside the per-epoch :rf.sub/create / :rf.sub/run / :rf.sub/skip events — the full sub-lifecycle answer to "what happened to my subs this run?". Pair / Story consumers use :rf.sub/reason to distinguish "last view unmounted" from "hot-reload" from "test teardown" without inferring from sequence. - :rf.cofx/run — emitted by re-frame.cofx on the success branch of an ambient supplier that ran during context assembly — the supplier returned a value (and post-validation, if any, passed). :op-type :rf.cofx, :operation :rf.cofx/run. One event per ambient supplier that delivered during context assembly (the retired inject-cofx-interceptor framing is gone; recordable facts are delivered verbatim from the token and emit no run op). The emit rides inside the supplier's with-handler-scope binding, so its source-coord / :rf.trace/trigger-handler / :rf.trace/call-site ride the trace per §Handler-scope. :tags {:frame <id> :rf.cofx/id <cofx-id> :rf.cofx/value <produced-value> :rf.cofx/arg <requirement-arg> :rf.cofx/elapsed-ms <number>}. :rf.cofx/value is the supplier's PRODUCED value — the coeffect that actually egresses into :coeffects — and is redacted at the classification chokepoint (re-frame.classification/project-cofx-run-tags) against the cofx's declared :sensitive / :large marks (mirroring :rf.fx/handled's :rf.fx/args redaction): the redaction is wired to the value that egresses, so a declared-sensitive produced value never surfaces in trace. :rf.cofx/arg is the requirement-arg — present only for a parameterized [id arg] requirement (the supplier's (supplier arg) input, e.g. a localStorage key); it is omitted on the bare no-arg path. (The produced value is stamped under :rf.cofx/value and the arg under :rf.cofx/arg, so the marks redaction — which targets :rf.cofx/value — acts on the produced value.) :rf.cofx/elapsed-ms is the dev-only wall-clock of the supplier invoke. The whole emit (tag-map + emit!) sits inside the interop/debug-enabled? gate so production DCEs it. Distinct from :rf.cofx/skipped-on-platform (the platform-gate skip — the supplier did NOT run). - :rf.cofx/generated — emitted by the recordable-generation step when a declared-absent generator-backed recordable fact's generator runs at processing-start to fill it (before the fold consumes it). :op-type :rf.cofx, :operation :rf.cofx/generated. :tags {:frame <id> :rf.cofx/id <fact-name> :rf.cofx/value <produced-value> :rf.cofx/arg <requirement-arg>}:rf.cofx/id carries the generated fact's name + supplier id (the cofx id is both), :rf.cofx/value the PRODUCED value (redacted at the classification chokepoint against the cofx's declared marks, exactly like :rf.cofx/run), :rf.cofx/arg the requirement-arg of a parameterized [id arg] requirement (omitted on the bare path) — so traces are self-describing even though the record is flat. Dev-gated like :rf.cofx/run (the whole tag-map + emit sits inside interop/debug-enabled?; the produced value rides the durable :rf.cofx record, not this dev trace). Distinct from :rf.cofx/run (an ambient supplier read) — generation produces a RECORDED fact written back into the causal token. Not emitted for a supplied / replayed fact (the generator finds the value already present and does nothing). - :rf.view/render — emitted by the views.cljs frame-aware-view wrapper at the START of every registered-view render (per Spec 004D §Render-tree primitives). :op-type :rf.view, :operation :rf.view/render. :tags {:frame <id> :rf.view/render-key [<view-id> <instance-token>]}. One event per render. The substrate-agnostic wrapper composes around every adapter's user render-fn, so this rides Reagent / UIx renders uniformly. Tools consuming render-count metrics subscribe to this op. - :rf.view/rendered — emitted by the same wrapper AFTER the user render-fn returns (so the per-render deref sink is fully populated), carrying run-attribution + per-view ACTION/REASON data for Xray's Reactive / Views panels. :op-type :rf.view, :operation :rf.view/rendered. One event per render (capped — see below). :tags:

    tag shape meaning since
    :rf.view/render-key [view-id instance-token] the rendering instance (parity with :rf.view/render).
    :rf.view/id keyword the registered view id.
    :frame keyword the frame the render landed in.
    :rf.view/mount? bool true on the instance's FIRST render, false on every subsequent re-render — the mount-vs-rerender discriminator (keyed off :rf.view/render-key, which is stable across an instance's re-renders and fresh per new instance). Always present.
    :rf.view/deref-subs [[query-id args] …] the subscription query-vectors THIS view deref'd during the render — its OWN read-set (first-seen order, captured for EVERY deref incl. memo-hits). Absent when the view derefs no subs (a pure structural render). This is the precise PER-VIEW reactive reason; distinct from :rf.view/cause-subs (run-wide, over-reports — lists every sub that ran in the run regardless of whether this view reads it).
    :rf.view/render-args [arg …] the vector of POSITIONAL render args/props passed to THIS render (captured by the substrate-agnostic views.cljs frame-aware-view wrapper, so it rides Reagent / UIx uniformly). Absent on a no-arg render (additive — pre-existing :rf.view/rendered consumers are unaffected). The prerequisite for the Xray VIEWS render-args diff column: it makes the props re-render cause OBSERVABLE rather than merely inferred. PRIVACY — render args are arbitrary user data, so this slot routes through the SAME emit-time elision chokepoint every other user-data trace payload uses (see the privacy note below); raw render args never reach a listener, epoch capture, or the wire. rpgq8
    :rf.view/triggered-by query-id the SINGLE sub-id that caused THIS view to re-render — the first sub in :rf.view/deref-subs (the view's own read-set) whose value changed in the run (intersection resolved at emit time against the in-flight run buffer's value-changed :rf.sub/run set). The pre-computed per-view re-render cause Xray's Views panel shows directly (no consumer-side intersection needed). Absent on a structural re-render (none of the view's own subs changed value — ← parent re-render) and outside a run. Narrower than :rf.view/deref-subs (full read-set, changed-or-not) and :rf.view/cause-subs (run-wide). .1
    :rf.view/elapsed-ms number wall-clock duration of the user render-fn for THIS render, in fractional milliseconds (measured around the performance-mark bracket). The per-view render timing Xray's Views panel shows. Always present in dev builds (the timing reads ride interop/debug-enabled? so production DCEs them with the rest of the emit). .1
    :rf.view/cause-event-id event-id (when in-run) the dispatching run's :rf.event/run-start event-id. Absent outside a run.
    :rf.view/cause-subs [query-id …] (when in-run) distinct sub-ids that ran in the run, first-seen order, capped at 100. Absent outside a run.

    The per-view "reason" classifier (the consumer's by-elimination rule). A re-render is reactive when at least one sub in :rf.view/deref-subs changed value this run (intersect :rf.view/deref-subs with the run's value-changed :rf.sub/run set) → show those subs. The runtime pre-computes the FIRST such sub as :rf.view/triggered-by, so a consumer can name the cause directly off the op without re-deriving the intersection. Otherwise the render is structural — the view re-rendered because its parent did (new props), with none of its own subs changed → :rf.view/triggered-by is absent → label it ← parent re-render, UNNAMED (no component-tree / props-diff capture exists; the structural parent is never named). :rf.view/deref-subs is what makes the reason PER-VIEW rather than run-wide.

    :rf.view/render-args privacy (rpgq8). Render args are arbitrary user data, so the slot is elided through the IDENTICAL path as the app-db snapshot (:rf.event/db) and every other user-data trace payload — not emitted raw. The capture itself rides interop/debug-enabled? (the wrapper passes nil in production, so production DCEs the capture with the rest of the emit). In dev, the emit-time classification-projection chokepoint (Spec 015 §Data classification; re-frame.classification/project-trace-event, consulted by re-frame.trace/build-event on every emit) routes EACH positional arg through re-frame.elision/elide-wire-value against the frame's app-db elision registry — paths classified :sensitive (the commit-plane :sensitive effect / subsystem declaration, :source :effect / subsystem) inside an arg elide to :rf/redacted; :large-classified or over-threshold leaves elide to :rf.size/large-elided — BEFORE the event reaches any listener, the epoch-capture sink, or the AI/MCP wire. A frame with no declarations leaves the args reference-identical (no walk). This is the same emission site the epoch off-box record uses for :db-before / :db-after.

    Capped at 100 :rf.view/rendered per run with a one-shot :rf.view/rendered-cap-reached marker (:tags {:frame <id> :rf.view/dropped-after 100}) to bound the per-run buffer's heap budget for full-page re-render storms. The whole emit body — including the :rf.view/mount? discriminator, the :rf.view/deref-subs sink, and the :rf.view/render-args capture — sits inside interop/debug-enabled?; production DCEs it (pinned by the elision probe, §Production builds). - :rf.view/unmounted — emitted when a registered-view component INSTANCE tears down. :op-type :rf.view, :operation :rf.view/unmounted. :tags {:rf.view/render-key [<view-id> <instance-token>] :rf.view/id <keyword> :frame <keyword>}. One event per instance teardown (NOT capped — teardown is one-shot per instance). Consumers (Xray's Views table) read this to label the unmount action. The whole surface sits inside interop/debug-enabled?; production DCEs it. Substrate coverage: all adapters. Two teardown seams emit the same op-shape, one per substrate family: - Reagent family (stock + reagent-slim). Rides the per-render-instance reaction-dispose mechanism (the same one r/with-let's finally arm uses): the views.cljs wrapper creates a lifecycle reaction, derefs it inside the render so the substrate's per-component render reaction tracks it as a dependency, and registers an on-dispose callback firing the emit — the render reaction disposes its tracked dependencies on the component's unmount, so the callback fires exactly once. - React-hook family (UIx). That substrate runs the views.cljs wrapper inside a function component with no tracked render reaction (it doesn't publish :adapter/make-reaction, so the reaction-dispose arm above no-ops), so the shared React-hook spine's wrap-view seam (re-frame.substrate.spine/make-wrap-view) arms a React.useEffect empty-deps cleanup that fires the emit on unmount — one-shot teardown matching the Reagent path. The instance-token is minted into a useRef so the :rf.view/render-key tuple is stable across re-renders; the :frame tag is captured in-render so the cleanup (which runs outside the React render) reports the frame the instance rendered under. The emit reaches re-frame.views/emit-view-unmounted! through the :views/emit-view-unmounted! late-bind hook so the spine carries no static views dependency.

    See Spec 004D §Render-tree primitives. - S6 committed-instance evidence schema (re-frame.ui.reactive/commit-record). The compiled-view substrate mints a DEBUG-only per-commit committed-instance record at each connected ViewCell commit (rf2-vxgfnd.98.1; Spec 004D §View identity, EP-0033 §S6 view-evidence delta). This is a tool-PROJECTION evidence schema — read through re-frame.ui.tool (mounted-views / explain-render) and the raw re-frame.ui.reactive/commit-record reader, NOT a trace-stream op — catalogued here per the one-catalogue runtime-tier rule and versioned by re-frame.ui.tool/schema-version (3; consumers Xray / Story / Pair pin it exactly and degrade to [] rather than mis-parse an evolved shape). Shape: {:render-key <int> :view-id <kw> :generation <int> :root-id <kw>|nil :connection :connected :observations [{:kind :subscription :query <v> :target-id <int> :version <int> :owned? <bool> :frame-id <kw>} …] :rf.view/causes [{:cause <kind> …ruled-fields} …]}. The :rf.view/causes vector ships six kinds — :mount, :subscription ({:target :query :frame-id :from :to :epoch}), :story-override ({:override-id :version}), :local-state, :hmr, :disposed — plus the :foreign-or-react honesty fallback; :prop / :frame-:context / :resource / :hydration-correction / :reconnect-correction / :epoch-restore / :hmr-remount are deferred-with-triggers (EP-0033 §S6 view-evidence delta), never emitted. Two render-keys, never conflated: the record's :render-key is a commit-time, module-global monotonic INTEGER minted fresh per connected commit — distinct from the trace stream's render-time :rf.view/render-key [view-id instance-token] tuple (above) and from :rf.sub/reader-render-key (the sub→view edge, §:tags), which are render-phase identities. The direct sub→view relation reads off the record's :observations (each carrying its :target-id + :query + per-observation :frame-id), not off a render-key. The whole schema sits inside interop/debug-enabled?; production mints no record and no :render-key (G-7/G-11 erasure). - :rf.cascade/captured — focused-event-only per-epoch cascade-DAG aggregator emitted by re-frame.trace.cascade at end-of-epoch (after the cascade buffer has been harvested but before :rf.epoch/snapshotted fires). :op-type :rf.cascade. Captures the full per-cascade DAG — db-paths, subs recomputed and skipped, flows computed and skipped, views rendered — for the operator's currently-focused epoch only (a consumer-published focus predicate via re-frame.trace.cascade/set-focus-predicate! discriminates; off-focus epochs pay just the predicate call). Bounded at 50 subs / 100 views per epoch per the Xray Reactive panel render budget; cascades exceeding the cap stamp :sub-cap-truncated? true / :view-cap-truncated? true and retain the first N entries. :tags {:frame <id> :rf.epoch/id <id> :rf.trace/event-id <id> :subs-recomputed [...] :subs-skipped [...] :flows-computed [...] :flows-skipped [...] :views-rendered [...] :sub-cap-truncated? <bool> :view-cap-truncated? <bool>}. Per — the substrate side of the Xray Reactive panel's "full cascade detail for the focused epoch + summary for the rest" budget. - :error / :warning — universal severity discriminators for failure events. The category-specific identity lives in :operation (e.g. :rf.error/handler-exception); see §Error contract for the authoritative model. - :info — informational advisories the runtime emits without warning or error severity (e.g. :rf.http/retry-attempt per 014 §Retry and backoff). Tools that filter for issues subscribe to :warning / :error; tools that surface activity timelines subscribe to :info as well. - Frame-exit machine teardown — single emit on the lifecycle channel. When a frame's destroy walks each surviving machine snapshot, frame.cljc emits one trace event per destroyed machine instance on the unified lifecycle channel: :op-type :rf.machine.lifecycle/destroyed, :operation :rf.machine.lifecycle/destroyed. :tags {:frame <id> :actor-id <live-instance-id> :last-state <state> :reason :parent-frame-destroyed}. The reaped actor's live INSTANCE address rides under :actor-id (:machine-id is reserved for the registered TYPE) — symmetric with the fx-substrate :rf.machine/destroyed. The :reason tag discriminates why the actor went away — :parent-frame-destroyed is the ONLY reason this channel carries (frame-exit reaping is its sole trigger); the fx-substrate's :rf.machine/destroyed emit sites (lifecycle_fx/finalize.cljc + lifecycle_fx/destroy.cljc) carry every non-frame-exit reason under the same :reason slot (:rf.machine/finished for natural termination, :rf.machine/join-reaped for runtime-authenticated non-cancellation cleanup of an already-terminal :spawn-all join child, :explicit for cancellations — imperative destroy and parent-cascade teardown alike; see the matrix below). Tools that want "an actor instance went away" subscribe to BOTH destroy channels — this one carries only the frame-exit cause, so alone it under-reports every other teardown — and branch on :reason only when they need cause-specific routing.

    The :reason enum — the canonical channel/reason matrix. Each reason belongs to exactly ONE channel: the registrar-substrate :rf.machine.lifecycle/destroyed carries only the frame-exit cause; every non-frame-exit cause rides the fx-substrate :rf.machine/destroyed. (Spec-Schemas §:rf/trace-event and 005 §Final states D6 restate this matrix; a machines-artefact conformance test (re-frame.destroyed-reason-channel-conformance-test) pins the three surfaces against each other and against the emit sites, so a reason added to one surface without the others goes red.)

    :reason Channel Emitted by Meaning
    :parent-frame-destroyed :rf.machine.lifecycle/destroyed lifecycle_fx/frame_destroy.cljc (machines-artefact orchestrator); frame.cljc (fallback when the machines artefact is absent) The actor's owning frame was destroyed; its snapshot was reaped as part of the frame-exit cascade.
    :rf.machine/finished :rf.machine/destroyed lifecycle_fx/finalize.cljc The actor reached a :final? state and the runtime auto-destroyed it after firing the parent's :on-done.
    :rf.machine/join-reaped :rf.machine/destroyed lifecycle_fx/destroy.cljc (verified resolution reap or authenticated current-child teardown) A current :spawn-all child already in the durable join's :done ∪ :failed set was physically cleaned up — by resolution reap, direct imperative destroy, or parent-exit cascade. This runtime-derived post-terminal classification is non-cancellation cleanup, so it publishes no second, contradictory terminal cancelled reply (see 005 §Spawn-and-join via :spawn-all).
    :explicit :rf.machine/destroyed lifecycle_fx/destroy.cljc (every other destroy shape) The actor was torn down before reaching a :final? leaf — an imperative [:rf.machine/destroy <id>] fx, a parent state-exit cascade (including the exit a view-unmount :cancel dispatch drives), or a :spawn-all cancel-on-decision. The cancellation discriminator. For an authenticated current in-progress join child, its logical id is durably tombstoned in :cancelled before teardown callbacks / trace emission, closing the attempt against already-queued or delayed exact completions.

    The enum is open per §:tags is the open-ended bag; future causes are additive — but a new cause must be added to BOTH this matrix and the surfaces above, and assigned to exactly one channel (the conformance test enforces the co-edit).

    Two-channel teardown — what each channel sees. The runtime emits machine-destroy traces on two parallel channels with distinct purposes; the channel name carries the source-of-emit, the :reason slot carries the cause:

    Channel Source-of-emit What it observes Typical consumer
    :rf.machine.lifecycle/destroyed lifecycle_fx/frame_destroy.cljc (machines-artefact orchestrator); frame.cljc (no-machines fallback) The registrar-substrate observation: the actor handler / snapshot disappeared from the registrar on frame exit. Fires ONLY from the frame-destroy cascade, one event per reaped instance, always with :reason :parent-frame-destroyed (the channel's sole reason). "Did a machine appear/disappear?" — observers building a live list of running actors.
    :rf.machine/destroyed lifecycle_fx/finalize.cljc + lifecycle_fx/destroy.cljc The fx-substrate observation: a destroy fx ran on the spawn / destroy fx-id path. One event per fx-driven teardown. Does NOT fire for frame-exit reaping (that is registrar-substrate only). Causal-graph builders ("which fx caused this teardown?") — observers correlating fx emission against actor lifecycle.

    Because the channels are disjoint, tools that "just want did a machine disappear?" subscribe to BOTH and treat either as the disappearance — picking one channel silently drops the causes the other owns. Tools building causal graphs (Pair, Xray, Story) subscribe to both for the same reason. No extra disambiguator is needed, or emitted: the naming axis itself (:rf.machine.lifecycle/* = registrar-substrate, :rf.machine/* = fx-substrate) carries the source-of-emit distinction, and the :reason slot carries the cause. Since each reason belongs to exactly one channel, :operation and :reason are mutually redundant on this pair — a consumer may branch on whichever it already has in hand, and a (channel, reason) tuple absent from the matrix above is malformed input, not a shape to render. A rename to :rf.machine.fx/destroyed / :rf.machine.registrar/destroyed was considered and rejected: the existing names align with how the rest of the spec namespaces the two substrates (:rf.machine.lifecycle/* is the registrar-lifecycle family, :rf.machine/* is the fx-substrate family), and the cost of churning every tool / fixture / docstring outweighs the marginal naming-axis clarity. - :rf.frame/drain-interrupted — lifecycle event emitted by router.cljc when an ordinary drain, immediately before dequeue, observes that its exact frame incarnation has been claimed for destruction or is already dead/absent. The claim is the ordinary-work cutoff: an authored callback already on the stack may return and already-entered authored interceptor :after callbacks may unwind, but its returned context/output is inert and no later framework-owned tail runs. The claim atomically removes the then-pending ordinary queue. An external ordinary dispatch that linearizes while the claimed incarnation remains lifecycle-live may enter its real queue; the next exact-incarnation drain check removes it before handler, effects, or child dispatch. Dispatch after lifecycle-dead/absence is rejected/no-op with :rf.error/frame-destroyed. The destroy-owned private exact-token :on-destroy cascade is the sole executable exception. The trace is emitted only when a drain observes the cutoff (a destroy before any drain begins is quiet), and :dropped-count is the combined total of events removed at claim time plus ordinary events removed by that later check. :op-type :rf.frame (the frame-lifecycle family — see :rf.frame/created / :rf.frame/destroyed siblings; not :op-type :rf.event, which is reserved for "an event was dispatched"). :tags {:frame <id> :dropped-count <int>}. Per 002 §Edge cases worth pinning. - :rf.epoch/snapshotted / :rf.epoch/outcome / :rf.epoch/restored / :rf.epoch/db-replaced — epoch-history operations under :op-type :rf.epoch. -snapshotted fires once per dequeued event when the runtime has appended a fresh :rf/epoch-record (one per epoch — per 002 §Drain versus event) and carries the detailed cause :outcome enum from :rf/epoch-record (:ok / :halted-depth / :halted-destroy / :halted-handler-exception, per Spec-Schemas §:rf/epoch-record §Outcomes). -outcome fires immediately after -snapshotted at the same cascade-trailer point and carries the consumer-facing summary :outcome enum (:ok / :blocked / :error) — the coarse three-tier projection the Trace-panel close-row (tools/xray/spec/023-Trace-Panel.md §13) and Story outcome chips read directly. The two ops carry the same :frame / :rf.epoch/id / :rf.trace/event-id so consumers correlate detail ↔ summary by epoch-id; tools that want the cause read -snapshotted's :outcome, tools that want the summary read -outcome's :outcome. -restored fires after a successful restore-epoch!; -db-replaced fires after a successful pair-tool injection (replace-frame-state!, the ONE partial-map mutator — the pair-tool write surface, see Tool-Pair §Pair-tool writes). Per Tool-Pair §Time-travel. :tags {:frame <id> :rf.epoch/id <id> :rf.trace/event-id <id>? :outcome <enum>} (the :outcome tag is required on -snapshotted and -outcome, absent on the other two).

    The consumer-facing :outcome mapping (pinned in implementation/epoch/test/re_frame/epoch_test.clj outcome-enum-projection-pins-mapping; load-bearing — devtools and trace-stream consumers depend on it):

    :rf.epoch/snapshotted :outcome (cause) :rf.epoch/outcome :outcome (summary) Rationale
    :ok :ok The run settled cleanly.
    :halted-depth :blocked The drain hit the configured depth limit; the halting event never ran. A drain-shape stop, not an error.
    :halted-destroy :blocked The frame was destroyed mid-drain — a deliberate lifecycle stop.
    :halted-handler-exception :error Schema-reserved cause; the reference runtime currently does NOT emit this (handler throws route through the interceptor error-capture seam and settle :ok with the error trace under :trace-events, per Spec-Schemas §:rf/epoch-record §Outcomes). The mapping is pinned for a future runtime that aborts the drain on a handler throw.

    The mapping rationale: :halted-destroy is a deliberate lifecycle stop (the frame's owner asked for the frame to go away mid-run) and :halted-depth is a shape-of-the-drain stop (a runaway re-dispatch loop tripped the configured depth limit — the runtime guarded against further work). Neither involves a thrown exception; surfacing both as :blocked distinguishes them from genuine errors that consumers want to flag. - :rf.epoch.cb/silenced-on-frame-destroy — listener-silencing notification emitted once per destroy-continuum for a (frame, cb-id) pair (exactly once for a single destroy; see the law below) when a frame previously observed by a register-epoch-listener! callback is destroyed (per Tool-Pair §Surface behaviour against destroyed frames). :op-type :rf.epoch.cb. :tags {:frame <id> :cb-id <id> :observed-gen <gen>}:observed-gen is the callback generation the silence is attributed to. The callback registration remains in place; the trace exists so a tool whose previously-firing cb has gone silent learns why. A consumer decides whether a received signal is CURRENT with the supported receiver operation in the law below — (rf/epoch-silence-current? tags), which weighs :observed-gen against the listener's current generation AND the listener's observation of the signal's :frame, both at receipt time, from one ledger snapshot and without a private registry read. Repeat destroys of the same frame do not re-emit; a re-registration of a same-keyed frame followed by a fresh delivery re-arms the cb's observation set so a subsequent destroy re-emits. Under concurrent same-id frame churn — where a destroyed incarnation's silencing fan is deferred past a same-id successor's lifecycle — which callback generation is silenced, and for which destroy, obeys the delayed-silence emission linearization law below.

Consumers filter by :op-type (or :source, or (get-in ev [:tags :frame])) to get the slice they care about. Adding new :op-type values is non-breaking — tools ignore what they don't understand.

The delayed-silence emission linearization law

The :rf.epoch.cb/silenced-on-frame-destroy signal is owed to a specific callback generation identity (cb-id, generation), not merely a cb-id. Each register-listener! :epoch (re-)registration under a key mints a fresh, process-monotonic, never-reused generation token; a callback's observation of a frame is stamped with the generation that consumed the record. This is what makes the one-shot rule exact under same-id frame churn — a destroyed incarnation A whose silencing fan is deferred past a same-id successor B's entire lifecycle (claim → re-arm → destroy), the case a destroy-frame! + re-make-frame reset of a keyed frame produces. The emission upholds three composable properties.

  1. Reservation authority, re-verified at receipt. A silence is reserved for (frame, cb-id, G) only if, at the reservation linearization point (under both ledger locks), G is still the callback's live generation, the callback is not a current live observer of the frame, and no equal-or-newer claim already took the signal. The emit itself runs outside the ledger locks, so a foreign listener's dispatch-sync can never reach a frame's :drain-lock while a ledger lock is held (the ledger↔:drain-lock AB-BA deadlock closed by rf2-8b9twg). Because no lock spans the emit, any of the reservation's three inputs may change before the signal lands, so authority is carried by data the receiver re-reads, never by a held lock. The mutations split into two kinds, and each has its own discriminator:

    • Registration identity — a same-id replacement G → H, or an unregister-drop. These mint a different generation. The payload carries :observed-gen = the reserved generation G, so a receiver whose current generation for cb-id no longer equals the carried :observed-gen self-filters the superseded signal. The forbidden ordering (H current, then a signal attributed to H) cannot occur — no unqualified silence is ever emitted. This supersedes the emit-under-lock mechanism of rf2-9bhne6 (which held the winning generation authoritative by running the emit inside the locks).
    • Observation continuum — a same-id successor frame delivering a fresh record to cb-id, re-arming it. A delivery mints no generation (only a re-registration does), so the reserved :observed-gen still equals the live generation while the callback is receiving records again. :observed-gen therefore does not discriminate this kind, and must not be relied on to (rf2-qg98y — the reservation's not-a-live-observer check is a reservation-time decision and cannot be re-taken across the lock-free emit). The discriminator is the observation continuum: whether cb-id's current registration is observing that frame right now.

    The supported receiver operation. ONE call, at receipt time, at the public boundary (re-exported on re-frame.core; no private registry read) — hand the signal's tags straight back:

    (rf/epoch-silence-current? tags)   ;; tags = (:tags ev)
    

    It is exact at read time: the silence names a current fact iff both facts hold — the carried :observed-gen is still the generation registered under :cb-id, and that registration is not observing :frame. A mutation landing after the receiver reads is simply a later continuum — a replacement the consumer performed itself, or fresh record deliveries it observes directly.

    Why one operation and not two queries (rf2-uhouu). The two facts were briefly two public reads a consumer composed at the call site. That composite is not linearizable: nothing holds the ledger still between the reads, so a same-id replacement or an unregister-drop landing at the seam is seen as generation still matches (read before) and not observing (read after, of the fresh or absent registration), and the composite accepts — a verdict describing a state that never existed, since the before-state rejects (the callback was observing) and the after-state rejects too (the generation is superseded). A second seam sat one level lower, inside the observation-continuum read itself, which derefs the observation ledger and then the listener registry — two atoms under two different monitors. epoch-silence-current? takes both facts inside the ledger's own critical section, so there is no seam to place a mutation at, and the low-level halves are not published: exposing them is exposing the race. The operation is a bounded pair of reads — no foreign code, no trace emission, no lock held across a callback — so it cannot reach a frame's :drain-lock while holding a ledger lock.

    Pinned by implementation/epoch/test/re_frame/epoch_silencing_generation_emission_test.clj (emit-runs-outside-the-ledger-locks-so-a-concurrent-registrar-and-rearm-are-not-blocked, silence-signal-is-generation-qualified-and-self-filters-a-replacement-in-the-emit-window), by implementation/epoch/test/re_frame/epoch_silencing_same_generation_rearm_test.clj (the same-generation re-arm case and its frame-scoping / no-over-rejection / compound adversarial peers), by implementation/epoch/test/re_frame/epoch_silence_decision_atomicity_test.clj (every mutation kind placed at every former read seam), and, at the public boundary, by implementation/epoch/test/re_frame/epoch_silence_receiver_public_api_test.clj.

  2. Re-arm supersedes. A same-id successor that re-armed the callback — delivered it a fresh record — owns the live callback, so no silence is owed for the new continuum. Note the re-arm is a delivery, not a re-registration: it re-arms the callback under its existing generation. Two seams enforce this at different points. At reservation, a still-live re-armed successor reads as a live observer and is excluded from the fan. Across the lock-free emit window, where no reservation-time check can reach, the observation-continuum half of the receiver operation above re-verifies the same fact at receipt. What a consumer that applies the rule sees: a callback is either live (never accepted as silenced while it is still receiving records from that frame) or, when the successor also destroys, silenced exactly once by the successor's own destroy. The same (frame, cb-id) pair therefore can be silenced more than once across the process — once per destroy-continuum — but never more than once for a single destroy.

  3. Late-predecessor ordering. A deferred predecessor snapshots its owed {cb-id → generation} observers and a baseline marker before its frame is dissoc'd. A same-id successor is constructable only after that dissoc, so every silence the successor emits is stamped strictly above the predecessor's baseline in a single process-monotonic total order. When the predecessor finally publishes, it grants each owed identity only if no claim stands above its baseline — a successor (or an overlapping same-id publisher) that already fired the one signal blocks the predecessor from re-emitting the identical unqualified signal (the A→B→nil ABA). Owed identities fan out in a deterministic (cb-id-ordered) sequence, so a listener that re-arms a later identity while an earlier silence is still delivering observes a stable, linearizable order. The predecessor's terminal :halted-destroy record (delivered to :epoch listeners, never stored) is published on this same deferred path and may arrive after a newer same-id incarnation has begun recording — a historical record for the destroyed incarnation, per the Tool-Pair late-record consumer rule.

The generation-churn invariant is additionally pinned by the epoch-concurrency-stress suite (implementation/epoch/test/re_frame/epoch_concurrency_stress_test.clj): under many concurrent same-id re-registrations, each destroy silences at most once, and a surviving fresh-generation observation is stamped with the live generation so its destroy silences exactly once. The consumer-routing counterpart of this law — what a long-lived pair-tool listener may rely on across frame churn — is Tool-Pair §Silencing under same-id frame churn.

Two-axis machine observation — registrar-substrate vs fx-substrate

A machine instance's appearance and disappearance are each observable on two parallel axes — the naming prefix carries which substrate did the observing, never duplicate facts about the same instant. This is the single model a consumer reaches for; the per-:reason teardown table above (under :rf.machine.lifecycle/destroyed) is the cause-routing detail beneath it.

Lifecycle moment Registrar-substrate axis (:rf.machine.lifecycle/*) fx-substrate axis (:rf.machine.spawn/* / :rf.machine/*)
Handler registered :rf.machine.lifecycle/created — (no fx; registration is registrar-only)
Actor spawned :rf.machine.lifecycle/spawned (the actor's snapshot landed in the registrar) :rf.machine.spawn/spawned (the :rf.machine/spawn fx ran)
Actor destroyed :rf.machine.lifecycle/destroyed (frame-exit reaping ONLY — always :reason :parent-frame-destroyed) :rf.machine/destroyed (a destroy fx ran; every non-frame-exit reason; does NOT fire for frame-exit reaping)

Which axis to subscribe to. The two moments differ, and the difference is load-bearing:

  • Spawn is symmetric. Both axes fire for every spawn — the same instant observed twice, once as "the spawn fx ran" (:rf.machine.spawn/spawned) and once as "the actor's snapshot landed in the registrar" (:rf.machine.lifecycle/spawned). A tool that just wants "did an actor appear?" picks either axis.
  • Destroy is disjoint. The two destroy channels partition the teardown causes between them (see the channel/reason matrix above): frame-exit reaping is registrar-substrate only, every other teardown is fx-substrate only. Neither channel alone is a complete record of actors going away. A tool that wants "did an actor disappear?" must subscribe to BOTH :rf.machine.lifecycle/destroyed and :rf.machine/destroyed — a lifecycle-only observer misses every normal teardown (:explicit, :rf.machine/finished, :rf.machine/join-reaped); an fx-only observer misses every frame-exit reap.

A tool building a causal graph ("which fx caused this spawn / teardown?") subscribes to both axes on both moments and correlates by :spawned-id (spawn) / :actor-id (destroy) plus the cascade :rf.trace/dispatch-id. Because the destroy channels are disjoint there is no de-duplication to do on teardown: an actor's disappearance produces exactly ONE destroy event, and which channel carried it already tells you the cause family. On spawn — where the axes genuinely do double-report — correlate the pair by :spawned-id rather than counting events.

The two-substrate naming. The names align with how the rest of the spec namespaces the two substrates: :rf.machine.lifecycle/* is the registrar-lifecycle family; :rf.machine.spawn/* / :rf.machine/* are the fx-substrate family. See 005 §Spawn lifecycle — ordering for where each emit sits in the spawn cascade.

History trace events (:rf.machine.history/*)

History pseudo-states (per 005 §History states) record a compound's last-active configuration on exit and restore it on re-entry. Two trace events make the record/restore observable so tooling renders why a re-entry landed where it did rather than only {from}→{to}. They live under the reserved :rf.machine.history/* sub-family of :rf.machine/* (reserved in Conventions §Reserved namespaces, consistent with the :rf.machine.lifecycle/* / :rf.machine.timer/* / :rf.machine.event/* / :rf.machine.microstep/* carve-outs and with the reserved-:rf/* exemption rationale). Both are machine-activity traces (:op-type :rf.machine), NOT severity discriminators — a history restore/record is benign observability, never an issue, so it never washes a cascade pink nor enters an issues ribbon (same posture as :rf.machine.event/unhandled-no-op).

Both events are EDN-clean — every payload slot is keywords and vectors-of-keywords only (the :rf/history slot's own shape per Spec-Schemas §:rf/machine-snapshot), so they round-trip through pr-str / read-string and survive SSR serialisation with no elision concern. The configs are small (state paths), so there is no large-payload leak and no per-slot redaction concern beyond the snapshot-rooted [:schemas :data] marks the surrounding :rf.machine/transition already applies to its :before / :after slots (per 005 §Privacy). Both ride the standard trace envelope and interop/debug-enabled? gate, so production CLJS bundles DCE them.

:rf.machine.history/restored — re-entry resolved a history pseudo-state

Fires when a transition targets a :type :history pseudo-state and re-entry resolves the recorded (or default) configuration to a concrete leaf — emitted once per restore, at target-resolution time, immediately before the resolved leaf feeds the standard entry cascade. :op-type :rf.machine, :operation :rf.machine.history/restored. :tags:

tag shape meaning
:actor-id keyword the live actor INSTANCE whose transition restored history (:machine-id is reserved for the registered TYPE).
:compound-path [keyword …] the declaration path of the compound that owns the history pseudo-state — the key into :rf/history (region-qualified head segment under :type :parallel, per 005 §Composition with parallel regions).
:kind :shallow | :deep the pseudo-state's depth (:deep?:deep; absent/false:shallow).
:source :recorded | :default :recorded — a recording existed for :compound-path in :rf/history and was still a valid path in the current definition, so the restore re-entered the recorded configuration. :default — no usable recording (the compound was never exited, OR the recorded path was dangling after a hot reload and discarded per 005 §Dangling recorded paths), so re-entry fell back to the pseudo-state's :default-target (or, when that is absent, the compound's :initial). This is the same :source discriminator the :cascade field's history-originated entry steps carry (below), so a consumer reads the headline source off this event and the per-step origin off the cascade without re-deriving either.
:fallback :default-target | :initial | absent present only when :source :default — names which fallback resolved the leaf (:default-target when the pseudo-state declared one, else :initial). Absent on the :recorded path.
:restored-config [keyword …] | keyword the recorded configuration that drove the restore, read straight from :rf/history — an absolute leaf path (deep) or a direct-child keyword (shallow, before its :initial cascade). Absent on the :source :default path (nothing was recorded).
:resolved-leaf [keyword …] the concrete absolute leaf path the restore resolved to and the entry cascade will enter — for shallow, this is the recorded child after its :initial chain descends; for deep, equal to :restored-config. This is the leaf that appears as the deepest :entry step in the same macrostep's :cascade.

Composition with the entry cascade (not duplication). A history restore is an entry cascade whose target leaf came from :rf/history — it is not a separate cascade mechanism (per 005 §Composition with the LCA, entry/exit cascade, and final states). So :rf.machine.history/restored does not re-list the per-level entry steps; those already appear as ordinary :entry steps in the same macrostep's :rf.machine/transition :cascade field (per 005 §The structured transition cascade). To let a consumer mark which entry steps originated from a history restore vs an ordinary :initial descent, each :cascade :entry step produced by a history restore additively carries :source :recorded (the step's leaf came from the recorded config) or :source :default (the step's leaf came from the :default-target / :initial fallback); a :cascade step with no :source key was not history-driven (an ordinary :on-target or :initial entry). The :source value matches this event's :source. (This is the cascade-step :source field the engine stamps on history-originated entry steps; it is the only addition history makes to the transition step shape, and it is absent on every non-history step.) Consumer flow: read :rf.machine.history/restored for the headline "restored :compound-path from :source," then walk the same cascade's :source-tagged :entry steps for the per-level path it entered. Xray's machine inspector renders this composition.

:rf.machine.history/recorded — compound exit wrote the configuration

Fires when the exit cascade leaves a compound state that owns a history pseudo-state and the runtime writes that compound's last-active configuration into :rf/history — emitted once per recording write, as part of the exit cascade's commit (the same drain that exits the compound; there is no separate write phase, per 005 §Recording — on compound-state exit). :op-type :rf.machine, :operation :rf.machine.history/recorded. :tags:

tag shape meaning
:actor-id keyword the live actor INSTANCE whose exit recorded history (:machine-id is reserved for the registered TYPE).
:compound-path [keyword …] the declaration path of the exited history-bearing compound — the key written in :rf/history (region-qualified head segment under :type :parallel, so per-region recordings never collide).
:kind :shallow | :deep the owning pseudo-state's depth, mirroring :rf.machine.history/restored.
:recorded-config [keyword …] | keyword the value written — for a deep compound, the absolute leaf path beneath the compound at exit; for a shallow compound, the recorded direct-child keyword. Reads =-equal to what a later :rf.machine.history/restored for the same :compound-path reports as :restored-config.
:prev-config [keyword …] | keyword | absent the value previously stored at :compound-path (overwritten by this write); absent on the first-ever recording for the compound (the slot was previously unallocated). Lets a consumer show "history advanced from X to Y" without re-folding the trace stream.

A compound that owns no history pseudo-state records nothing and emits no :rf.machine.history/recorded (the runtime only writes the slot for history-bearing compounds). The -recorded event pairs naturally with the headline :rf.machine/transition of the macrostep that exited the compound — they share the cascade's :rf.trace/dispatch-id — so a consumer correlates "this transition exited :player, and here is what got recorded" without threading.

No error for a dangling recorded path. A recorded configuration that a hot-reloaded definition later invalidated is a benign, expected consequence of hot reload, not a grammar violation (per 005 §Dangling recorded paths after hot reload) — no :rf.error/* is raised. It is observable purely as a :source :default (with :fallback) on the next :rf.machine.history/restored for that compound; the malformed-grammar :rf.error/machine-history-* catalogue (see §Error event catalogue) is registration-time only and never fires for a dangling-at-runtime recording.

:tags is the open-ended bag

Variable per-event data goes in :tags. New tags can be added without breaking consumers. Use :tags for op-type-specific data; reserve top-level keys for fields universal across all events.

Every framework-owned top-level :tags key is namespaced under its domino family or — for the cross-cutting correlation spine — under :rf.trace/*, per the single-root convention (Conventions §Reserved namespaces). The one deliberate carve-out is :frame (see the canonical-routing note below). The full key scheme:

:tags key Family Notes
:frame universal routing Bare carve-out (see below)
:rf.trace/dispatch-id, :rf.trace/parent-dispatch-id, :rf.trace/event-id, :rf.trace/trace-id, :rf.trace/phase cross-cutting correlation Stamped across every domino family — the trace channel's own correlation spine; no single domino home, so they live under :rf.trace/* (per Conventions §:rf.trace/*).
:rf.event/v (the dispatched event vector), :rf.event/origin, :rf.event/sync?, :rf.event/fx, :rf.event/db-present?, :rf.event/db, :rf.event/coeffects, :rf.event/cofx, :rf.event/fx-overrides, :rf.event/interceptor-overrides, :rf.event/elapsed-ms event The closed-enum functional-origin discriminator rides on the bare :source tag (a bare carve-out under the trigger-kind axis). :rf.event/elapsed-ms is the HANDLER-BODY-only wall-clock on :rf.event/run-end. :rf.event/db is the FULL :db value stamped on the :rf.event/db-pending (t1) and :rf.event/db-pending-post-flow (t2) trace events; PDS structural sharing keeps the cost pointer-sized, the day8/de-dupe wire layer collapses repeated subtrees on egress. :rf.event/cofx is the dev-only POST-GENERATION flat :rf.cofx replay token on the :rf.event/run-start emit — the causal cofx map as it was AFTER the router's declared-only delivery ran (generator-backed recordable facts minted at processing-start written back, plus the framework :rf/time-ms); the epoch surface pins it as the record's :rf.cofx replay slot. Each fact value is redacted against the cofx-id's declared :sensitive / :large marks at the chokepoint (re-frame.classification/project-trace-event, the same per-cofx-id rule :rf.event/coeffects uses) before any off-box egress. :rf.event/fx-overrides / :rf.event/interceptor-overrides (rf2-yigokd) are the dev-only envelope override captures on the SAME :rf.event/run-start emit — the envelope's own per-call (+ lexical, for :fx-overrides) :fx-overrides / :interceptor-overrides (never the per-frame tier), marker-ized (:rf/fn-override) for a fn-valued :fx-overrides entry at the emission site; the epoch surface pins them as the record's bare :fx-overrides / :interceptor-overrides replay slots beside :rf.cofx (per Spec-Schemas §:rf/epoch-record and Tool-Pair §Replay). :rf.event/fx — the handler's WHOLE returned effect vector, stamped on the :rf.fx/do-fx trace — is registration-classified at egress: each [fx-id args] entry's args are redacted through THAT fx-id's reg-fx :sensitive / :large at the chokepoint (re-frame.classification/project-trace-event), mirroring the sibling :rf.event/db walk (rf2-6h3c02) — the shared-posture note in §Canonical per-event trace sequence is now realised.
:rf.sub/id, :rf.sub/query-v, :rf.sub/input-signals, :rf.sub/value-changed?, :rf.sub/prev-value, :rf.sub/value, :rf.sub/cascade?, :rf.sub/cause-sub, :rf.sub/cause-event-id, :rf.sub/reader-render-key, :rf.sub/input-paths-unchanged, :rf.sub/reason, :rf.sub/elapsed-ms sub :rf.sub/elapsed-ms is the per-recompute wall-clock. :rf.sub/cause-event-id mirrors :rf.view/cause-event-id — the dispatching cascade's event-id, present only inside a cascade.
:rf.view/render-key, :rf.view/id, :rf.view/mount?, :rf.view/deref-subs, :rf.view/render-args, :rf.view/triggered-by, :rf.view/elapsed-ms, :rf.view/cause-event-id, :rf.view/cause-subs, :rf.view/dropped-after view :rf.view/render-args (rpgq8) carries the view's positional render args/props — arbitrary user data, so it is elided at emit time through the same chokepoint as :rf.event/db before delivery.
:rf.fx/id, :rf.fx/args, :rf.fx/from, :rf.fx/to, :rf.fx/platform, :rf.fx/registered-platforms, :rf.fx/elapsed-ms fx :rf.fx/elapsed-ms is the per-fx-handler-invoke wall-clock on :rf.fx/handled. :rf.fx/args is registration-classified at egress against the fx's reg-fx :sensitive / :large on every slot that stamps the [:rf.fx/id :rf.fx/args] pair — keyed off the slot SHAPE, not op :rf.fx/handled — so the per-effect :rf.fx/handled success trace AND the always-on fx error traces (:rf.error/fx-handler-exception + siblings) and :rf.fx/skipped-on-platform all redact through the same declaration at the chokepoint (re-frame.classification/project-trace-event, rf2-6h3c02); an unregistered fx-id (:rf.error/no-such-fx) has no registration to read and is the documented fail-open.
:rf.interceptor/override-summary interceptor Dev-only, on the :rf.event/run-start trace emit (rides the dev-only trace stream, NOT the always-on flat event-emit record — see the substrate note under §Emit-gate summary). Present ONLY when this dispatch's merged per-frame + per-call :interceptor-overrides actually acted on the resolved chain (omitted entirely on the override-free hot path). The value is strictly id/count-only — a map {:matched [<ref-id>…] :replaced [<ref-id>…] :removed [<ref-id>…] :count N} where each <ref-id> is an authored interceptor reference (a bare keyword id or an [id arg] 2-vector head id). It carries NO interceptor values, executable maps, fns, raw factory args, or raw replacement values: a parameterized ref is EDN-serializable but its arg is not proven privacy-safe, so this surface egresses ids/counts only, enforced fail-closed at the classification chokepoint (re-frame.classification/project-trace-event reduces an [id arg] ref to its head id and collapses any non-ref payload to :rf/redacted). :matched is the union of :replaced (override with a ref replacement) and :removed (override with a nil replacement); :count is (count :matched). Rides the dev-only trace stream — see the substrate note below.
:rf.epoch/id, :rf.epoch/outcome epoch
:rf.cofx/id, :rf.cofx/value, :rf.cofx/arg, :rf.cofx/elapsed-ms cofx On :rf.cofx/run: :rf.cofx/value is the supplier's PRODUCED value (redacted by marks — see the op above), :rf.cofx/arg is the requirement-arg of a parameterized [id arg] requirement (omitted on the bare path), :rf.cofx/elapsed-ms is the per-supplier-invoke wall-clock. The :rf.cofx/generated op reuses :rf.cofx/id for the generated fact's name + supplier id, :rf.cofx/value for the produced value (redacted by the same classification chokepoint), and :rf.cofx/arg for a parameterized requirement-arg.

Nested record-map keys are NOT renamed. Where a tag value is a structured map or vector-of-maps (e.g. :rf.cascade/captured's :subs-recomputed [{:sub-id _ :query-v _} …] entries), the inner keys are internal value shape, not top-level :tags keys — they carry no collision surface and stay as-is. The namespace scheme governs top-level :tags keys only.

Canonical per-frame routing key — the deliberate bare carve-out. Every trace event that names a frame uses :frame under :tags. The framework MUST NOT emit :frame-id as a tag key — :frame is the single canonical name; ports that re-emit must follow suit. The raw trace-event shape carries :frame only at [:tags :frame] — there is no public top-level :frame on a raw event (that top-level slot belongs to the projection layer — event bundles, :rf/epoch-records, dispatch consequences, cursor / summary records — per §Frame identity on the raw event and Tool-Pair §Identity spellings). Consumers read a raw event's frame through the one canonical accessor the trace contract owns — re-frame.trace/trace-event-frame (alias frame-of), implemented as (get-in ev [:tags :frame]) — not by hardcoding the path (and not via a dual (or (get-in ev [:tags :frame]) (:frame ev)) read). :frame is the one deliberate exception to the "every framework tag is namespaced" rule above: it is the single universal routing tag stamped on every frame-qualified event, carries zero collision risk in practice, and is the one key every tool already special-cases — so it stays bare rather than becoming :rf/frame.

Open shape; new fields are additive

The map is open. New fields can be added by future versions without breaking consumers — listeners read what they understand and ignore the rest. The forward-compat commitments:

  • Required top-level fields (:id, :operation, :op-type, :time, :tags) are stable. Removing or renaming any is a breaking change.
  • Re-frame2 additions hoisted to top level (:source, :recovery) are stable once shipped; they are present on every event whose tags carry them.
  • Op-type-specific fields inside :tags are stable within their op-type — including :frame, which every emit site supplies under :tags. New optional tag keys are additive; existing keys don't change shape.
  • New :op-type values can be added without breaking existing tools — tools filter the values they recognise.

Canonical per-event trace sequence

A single event's run emits a canonical, ordered trace sequence. The ordering is contract — off-box monitors, Xray's Trace panel, Story, and conformance recorders rely on it to place each phase relative to the others. A conformant port MUST emit (the phases that fire for a given event; omit those whose condition is unmet) in this order:

:rf.event/dispatched         ;; (envelope queued; one per dispatch — may precede the drain)
:rf.event/run-start          ;; the handler's interceptor chain begins
:rf.cofx/run                 ;; per coeffect supplier that ran to success during
                             ;; context assembly (carries :rf.cofx/id + value +
                             ;; :rf.cofx/elapsed-ms); precedes the handler body.
                             ;; (EP-0017 slice B.7 adds :rf.cofx/generated for
                             ;; the recordable-generation step, also pre-handler.)
  … handler body + the rest of the :after chain run (reshaping the :db effect) …
:rf.event/db-pending         ;; t1 — the post-handler-chain / pre-flow-
                             ;; transform pending :db. Carries the FULL value the
                             ;; handler returned under :tags :rf.event/db (same
                             ;; posture as :rf.event/fx on :rf.fx/do-fx — Mike
                             ;; 2026-05-25). Fires ONLY when the handler returned
                             ;; a :db slot; suppressed otherwise.
:rf.flow/computed | :rf.flow/skip | :rf.flow/failed   ;; the OUTERMOST :after —
                             ;; the flow transform, per flow, in topological order.
                             ;; Fires after the rest of the :after chain (so it
                             ;; sees the fully-reshaped :db effect) and BEFORE
                             ;; :rf.event/db-changed (install).
:rf.event/db-pending-post-flow ;; t2 — the post-flow-transform / pre-
                             ;; commit pending :db. Carries the FULL flow-
                             ;; augmented value under :tags :rf.event/db. Fires
                             ;; ONLY when flows actually transformed the value
                             ;; (the substrate's identical?-by-reference guard);
                             ;; omitted otherwise (t1 == t2 carries no info).
:rf.event/db-changed         ;; the FLOW-AUGMENTED :db installs into app-db (the
                             ;; single deferred commit — the atomic boundary).
                             ;; APP-DB-ONLY: fires only when the
                             ;; app-db partition changed; never fires for a
                             ;; runtime-only commit.
:rf.event/db-noop            ;; OR (complement of db-changed) — a :db effect was
                             ;; present but app-db did NOT change (handler returned
                             ;; an unchanged db; identical?-noop skipped the write).
                             ;; APP-DB-ONLY. For a :db-bearing commit EXACTLY ONE of
                             ;; db-changed / db-noop fires; neither fires for an
                             ;; :fx-only / runtime-only commit (no :db effect).
:rf.event/frame-state-changed ;; fires when EITHER partition changed (the frame-
                             ;; level signal). Carries :tags :rf.event/partitions
                             ;; — a set drawn from #{:app-db :runtime-db} naming
                             ;; which partition(s) this commit touched. A runtime-
                             ;; only commit emits THIS (with #{:runtime-db}) and
                             ;; NOT :rf.event/db-changed; an app-only commit emits
                             ;; both (db-changed + frame-state-changed #{:app-db}).
:rf.sub/run | :rf.sub/skip   ;; sub-cache recompute on the new (flow-augmented) frame-state
:rf.fx/handled               ;; per :fx entry (reads the flow-augmented app-db)
:rf.fx/do-fx                 ;; terminating :fx-walk marker — fires AFTER the per-fx
                             ;; :rf.fx/handled entries (carries the :fx-vector +
                             ;; :db-present? shape for the Event lens)
:rf.view/render | :rf.view/rendered   ;; reactive re-render on the new db
:rf.event/run-end            ;; run-tail: fires LAST, after the deferred :db
                             ;; install and the :fx walk (router emits it in
                             ;; emit-cascade-trailers!, after commit-and-flow!).
                             ;; Carries :rf.event/elapsed-ms — the HANDLER-BODY-only
                             ;; wall-clock (the interceptor chain, NOT the whole
                             ;; run) for the Trace panel's DURATION column.

The :rf.event/db-pending / :rf.event/db-pending-post-flow pair (t1 / t2). Two trace events bracket the flow transform. t1 (:rf.event/db-pending) fires inside the framework's outermost :after (flows-after-interceptor) BEFORE running flows, when the handler returned a :db slot; t2 (:rf.event/db-pending-post-flow) fires inside the same interceptor AFTER running flows, when the flow transform actually changed the pending value ((not (identical? new-db pending-db))). Both carry the FULL :db value under :tags :rf.event/db — same payload-slot posture as :rf.event/fx on :rf.fx/do-fx, and same Mike-ruled posture (2026-05-25): full reference, no diff, no DEBUG gate. Persistent-data structural-sharing keeps the cost pointer-sized; the day8/de-dupe layer at the pair-mcp wire boundary collapses repeated subtrees on egress. The :rf.event/db slot is redacted at the classification chokepoint (re-frame.classification/project-db-tags, run by re-frame.trace/build-event for every t1 / t2 emit): the full-app-db slot routes through the schema-first wire walker re-frame.elision/elide-wire-value against the frame's app-db elision registry — the SAME site epoch's projected-record uses for :db-before / :db-after — so schema-:sensitive? slots egress as :rf/redacted and :large? slots get the :rf.size/large-elided marker; the walk is gated on the frame carrying declarations, so a frame with no marks keeps the copy-free reference-identity the slot promises. Consumers (Xray's Handler panel, re-frame2-pair's cascade-of) read t1 to render the handler's returned :db value and read (t1, t2) together to render the t1→t2 flow reshape — the framework does NOT precompute a diff (the values are full both ends, modulo redaction; client-side diff is cheap).

t1 fires when the handler returned :db, regardless of whether the flows artefact is loaded (apps that never registered a flow still get t1). t2 is by definition impossible without the flows artefact (no flow could have transformed the pending value). On a flow-throw abort (Spec 013 §Failure semantics), t1 still fires (it ran before the throw) but t2 does NOT (the run aborted, the pending :db was discarded with no install — mirrors the absence of :rf.event/db-changed). Both emits sit inside the shared interop/debug-enabled? gate so production CLJS bundles DCE them along with the rest of the trace surface.

The flow position is the load-bearing change. :rf.flow/computed is emitted after the handler's :after chain (the flow transform is the outermost :after, so it fires after the rest of the chain reshapes the :db effect) and before :rf.event/db-changed. :rf.event/db-changed reflects the flow-augmented db — the value installed already carries every flow's output. A consumer placing flows on the run timeline reads :rf.flow/computed between :rf.event/run-start and :rf.event/db-changed; the flow's write is visible in the :rf.event/db-changed snapshot, not applied after it.

:rf.event/run-end is a run-tail trace: the router emits it in emit-cascade-trailers! after commit-and-flow! has run, so it falls after the deferred :db install (:rf.event/db-changed) and after the :fx walk — it is the last trace of a clean run. (It is not emitted at the close of the interceptor chain; the chain completes inside run-chain, the install and :fx walk follow in commit-and-flow!, and only then does the trailer fire.) The relative order that consumers depend on is :rf.flow/computed:rf.event/db-changed:rf.fx/handled.

:rf.event/run-end's :rf.event/elapsed-ms is the HANDLER-BODY duration, not the run duration. Although the trace fires at run tail, the :rf.event/elapsed-ms tag it carries is measured around the interceptor chain only (run-chain, captured before commit-and-flow!) — so the Trace panel's DURATION column shows the time the handler body itself took, distinct from the whole run-start → run-end wall-clock (which also covers the :db install, the flow walk, the :fx walk, and the resulting sub recomputes + view re-renders). A consumer wanting the whole-run latency reads it off the always-on event-emit record's :elapsed-ms (per §Event-emit listener) or subtracts the run-start / run-end :time stamps; :rf.event/elapsed-ms answers the narrower "how long did the handler take?" question the per-op DURATION column poses. The tag rides interop/debug-enabled? so production DCEs it.

Throw variant — the event aborts at the commit boundary. A flow throw is a pre-install throw, so the event aborts before the :db install (the atomicity contract — per 013 §Failure semantics and 002 §Drain-loop pseudocode). The canonical sequence truncates: the :rf.flow/* phase ends at :rf.flow/failed, the router emits the run-level :rf.error/flow-eval-exception, and the run STOPS — NO :rf.event/db-changed, no :rf.sub/run, no :rf.fx/*:

:rf.event/run-start
  … handler body + the rest of the :after chain run …
:rf.event/db-pending                    ;; t1 — STILL fires when the
                                        ;; handler returned :db; it ran BEFORE the
                                        ;; throw. The trace records what the
                                        ;; handler tried to write; the install
                                        ;; never happened.
:rf.flow/computed                       ;; per prior flow that ran (its WRITE is
                                        ;; discarded — the trace records the run only)
:rf.flow/failed                         ;; the throwing flow
:rf.error/flow-eval-exception           ;; run-level error (always-on substrate)
:rf.event/run-end                       ;; run-tail — fires LAST (the trailer is
                                        ;; emitted unconditionally after the aborted
                                        ;; commit, with :outcome :flow-error)
;; — NO :rf.event/db-pending-post-flow, NO :rf.event/db-changed, NO
;;   :rf.sub/run, NO :rf.fx/* (the event aborted before the deferred :db
;;   install — t2 fires only on a successful post-flow path) —

:rf.event/db-changed does NOT fire because the pending :db effect was discarded (no install, app-db unchanged, no partial commit); :rf.fx/handled does NOT fire because :fx is the post-install stage and the event aborted before it. This is the same truncated signature every other pre-install throw produces — a handler throw or an interceptor-:after throw emits :rf.error/handler-exception and then the :rf.event/run-end run-tail trailer, with no :rf.event/db-changed and no :rf.fx/handled. (As on the clean path, :rf.event/run-end is the last trace — the error event precedes the trailer.)

Schema-rejection variant — the candidate is rejected at the commit boundary (rf2-uhk9ko). Candidate app-db / machine-data schema validation runs over the COMPLETE candidate frame transition BEFORE the single install. On a failure the candidate is REJECTED — never installed — and the canonical sequence truncates at the commit boundary. This whole variant is a development-build sequence: the candidate validator is dev-only per 010 §Production builds, so a release build never produces it — the violating candidate installs and the dispatch follows the clean path with :outcome :ok (rf2-bkvu5).

:rf.event/run-start
  … handler body + the rest of the :after chain run …
:rf.event/db-pending                    ;; t1 — the handler returned :db
:rf.flow/computed                       ;; per flow that ran (its write rides the
                                        ;; candidate, which is now discarded)
:rf.event/db-pending-post-flow          ;; t2 — when flows transformed the value
:rf.error/schema-validation-failure     ;; one per failing entry, :rollback? true
                                        ;; (= transaction REJECTED; every violation
                                        ;; surfaces — no first-failure short-circuit)
:rf.event/run-end                       ;; run-tail — fires LAST, with the always-on
                                        ;; event-emit record's :outcome :rolled-back
                                        ;; (dev builds only — the validator that
                                        ;;  produces this outcome is elided in
                                        ;;  production; see above)
;; — NO :rf.event/db-changed, NO :rf.event/db-noop, NO
;;   :rf.event/frame-state-changed, NO :rf.sub/run, NO :rf.fx/* — the
;;   candidate never installed; there is no :rf.trace/phase :rollback
;;   re-emit (the retired install-then-rollback pair is gone) —

The rejection differs from the flow-throw variant only in its ERROR op (:rf.error/schema-validation-failure — the flow/handler computed cleanly, the VALUE failed its declared shape — vs :rf.error/flow-eval-exception); both truncate with ZERO change traces. Synchronous observers can never see the invalid candidate: a trace listener reading the frame during the failure emit reads the PRE-handler value, a container watch receives zero callbacks, and no substrate subscriber (Reagent reaction, useSyncExternalStore snapshot) is notified — the container is simply never written.

Emit-gate summary — which emits ride which substrate

Tooling authors (Xray, Story, re-frame-10x, off-box monitors) need to know for every emit in the canonical sequence: under what condition does the emit fire, and which substrate carries it — the always-on event-emit / error stream available in production builds, or the dev-only trace stream gated by re-frame.interop/debug-enabled? (alias of goog.DEBUG; DCE'd in :advanced builds). The table below pins both contracts for the per-event emits between :run-start and :run-end:

:operation Substrate Gate — fires when
:rf.event/dispatched always-on every dispatch envelope (precedes the drain; one per dispatch)
:rf.event/run-start always-on every event the handler chain begins on
:rf.cofx/run dev-only per coeffect supplier that ran to success during context assembly
:rf.cofx/generated dev-only a recordable generator ran at processing-start to fill a declared-absent fact; carries fact-name + supplier id + the produced value
:rf.event/db-pending (t1) dev-only the handler returned a :db slot (suppressed otherwise)
:rf.flow/computed dev-only per flow whose dirty-check observed an input value-difference
:rf.flow/skip dev-only per flow whose dirty-check found inputs =-equal to the previous run
:rf.flow/failed dev-only per flow whose :output threw
:rf.event/db-pending-post-flow (t2) dev-only flows transformed the pending value ((not (identical? new-db pending-db))); suppressed when t1 == t2
:rf.event/db-changed dev-only the flow-augmented :db installed into app-dbAPP-DB-ONLY; never fires for a runtime-only commit
:rf.event/db-noop dev-only a :db effect was present but app-db did NOT change (handler returned an unchanged db; identical?-noop skipped the write) — APP-DB-ONLY; the complement of db-changed (exactly one fires for a :db-bearing commit)
:rf.event/frame-state-changed dev-only EITHER partition changed; carries :tags :rf.event/partitions (a subset of #{:app-db :runtime-db}) naming which partition(s) the commit touched
:rf.sub/run / :rf.sub/skip dev-only per sub on the cache that depends on a changed input
:rf.fx/handled dev-only per :fx entry, after the deferred install
:rf.fx/do-fx dev-only terminating :fx-walk marker, after all per-entry :rf.fx/handled emits
:rf.view/render / :rf.view/rendered dev-only per reactive view re-rendered on the new db
:rf.error/* (run-level) always-on every run error — :rf.error/handler-exception / :rf.error/coeffect-exception / :rf.error/interceptor-exception / :rf.error/flow-eval-exception / fx errors (each attributed to its true failing component)
:rf.event/run-end always-on run-tail; fires LAST after commit-and-flow!, on both clean and aborted paths

Always-on emits ride the production-available event-emit / error-emit channels per §Production debugging: they survive :advanced DCE and feed the production listener surfaces (event-emit, error-emit, error-projection). Wire-egress to off-box monitors (Sentry / Rollbar / etc.) only sees these.

Dev-only emits are wrapped in the interop/debug-enabled? gate per §Production builds: zero overhead, zero code; they vanish entirely from :advanced bundles. Xray / Story / re-frame-10x consume them in dev builds where the gate is true. A production :advanced build emits exactly the always-on rows above and nothing else.

The always-on label on :rf.event/run-start / :rf.event/run-end names the flat event-emit RECORD, not the trace event's :tags. Two distinct substrates carry run-start/run-end information. The always-on event-emit substrate fans out one fixed-shape, flat record per processed event ({:event :event-id :frame :time :outcome :elapsed-ms}, per §What IS available in production) — that record is NOT a :tags-bearing trace event and is NOT extensible by adding a tag. The separate dev-only trace-stream emit (re-frame.trace/emit! :rf.event :rf.event/run-start {…tags…}) carries the :tags bag documented in §:tags is the open-ended bag and rides the interop/debug-enabled? gate inside emit!, so the whole emit — and every tag on it — DCEs in :advanced. Consequently a :tags key added to the run-start trace emit (e.g. :rf.interceptor/override-summary) is dev-only automatically: it never reaches the always-on flat record and never survives production elision. The always-on label gates the flat record's firing condition; it does not promote the trace event's tags to production.

Flow trace events

Five trace events constitute the flow lifecycle stream (per 013 §Flow tracing). All five carry :op-type :flow; consumers filter by :op-type to subscribe to the whole stream and branch on :operation to discriminate. Every event's :tags carries :flow-id and :frame so tools can attribute and route per-frame.

:operation When it fires :tags payload (in addition to :flow-id and :frame)
:rf.flow/registered After reg-flow (or :rf.fx/reg-flow) successfully registers a flow against a frame, including post-cycle-detection. :inputs (the flow's input paths), :path (the flow's output path)
:rf.flow/computed A flow's :output fn ran and the result was assoc-in'd into the pending :db effect at :path (the outermost-:after flow transform — before :db installs). Fires only when the dirty-check observed an input value-difference, and BEFORE :rf.event/db-changed (per §Canonical per-event trace sequence). Note: the trace records that the :output ran — if a LATER flow in the same drain throws, this write is discarded by the event abort (the trace is observational, not a commit guarantee). :input-values (raw values read from the input paths), :result (the new output value), :path, :before (the value at :path immediately before this write), :elapsed-ms (the dev-only wall-clock of the :output recompute — the per-op DURATION the Trace panel reads)
:rf.flow/skip The dirty-check found inputs =-equal to the previous run; the recompute was suppressed (per 013 §Dirty-check semantics and value-equal recompute suppression). :reason (currently :inputs-value-equal; the keyword is open for future skip reasons), :input-paths-unchanged (the flow's input db-paths whose values were stable — for a value-equal skip every input is stable by definition, so this names the full input set; consumed by the cascade-DAG aggregator).
:rf.flow/cleared After clear-flow (or :rf.fx/clear-flow) removes the flow from the per-frame registry and dissoc-in's its output path. :path (the path that was vacated)
:rf.flow/failed The flow's :output fn threw during recompute. The exception is re-thrown after this trace fires so the router's outer catch emits the cascade-level :rf.error/flow-eval-exception (per §Error contract); tools see the per-flow detail here and the cascade abort there. Per 013 §Failure semantics (the atomicity contract), a flow throw is a pre-install throw: the event ABORTS — the pending :db effect is discarded (no install, app-db unchanged, no :rf.event/db-changed), :fx is skipped, and last-inputs is rolled back so every flow re-attempts next drain. No partial commit — neither the handler's :db nor any prior flow's write lands. :exception-message (the thrown exception's plain message string), :exception-data (the ex-info ex-data map, or nil) — a structured, EDN-safe exception summary, NOT a raw Throwable: a bare Throwable is not serializable and would bypass the central trace-projection chokepoint. The shape matches every other :rf.error/* category (:exception-message; :exception-data mirrors the machine path). project-trace-event redacts :exception-data to :rf/redacted (and stamps top-level :sensitive? true) fail-closed when the flow's frame declares any sensitive classification — the flows analogue of project-machine-error-tags. The live Throwable rides only the cascade-level :rf.error/flow-eval-exception :exception slot (for stack-trace introspection on that error row). Plus :inputs (the input values that were read just before the throw, each already routed through the wire-elision walker — runtime-qualified inputs normalized to their stripped declaration path)

Payload-shape decisions:

  • :input-values / :result are the actual values, not hashes. The trace surface is dev-only (per §Production builds) and downstream tools — Xray's flow panel, custom dashboards — display the values. Hashing would force consumers to consult an out-of-band side table; raw values keep the stream self-contained.
  • :rf.flow/skip carries :reason :inputs-value-equal rather than always being implicit. The keyword is the future extension point if additional skip reasons land (e.g. flow disabled mid-walk, frame in restore).
  • :rf.flow/failed re-throws so run-level error-handling (Spec 009 §Error contract's :rf.error/flow-eval-exception) still fires; the per-flow :rf.flow/failed adds the per-flow attribution.

Pair-shaped tools and Xray's flow panel filter op-type :flow (per Tool-Pair §How AI tools attach) to subscribe to the whole stream.

Subscription / consumption

re-frame2's trace API uses event-at-a-time delivery: every registered listener is invoked once per emitted trace event, one event at a time, never concurrently with itself. There is no batching, debounce window, or background delivery loop — every event a public operation produces is delivered before that operation returns. Listener-invocation order is not contract; tools must not depend on the order in which sibling listeners receive a given event. Listeners SHOULD do minimal work in the callback (queue, append to a buffer, mark a flag) and defer expensive work to a separate timer or animation frame they own.

Trace listeners are observers, not participants. When a listener is invoked depends on who emitted the event, and the runtime distinguishes two tiers:

  • Public emits — an emit raised outside a frame drain, including one an application or tool raises itself — fan out synchronously, on the emitting call stack, through callback completion.
  • Internal, drain-owned emits — those the runtime raises while it owns a frame's drain lock — are delivered at the post-drain boundary. They are queued during the drain and fanned out on the way out: serialized process-wide, in emission order, and completing before the enclosing dispatch / drain call returns. A listener therefore never observes a partially settled state, and two frames draining concurrently can never enter the same listener at once.

The reason is a hard invariant: arbitrary listener code is never invoked, nor awaited, while the framework owns any frame drain lock. A listener that dispatched, destroyed a frame, or blocked would otherwise be running inside the runtime's own critical section — which on a multi-threaded host admits overlapping delivery across independent frames, and lock cycles through listener-authored work.

The corollary is a contract boundary tools must respect: a listener's side effects cannot influence the drain that produced the event. A mutation performed in a callback takes effect when that callback runs, which for a drain-owned emit is after the drain has finished — exactly as if it had been written on the line following dispatch-sync. Code that depends on a listener body altering the outcome of the in-flight operation is out of contract on every platform. This holds uniformly across hosts: a single-threaded host also defers drain-owned delivery to the post-drain boundary — queueing during the drain, flushing once the lock is released — rather than delivering inline, so no listener observes a partially settled state, or can influence the in-flight drain, on any platform. Inline delivery is not a promise a portable tool may read intra-drain observation or influence into.

Observation itself is unaffected: every event is still delivered, exactly once, in emission order, before the operation that produced it returns.

The listener API

The listener API is one stream-parameterized verb across the four pure observation streams. The differentiator is data — which stream — so it rides in a leading required stream keyword; the verb replaced the former per-channel register-(trace|event|error|epoch)-listener! pairs. The closed stream vocabulary is :trace / :events / :errors / :epoch; an unknown stream throws :rf.error/unknown-listener-stream (no bare trace default, no compatibility aliases). The raw trace stream is :trace:

(rf/register-listener! :trace key callback-fn)
;; Subscribes callback-fn to receive every trace event as it is emitted.
;; Same key replaces any previously-registered listener under that key on
;; the same stream. Returns the key.
;;
;; Arguments:
;;   stream      — :trace (dev-only raw trace events; see :events / :errors /
;;                 :epoch below for the other streams)
;;   key         — any comparable value identifying the listener
;;                 (replaces same-key registration)
;;   callback-fn — invoked with one trace event per call.
;;                 Signature: (fn [trace-event] ...)

(rf/unregister-listener! :trace key)
;; Unsubscribes the listener registered under key on the stream. Returns nil.

;; There is deliberately NO facade `clear-listeners!` verb (retired in
;; API-shrink #4). Dropping every listener on a stream is a test-isolation
;; concern owned by the fixture layer:
;; `re-frame.test-support/make-reset-runtime-fixture` restores a clean listener
;; registry between tests via the lower-level sinks
;; (`re-frame.trace.tooling/clear-listeners!` and its :events / :errors / :epoch
;; siblings). Ordinary application code SHOULD use `unregister-listener!` per
;; key. The same dev-only elision rules apply to the `:trace` stream (production
;; builds drop the registry entirely); the always-on `:events` / `:errors`
;; streams survive elision.

The other three streams take the same verb. :events and :errors are the always-on corpus-wide integration hooks (per §What IS available in production); :epoch is the assembled-epoch listener (below), which no-ops returning nil when the optional epoch artefact is absent:

(rf/register-listener! :events id listener-fn)  ;; always-on event-emit record
(rf/register-listener! :errors id listener-fn)  ;; always-on error-emit record
(rf/register-listener! :epoch  key callback-fn) ;; assembled :rf/epoch-record — a publication, not a per-event count

Conventional keys: :my-app/recorder, :my-app/timing-monitor, etc.

Re-registration semantics. register-listener! called with a key already in the registry replaces the previous callback atomically — the swap from old to new happens between two emits, never mid-emit. No trace event is emitted for the replacement (the listener registry is itself dev-only metadata; mutating it does not feed the trace stream); no events delivered to the previous callback are re-delivered to the new one, and no events emitted after the swap are dropped. Hot-reload tools that re-register their listener on every code reload see exactly one stream of events with the swap point invisible to the runtime. The same semantics apply to register-epoch-listener! re-registration under an existing key.

Worked example. A minimal recorder that prints every error trace to the console. The (when-not (:sensitive? trace-event) …) guard is the load-bearing line: listeners receive every event regardless of :sensitive? (per §Listener filtering semantics), so any listener body that egresses a payload off-box — and println to a console that may be captured into a log IS an off-box sink — MUST gate on the flag. Teaching the safe shape here, at the copy site, is deliberate: the worked example is the first thing a tool author copies (per the egress-ergonomics ruling).

(rf/register-listener! :trace
  :my-app/error-logger
  (fn [trace-event]
    (when (and (= :error (:op-type trace-event))
               (not (:sensitive? trace-event)))  ;; gate any off-box egress on :sensitive?
      (println (:operation trace-event)
               (-> trace-event :tags :reason)))))

The same pattern with the :epoch stream to log one assembled epoch per event:

(rf/register-listener! :epoch
  :my-app/run-logger
  (fn [epoch-record]
    (println (:event-id epoch-record)
             "→" (count (:effects epoch-record)) "fx"
             "/" (count (:sub-runs epoch-record)) "sub-runs")))

register-epoch-listener! — assembled-epoch listener

Alongside the raw :trace stream, the :epoch stream delivers a parallel assembled-epoch listener feed. Where :trace delivers each raw event as it is emitted, :epoch delivers one fully-assembled :rf/epoch-record (per Spec-Schemas) per dequeued event — one per epoch (per 002 §Drain versus event). It routes through the optional day8/re-frame2-epoch artefact and no-ops (returns nil) when the artefact is absent. The app-facing route is the :epoch stream of the one listener verb — (rf/register-listener! :epoch key f) / (rf/unregister-listener! :epoch key), exactly like :trace / :events / :errors; the dedicated rf/register-epoch-listener! / rf/unregister-epoch-listener! facade pair was retired in API-shrink #4, and the re-frame.epoch/register-epoch-listener! native form remains the implementation the stream delegates to (per Tool-Pair §Time-travel):

(rf/register-listener! :epoch key callback-fn)
;; Subscribes callback-fn to receive assembled epoch records.
;;
;; Arguments:
;;   stream      — :epoch
;;   key         — any comparable value identifying the listener
;;                 (replaces same-key registration)
;;   callback-fn — invoked with one :rf/epoch-record per publication: one per
;;                 dequeued event, and again when a post-settle back-fill
;;                 re-publishes the same epoch (reconcile on [frame epoch-id];
;;                 see the invocation rules below). Signature: (fn [epoch-record] ...)
;;
;; The record is the same shape the runtime appends to (rf/epoch-history frame-id):
;; assembled :event-id / :trigger-event / :db-before / :db-after, plus the structured
;; :sub-runs / :renders / :effects projections derived from the run's traces.

(rf/unregister-listener! :epoch key)
;; Unsubscribes the listener registered under key on the :epoch stream.

Invocation rules (mirrors register-listener!):

  • Per dequeued event, not per drain — and a publication, not a counter. An ordinary event's full pipeline run (and, for a machine event, its entire macrostep) is one epoch (per 002 §Drain versus event). A drain that settles a parent event and the child it :fx-dispatched publishes two records — one per event — not one for the drain. A machine's :raise sub-events and :always microsteps do not publish: they ride inside the triggering event's epoch (per 005 §Drain semantics). But the callback is a record-publication notification, not a once-per-event clock: the SAME epoch re-publishes — carrying the same :epoch-id — when a post-settle render / sub-run / unmount back-fills into that already-settled epoch (a corrected record); the synthetic :rf.epoch/db-replaced and :halted-depth records publish with no dequeued event at all, while the terminal :halted-destroy closes an already-started event interrupted mid-drain by frame destruction (see Spec-Schemas §:rf/epoch-record §Outcomes). Because the listener is process-global while :epoch-id is unique only within one frame, reconcile on the pair [(:frame record) (:epoch-id record)] — cache under that key and REPLACE on re-publication rather than counting callbacks as events; :outcome is record STATE (:ok / :halted-depth / :halted-destroy), not identity. A dequeued event rejected before it runs (no handler) publishes nothing.
  • After commit. The callback receives a fully-formed record with :db-after, :sub-runs, :renders, :effects, and any optional :trace-events populated. An ordinary, :halted-depth, or :rf.epoch/db-replaced record has been appended to the frame's epoch-history ring buffer when depth permits — the runtime attempts the append before it publishes, but a depth-0 ring retains nothing while the record still publishes; the terminal :halted-destroy record is delivered to listeners only and is never appended (see Halted runs below).
  • Exception isolation. An exception thrown by an epoch callback is caught and does not propagate. One broken epoch listener cannot break the app or block other listeners (raw-trace or epoch).
  • Listener ordering is not contract.
  • Production elision. The epoch listener machinery is gated on the same re-frame.interop/debug-enabled? flag (alias of goog.DEBUG) as the raw-trace surface — see §Production builds. Production builds elide registration, dispatch, and the epoch ring-buffer all together.

Halted runs. Listeners receive epoch records for halted drains as well as clean settles. :outcome on the record discriminates — :ok, :halted-depth, or :halted-destroy. The partial record carries whatever the runtime captured up to the halt point: :trace-events, :sub-runs, :renders, :effects reflect the run-so-far, and :halt-reason carries a structured descriptor of why the drain halted. Retention differs by outcome: a :halted-depth record is ring-retained when depth permits (it lands in epoch-history like an ordinary record), while the terminal :halted-destroy — an already-started event interrupted by frame destruction — is delivered to listeners only and never retained, because the destroyed frame's history is already gone (a late :halted-destroy is HISTORICAL for the destroyed incarnation; per Tool-Pair §the late-record consumer rule, never splice it into a same-id successor's ring). This is the devtools surface for failing runs — Xray's epoch panel, re-frame2-pair's cascade-of, post-mortem dashboards: all route off the same listener, and :outcome lets them render the failure with the right shape. Consumers that only care about successful drains filter on (= :ok (:outcome record)) at the top of their callback. restore-epoch! refuses non-:ok records — see Spec-Schemas §:rf/epoch-record §Outcomes.

When to use which. register-listener! is the right shape for tools that need fine-grained per-event activity (custom recorders, error-monitor forwarders, timing aggregators). register-epoch-listener! is the right shape for tools that route diagnostics off "what just happened in this run" — pair-shaped tools, post-mortem dashboards, anything that wants the structured :sub-runs / :renders / :effects projection without re-folding the raw trace stream.

The two listener APIs are independent: tools may register either, both, or neither. They share the production-elision gate but have separate listener registries; no listener of one kind can interfere with the other.

Event-bundle projection (group-by-event / domino-bucket)

The grouping fn is group-by-event, and its per-run output record is an event bundle — one record per pipeline run (one dequeued event; the event-* noun family, per Conventions §The event-* noun family and Conventions §Event-pipeline vocabulary).

The raw trace stream is event-at-a-time; pair-shaped UIs (the Story trace panel, the Xray Epoch panel, re-frame2-pair's cascade-of) all want the per-run slice of the stream — one record per pipeline run (one dequeued event) with the event vector, handler emit, fx-map emit, effects, sub-runs, and renders already split into named slots. (First-contact mnemonic: the run's per-event half is the "six dominoes".) The framework ships that projection as a pure-data function in re-frame.trace.projection, re-exported from re-frame.core:

(rf/group-by-event trace-events)
;; -> [{:dispatch-id        <id-or-:ungrouped>
;;      :parent-dispatch-id <id or nil>      ;; causal-parent link from
;;                                           ;;   :rf.trace/parent-dispatch-id
;;                                           ;;   on the :rf.event/dispatched trace
;;                                           ;;   — the run that emitted this
;;                                           ;;   dispatch (an :fx :dispatch parent,
;;                                           ;;   a machine-internal dispatch, etc.);
;;                                           ;;   nil for a root / external dispatch
;;                                           ;;  
;;      :frame              <frame-id or nil> ;; the run's host frame, per
;;                                           ;;   002-Frames §Routing the dispatch
;;                                           ;;   envelope; nil for an :ungrouped
;;                                           ;;   slot or a run with no frame
;;                                           ;;   tag in any of its events
;;      :event              <event-vector | nil> ;; from :rf.event/dispatched :tags
;;                                              ;;   (the slim, common-case form)
;;      :dispatched         <trace-event | nil>  ;; the FULL :rf.event/dispatched
;;                                              ;;   trace event — preserves
;;                                              ;;   top-level hoisted slots
;;                                              ;;   (:rf.trace/call-site, :source,
;;                                              ;;   :origin) per 
;;      :handler            <trace-event | nil>  ;; :rf.event/run-start | :rf.event/run-end
;;                                              ;;   (last wins — typically :run-end)
;;      :fx                 <trace-event | nil>  ;; :rf.fx/do-fx
;;      :effects            [<trace-event> ...]  ;; :op-type :rf.fx — :rf.fx/handled,
;;                                              ;;   override-applied,
;;                                              ;;   skipped-on-platform
;;      :subs               [<trace-event> ...]  ;; :rf.sub/run + :rf.sub/skip
;;                                              ;;   + :rf.sub/create
;;      :renders            [<trace-event> ...]  ;; :op-type :rf.view /
;;                                              ;;   :operation :rf.view/render
;;      :other              [<trace-event> ...]} ;; errors, warnings, machines,
;;                                              ;;   frames, flows, registry,
;;                                              ;;   anything outside the six dominoes
;;     ...]

The slot order above is the projection's emit order. Each slot's wire-shape and semantics are documented alongside the per-frame ring's read surface at Tool-Pair.md §Reading the per-frame trace ring — the same event-bundle shape (rf/trace-buffer frame-id) returns by default, since the ring pre-computes this projection per run. (Pair tools assembling the same projection from an epoch-history record route off :rf/epoch-record's :sub-runs / :renders / :effects slots per Spec-Schemas §:rf/epoch-record; those structured slots derive from the same :trace-events stream this projection groups.)

(The event-bundle slot names above — :dispatch-id, :parent-dispatch-id, :frame, :event, :dispatched, :handler, … — are the projection's own output shape, distinct from the trace :tags keys it groups by.) Events without a :rf.trace/dispatch-id tag (registry-time emits, frame lifecycle, REPL evals outside a drain) collect under the projection's :dispatch-id :ungrouped slot. The returned vector is sorted by the lowest :id in each run so consumers render runs in emission order. The projection is pure data — JVM and CLJS run the same code; tools wiring up post-mortem renders against (rf/trace-buffer) get the same output shape as live consumers reading from a register-listener! listener.

(rf/domino-bucket trace-event) is the underlying classifier — returns one of #{:event :handler :fx :effect :sub :render :other}. Tools that want custom rollups can call it directly per event and skip group-by-event.

Per (per-event correlation) the projection is robust against errors, fx, sub-runs, and renders that fire inside an event's run even though they aren't :rf.event/dispatched — every such event carries :tags :rf.trace/dispatch-id so they group into that event's bundle automatically.

The projection is additive: new :op-type values that don't fit a domino slot flow through :other without breaking existing consumers.

Listener invocation rules

  • Synchronous, event-at-a-time. Every registered listener is invoked once per emitted trace event, on the runtime's emit call stack. There is no batching, debounce window, or background delivery loop. Listeners SHOULD return quickly; expensive work belongs on a tool-owned timer or rAF.
  • Events arrive in emission order. Each listener sees trace events in the order the runtime fired them. (This is about per-listener event order, not order across listeners — see the next rule.)
  • Reentrant emits complete synchronously and in order. A listener may itself emit — a dispatch, a flow registration, a frame create/destroy all emit. Such a nested emit! obeys BOTH delivery laws: every listener still sees the outer event before the nested one, AND the nested emit! returns only once its event has reached every listener (the synchronous-completion rule under §Emitting trace events applies to nested emits too). Concretely, when a listener handling event A emits B, the runtime finishes delivering A to the remaining listeners first, then delivers B to all listeners, before the nested emit! returns — so a listener may emit and then observe that every other listener has already processed that emission, and no listener ever sees B before A.
  • Listener-invocation order is not contract. When multiple listeners are registered, the order in which sibling listeners receive a given event is unspecified. Tools must not depend on order; each listener receives the same event independently. The same rule applies to register-epoch-listener! callbacks.
  • Exception isolation. An exception thrown by a listener is caught and does not propagate to the framework or other listeners. One broken tool can't break the app or block other tools. The caught exception is logged via re-frame.interop/log-error (or the host equivalent) and otherwise discarded; the runtime does NOT emit a self-referential trace event for the failed listener (which would risk a re-entrant trace-emit storm). The same handling applies to exceptions thrown by an register-epoch-listener! callback.
  • No buffering between listeners and the runtime. The framework does not retain a delivery buffer; the per-frame trace rings described next are independent and exist for late-attaching tools.

Per-frame trace rings (event-keyed, dev-only)

In dev builds, each frame owns its own trace ring alongside the synchronous-delivery path. The ring's unit of retention is the event (one dispatched event = one pipeline run = one slot, keyed by :rf.trace/dispatch-id), not the individual trace event. This lets pair-shaped AI tools, REPL-attached debuggers, and post-mortem dashboards read recent activity from the frame they care about without having to be registered as a register-listener! listener at the time the events fire — and without their reads being polluted by trace volume from other frames.

This is the per-frame model joining what frames already own — app-db, epoch-history, the sub-cache reactive context, fx/cofx routing — extended one rung further: the trace surface, too, is partitioned by frame, with event-keyed eviction.

Event-keyed, per-frame

Each frame owns an independent event-keyed trace ring; the unit of retention is the EVENT (one dequeued event = one pipeline run = one slot). Two structural properties drive this shape (a process-global, raw-trace-count ring has neither):

  1. Frame mismatch. Tools that mount in their own frame (Xray, re-frame2-pair-mcp, story-mcp, any future inspector) emit trace events whose volume swamps every other frame's view of the same buffer. Sub-recompute storms in an inspector's reactive substrate could evict every application event from the global ring within a few microtask cycles.
  2. Eviction unit mismatch. Trace volume per run is wildly uneven — a single click run through a machine entry can emit hundreds of :rf.sub/skip short-circuit events alongside the handful of :run-start / :run-end / :db-changed / :fx/handled "real" events. A flat ring sized by trace-event count means one chatty run can evict every earlier run's traces (including its own real events) before a consumer reads them.

The fix is two compositional structural changes:

  • Per-frame ring. Every frame owns an independent ring; emit-site routing places each trace event in the frame whose reactive chain or event pipeline run is running (see §Emit-site routing below). Each frame's ring is sized independently via frame metadata. Frames are isolated (per 002 §Per-frame and trace surface); the trace surface matches.
  • Event-keyed eviction. The ring slot is the event. One :rf.trace/dispatch-id consumes one slot regardless of how many trace events that run emitted. When event #N+1 arrives, the oldest event (and every trace event ever emitted under it) is dropped as a unit. A run with 5 traces and a run with 50,000 traces each occupy one slot.

Retention contract — the single knob :rf.trace/events-retained

Retained unit — the one known misread. The retained unit is one slot per EVENT (one dequeued event = one pipeline run = one epoch), regardless of how many trace events that run emitted — a run emitting 50,000 trace events still consumes exactly one slot. events (dequeued events / traversals) vs trace-events (individual instrumentation emissions) is the one known misread. The knob is the event-* noun family (per Conventions §The event-* noun family).

API Default Notes
:rf.trace/events-retained (frame metadata) 50 Per-frame override. Sets the number of retained slots — one slot per event (per dequeued event / pipeline run) — in this frame's ring. 0 disables the ring (synchronous delivery still works).
(rf/configure! {:trace-buffer {:events-retained N}}) applies to :rf/default Process-default tuner — applied to frames that did not set per-frame metadata.

This is the entire retention surface. There is no per-run trace cap, no per-trace-type cap (no :skip-specific budget, no :sub-specific budget), no per-frame override beyond the event count, no other knobs. Operator-facing tuning is one number: how many events (pipeline runs) does this frame keep?

Rationale:

  • Matches operator mental model. Operators think "I want to see my last 50 events"; storage matches the unit they think in.
  • Predictable retention. A chatty sub never silently evicts the run the operator cares about. Only newer events push older events out.
  • Naturally bounds skip noise. Sub-skip emits stay associated with their parent run; the burst lives or dies as a unit with the rest of that run's traces.
  • Aligns with epoch-history. epoch-history is already retained per-event (one assembled :rf/epoch-record per dispatch). The trace ring becomes "the diagnostic detail of the same 50 epochs" — consistent retention semantic across the per-frame model.
  • No per-trace-type tuning needed. Eviction is event-driven; trace volume per run is incidental. A run with 50K traces is a slot like any other.

There is no worst-case memory bound on a single run's trace volume. If a run emits 100K traces, that event's slot holds 100K traces until evicted. The operator's tradeoff is in the event-count knob, not in trace-volume bounding. (Apps whose trace-volume budget genuinely matters tune the per-frame event-count downward.)

Retention boundary — structural terminal facts are delivered, never retained

One class of trace fact bypasses every ring. When an obsolete frame incarnation must report a lifecycle fact after its registry slot may already belong to a same-id successor, the runtime delivers it through retentionless structural delivery (re-frame.trace/call-with-structural-delivery, rf2-vxgfnd.244). Such a terminal fact still streams live to every registered listener exactly once, but no per-frame ring retains it — not the destroyed incarnation's ring, and not the successor's. Retention is the only thing this path gates (deliver!'s retain? input); listener fan-out is unconditional, so a tool listening at teardown still sees the fact.

The boundary exists because retention is keyed by the bare frame id, and predecessor and successor share it: a terminal fact from an obsolete incarnation A carries the id a same-id successor B now names, so ring-pushing it would leak A's evidence into B's ring. The rule is therefore absolute — no current or future frame's ring ever retains a predecessor incarnation's terminal facts. Post-mortem ring reads never surface a dead incarnation's tail; the live listener stream is the sole channel for it.

Emit-site routing (per-frame)

Trace events that ride inside an in-flight run are routed to the frame whose router is processing that event. Concretely:

  • The runtime carries the in-flight frame's id through re-frame.trace/*handler-scope* (alongside :rf.trace/dispatch-id, per §Handler-scope). emit! reads the slot to look up the destination ring.
  • For trace events emitted from inside a frame's drain loop, fx-pass, or epoch-settle path (:rf.event/dispatched, :rf.event/run-start, :rf.event/db-changed, :rf.fx/handled, :rf.machine/*, :rf.flow/*, every :rf.error/* thrown inside the run), the destination frame is the one whose router is running — the same frame the trace event's :frame tag already carries.
  • For trace events emitted from inside a sub's reactive recompute (:rf.sub/run, :rf.sub/skip), the destination frame is the one whose reactive chain is running — Spec 006's per-frame sub-cache already runs each sub against its own frame, so the per-frame ring inherits that boundary. Cross-frame sub composition is an anti-pattern (per Spec 002); subs do not reach across frames, so trace events from a frame's reactive substrate stick to that frame's ring.
  • For trace events emitted from inside a view render (:rf.view/render, :rf.view/rendered, :rf.view/unmounted), the destination frame is the frame the view is bound to — carried on :frame and on the in-flight handler scope set up by the registered-view wrapper.

Cross-frame runs — merge by :dispatch-id

Frames are isolation boundaries, not communicating agents — re-frame2 does not support one frame dispatching directly into another; the run of a dispatched event lives in exactly one frame. But several distinct runs may share the same :dispatch-id family when consumers correlate across rings (e.g. a pair-mcp client watching every frame, an off-box trace dashboard merging runs from a multi-frame story session).

The contract: each frame retains the traces of runs that EXECUTED IN IT, keyed by their own :rf.trace/dispatch-id. Cross-frame consumers (pair-mcp, monitoring tools, the Xray off-box rendering tier) merge by :dispatch-id across rings to reconstruct a multi-frame timeline. The framework does not maintain a process-global "cross-frame index"; the merge is the consumer's job, and it is cheap because each ring already keys by :dispatch-id.

Frameless trace events — live stream only (no ring storage)

Some trace events ride outside any in-flight run — registration-time :rf.registry/handler-registered / :rf.registry/handler-replaced / :rf.registry/handler-cleared emits, :rf.frame/created / :rf.frame/destroyed lifecycle, REPL evals that don't dispatch, schema-validation warnings emitted at namespace load. These events have no :rf.trace/dispatch-id and no destination frame.

B3+B4 ruling: frameless events SKIP the rings entirely. They stream live to registered listeners (register-listener!) only; they are NEVER retained in any ring.

  • No frameless ring is allocated. There is no :rf/global ring, no shared "uncorrelated bucket", no fallback slot for frameless emits. The ring exclusively holds events.
  • The live stream is the egress channel. Tools that care about registration drift, hot-reload diagnostics, or REPL-eval emits subscribe to the live stream via register-listener! and filter by :op-type / :operation themselves. Per §The listener API, the live stream is synchronous and event-at-a-time.
  • The registry is the source of truth for "what's registered right now". Tools reading "the current set of registered handlers / frames / routes" consult (rf/registrations kind) (per 001 §The query API) — not by scanning ring contents. This collapses a class of duplication: the ring need not double-bookkeep registration state.
Hot-reload dedup — re-emits suppressed by shape

Hot-reload at scale would otherwise leak: every file save re-fires the entire ns-load worth of reg-* traces, and retained rings would fill with re-registration noise.

Hot-reload re-emits are deduplicated by shape at the emit site. The registrar tracks the last-emitted shape per (kind, id) pair and suppresses re-emits whose shape is unchanged. The emitter compares:

  • The registration's handler-fn identity (or source-coord, per the dedup mechanism's choice).
  • The metadata-map's content (:doc, :schema, :interceptors, :tags, etc.).

Identical shape → re-emit suppressed; no trace event fires. Changed shape (a real edit to the handler or its metadata) → exactly one trace fires (:rf.registry/handler-replaced). Hot-reload of an unchanged file is a non-event for the trace bus.

The dedup applies symmetrically to register / replace / clear:

  • A register for an id with no prior emit always fires.
  • A replace whose new shape matches the last-emitted shape is suppressed.
  • A clear of an id that the dedup table thinks is already absent (e.g. a double-clear) is suppressed.

The dedup table is process-scoped, dev-only (it sits inside the same interop/debug-enabled? gate as the rest of the trace surface), and is implicitly cleared by re-frame.trace.tooling/clear-listeners! / test-runtime reset fixtures. The mechanism is per-(kind, id) — different ids hot-reload independently; the dedup of :user/login does not interfere with the dedup of :user/logout.

Together (B3 + B4): rings hold events exclusively; the live stream filters reload-noise via shape-dedup; the registry is the source of truth for "what's registered right now". Tools wanting "the current state of the world" read registrations; tools wanting "what changed in the last run" read the per-frame ring; tools wanting fine-grained event-by-event observation register a listener and receive the (post-dedup) live stream.

trace-buffer API — per-frame, event bundles by default

The query surface is per-frame, with a frame-id required argument. The default return shape is event bundles (one map per pipeline run / dequeued event with the run's :dispatch-id, its trace events, and the structured projection slots; the event-* noun family, per Conventions §The event-* noun family); the :flat opt-in returns raw trace events for callers that want the pre-grouping shape.

API Signature Notes
(rf/trace-buffer frame-id) (frame-id) → vector Returns the frame's event-bundle vector, oldest-first. Each entry is {:dispatch-id <id> :parent-dispatch-id <id or nil> :frame <frame-id or nil> :event <event-vector or nil> :dispatched <trace-event or nil> :handler :fx :effects :subs :renders :other :trace-events [...]} — the group-by-event-shape projection per §Event-bundle projection, pre-computed per run by the ring. Empty when no events have been recorded.
(rf/trace-buffer frame-id opts) (frame-id, opts) → vector Optional filter map (see §Filter vocabulary below). Filters compose AND-wise across bundle-level fields; absent key = no constraint. The :flat true opt returns raw trace events instead of event bundles (escape hatch — see below).
(rf/clear-trace-buffer! frame-id) (frame-id) → nil Empties the named frame's ring. Tooling uses this between sessions.
(rf/configure! {:trace-buffer {:events-retained N}}) (opts) → nil Process-default ring depth (applies to :rf/default and any frame that did not set per-frame metadata).

The :flat opt is the escape hatch for callers that want the pre-grouping raw stream:

(rf/trace-buffer :step-deck)
;; → [{:dispatch-id 17 :parent-dispatch-id nil :frame :step-deck
;;     :event [:user/click ...] :dispatched {...}
;;     :handler {...} :fx {...} :effects [...] :subs [...] :renders [...] :other [...]
;;     :trace-events [...]}
;;    {:dispatch-id 18 :parent-dispatch-id 17 :frame :step-deck
;;     :event [...] :dispatched {...} ...}
;;    ...]

(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 (event bundles) matches the storage unit; tools whose existing code shapes around the raw stream pass {:flat true} and re-fold via group-by-event themselves. (The :flat form is not a "polyfill of the old surface" — the storage is genuinely per-event, so even :flat reads the ring's per-run slots and flattens them; it is not reading a pre-grouping flat ring.)

Filter vocabulary

(rf/trace-buffer frame-id opts) recognises the following filter keys. All compose AND-wise; an absent key means "no constraint on that axis." Unrecognised keys are ignored (forward-compat: tools may probe new axes; missing support degrades to "no filter").

Key Type Semantics
:flat true Return raw trace events instead of event bundles. The other filter keys apply to events when :flat true, otherwise to bundles.
:operation keyword (:flat-only) Match exact :operation value (e.g. :rf.event/dispatched, :rf.fx/handled).
:op-type keyword (:flat-only) Match exact :op-type discriminator (e.g. :rf.event, :rf.fx, :error).
:since number (:flat-only) Keep events whose :id is strictly greater than this. Cursor-based polling — read the last event's :id, pass on next call.
:severity :error / :warning / :info (:flat-only) Synonym for :op-type restricted to the three severity tiers.
:event-id keyword Match :tags :rf.trace/event-id (the first element of the dispatched event vector, e.g. :user/login). For event-bundle reads, matches bundles whose :event first element is this id; for :flat, matches per-event.
:handler-id keyword (:flat-only) Match :tags :handler-id. Present on handler-error emits.
:source one of Spec-Schemas §:rf/dispatch-envelope's :source enum (:ui / :after-timer / :http / :repl / :machine-action / :machine-spawn / :fx-dispatch / :fx-dispatch-later / :always / :ssr-hydration / :test / :frame-init / :unknown / :other) (:flat-only) Match the top-level :source slot.
:origin :app / :pair / :story / :test / ... Match :tags :rf.event/origin. For event-bundle reads, matches bundles whose root :rf.event/dispatched carries this origin.
:dispatch-id number Match the bundle's :dispatch-id (event-bundle reads) or :tags :rf.trace/dispatch-id (:flat).
:since-ms number Keep bundles / events whose :time (host-clock ms) is strictly greater than this.
:between [t0 t1] Two-element vector — keep bundles / events whose :time falls in [t0, t1] inclusive.
:pred (fn [ev-or-bundle] → truthy) Arbitrary predicate. Receives the event bundle (or raw event when :flat true). Returning truthy keeps the entry. Escape hatch for filters not yet promoted to named keys.

Filters compose AND-wise — supplying both :op-type :error and :flat true keeps only error events. For event-bundle reads, bundle-level keys (:event-id, :origin, :dispatch-id, :between, :pred) apply; event-level keys (:operation, :op-type, :severity, :handler-id, :source) require :flat true.

Semantics

  • Ring discipline — per-event. When the ring is full at :rf.trace/events-retained slots, the oldest event slot (and every trace event ever emitted under its :dispatch-id) is evicted as a unit as the new event arrives. No allocation churn beyond the slot count.
  • Per-frame isolation. Each frame's ring is independent — a burst of :rf.sub/skip runs in :rf/xray does not touch :step-deck's ring. Tools that mount in their own frame (Xray, re-frame2-pair-mcp, story-mcp) see their own ring; the app frame they observe sees its own ring; cross-frame consumers merge by :dispatch-id across rings.
  • Same events as delivery. Every event delivered to listeners also lands in its frame's ring (when in-run). Ring-buffer events are the same maps the listeners receive.
  • Frameless events bypass the ring. Per B3+B4 above, frameless trace events never land in any ring; they stream live to listeners only.
  • Independent of listeners. A tool that attaches after events have fired can read the most-recent N events from the ring to bootstrap its view; a tool that wants a continuous live feed registers a register-listener! listener as well.
  • Production elision. The ring, like the rest of the trace surface, is compile-time eliminated in production builds (per §Production builds). (rf/trace-buffer frame-id) returns an empty vector in production, and the ring itself is not allocated.
  • Events-retained-zero semantics. When configured with {:events-retained 0}, the ring is disabled but the surface remains live: (rf/trace-buffer frame-id) returns [], (rf/trace-buffer frame-id opts) returns [], and (rf/clear-trace-buffer! frame-id) is a no-op (returns nil). Synchronous-delivery to registered listeners continues to fire — only the queryable history is suppressed.
  • Lowering events-retained on a populated ring. Applied while the ring holds more than N events, drops the oldest events first to fit (same eviction order as the ring discipline). Raising it keeps existing events and grows the slot count.
  • Reads against a destroyed / missing frame. (rf/trace-buffer <unknown-frame-id>) returns [] (parity with (rf/app-db-value <unknown>) returning nil and the destroyed-frame read posture in Tool-Pair §Surface behaviour against destroyed frames).

Rejected alternatives

For context — the design space the event-keyed per-frame ring won out against:

  • "Don't emit sub-skip trace events at all." Sub-recompute short-circuit signal is useful diagnostic output (performance work, invalidation chasing). The fix preserves the emit by routing to the right frame's ring and bundling with the parent run.
  • "Filter :skip at the consumer level." Too late — by the time a consumer reads the global ring, the real events the operator cares about are already evicted by the :skip flood. Filter-at-read is structurally late; the fix has to happen at INGEST or at the storage-partition level.
  • "Bigger flat ring (10× the current default)." Procrastinates the architectural mismatch without fixing it. A bigger ring still gets polluted by tool-frame traces and still has no eviction semantic that respects event boundaries.
  • "Frameless events go to a :rf/default cluster (the :rf/default + nil slot)." Original Q2 design (Mike ruled B during, then AMENDED to B3+B4 the same day). The cluster proposal had a hot-reload memory leak: every file save re-fires the entire ns-load worth of reg-* traces, the cluster grows without bound, the operator never benefits from the noise. B3+B4 fixes both halves: B3 keeps the ring exclusively for events (no leak surface), B4 deduplicates re-emits by shape (no live-stream noise either).

Why this is a framework primitive (not a Xray-specific concern): pair-shaped tools, REPL companions, and any non-Xray consumer needs recent-history access. Locating the rings in the framework — keyed by the per-frame model that already exists — means external tools depend on a stable framework primitive rather than on Xray's internal data structures. See Tool-Pair §How AI tools attach for the full consumption pattern.

Topology note. The public-tooling surface — register-listener! / unregister-listener! / clear-listeners! / trace-buffer / clear-trace-buffer! / configure-trace-buffer! / configure — and the per-frame ring + listener state live in the sibling re-frame.trace.tooling namespace, not re-frame.trace itself. re-frame.trace carries the always-loaded hot fast path (emit! / emit-error! / *handler-scope*); the tooling sibling is loaded only when a test fixture, tool (Xray / Story / re-frame2-pair-mcp), or dev preload :requires it. The rf/... public Vars and the re-frame.trace/<surface> wrappers delegate via the :trace.tooling/* late-bind hooks so existing consumer call sites are unchanged. On the JVM the tooling sibling is autoloaded by re-frame.trace (zero bundle cost off-bundle). On CLJS the tooling sibling is omitted from production counter bundles — the hook lookups return nil and the wrappers no-op (DCE drops the body wholesale, ~2 KB raw / ~600 B gzipped saved).

Emitting trace events

The framework emits trace events through one entry point: re-frame.trace/emit!. User code may also call it (re-exported as rf/emit-trace-event!) to add custom events to the stream.

(re-frame.trace/emit! op-type operation tags)
;; Emits one trace event with the given :op-type / :operation / :tags.
;; Returns nil. The runtime stamps :id and :time, hoists :source and
;; :recovery (when present in tags) to the top level, routes the event
;; into the in-flight frame's per-frame ring (event-keyed; frameless
;; emits bypass the ring per the B3 ruling), and synchronously invokes
;; every registered listener.

The shape is synchronous and side-effecting: the emit returns once every listener has been invoked. There is no span-shape machinery — events are emitted at the moment of interest with all relevant tags already populated. (For codebases migrating from a span-shaped tracing library, see MIGRATION.md §M-26.)

Compile-time elision

emit!'s body is wrapped in (when re-frame.interop/debug-enabled? ...). debug-enabled? is an alias of goog.DEBUG on CLJS (default true in dev, false in :advanced production builds); when the constant is false the closure compiler eliminates the gated branch and the call becomes a no-op. See "Production builds" below for the full mechanism.

Trace-emission opt-out: :rf.trace/no-emit? event-meta

Handlers (reg-event, reg-sub, reg-fx, reg-cofx, view registrations) whose registration metadata carries :rf.trace/no-emit? true produce no trace events. The runtime short-circuits emit! / emit-error! / the queue-time :rf.event/dispatched emit when the in-scope handler — or, for :rf.event/dispatched, the target handler — opts out. The runtime publishes the handler's :no-emit? reading via the :no-emit? slot of re-frame.trace/*handler-scope* (alongside :trigger-handler and :sensitive?, per §Handler-scope); the gate sits inside the outer interop/debug-enabled? when so production elision is preserved.

(rf/reg-event :rf.xray/note-trace-event
  {:rf.trace/no-emit? true}                     ;; <- opt-out
  (fn [{:keys [db]} [_ event]]
    {:db (assoc db :trace-buffer (conj (:trace-buffer db []) event))}))

The flag is the framework-level escape hatch for trace-consuming integrations whose own bookkeeping dispatches — emitted from inside a registered trace-cb — would otherwise re-enter the consumer through the trace-cb fan-out and form a cb-dispatch loop. Xray, Story, re-frame2-pair-mcp, and story-mcp all have the same risk shape; without the opt-out each consumer would need its own per-dispatch guard predicate. Promoting the gate to the framework lets any consumer mark a handler internal-only and trust the runtime to suppress the run.

Semantics:

  • What's suppressed. :rf.event/dispatched (queue-time, when the target handler's meta carries the flag), the pipeline-run markers :rf.event/run-start / :rf.event/run-end, :rf.event/db-changed, :rf.fx/handled, :rf.machine/transition, :rf.sub/run, :rf.view/render, and every :rf.error/* emit produced inside the handler's scope. The always-on event-emit substrate (`) ALSO honours the flag and drops the per-event record for:rf.trace/no-emit?-flagged handlers — same boundary semantics as the:sensitive?` short-circuit, on the rationale that framework-internal bookkeeping handlers are not user-domain observable signal.
  • What's NOT suppressed. The handler body still runs — the opt-out applies to OBSERVABILITY (trace + event-emit), not handler execution. The dispatch is queued, drained, and committed normally; the handler's db effect is committed; its fx are walked.
  • Run composition. Innermost in-scope handler wins. A non-opt-out handler dispatched from inside a :rf.trace/no-emit? true handler emits normally — the inner binding rebinds to false and the inner run is visible. (Same composition rule as :sensitive?, per Spec 009 line 1177.)
  • Production elision. The trace-surface gate sits inside interop/debug-enabled? and DCEs out in :advanced production builds (the trace surface is dev-only by construction — production never emits trace events at all). The event-emit short-circuit survives production builds (event-emit is always-on), so production listeners equally drop opt-out handler records.

and the framework re-frame.trace/*handler-scope* Var's :no-emit? slot (per §Handler-scope).

Frame-level trace-emission opt-out: :rf.trace/frame-no-emit? frame-config

A frame registered with :rf.trace/frame-no-emit? true produces no trace events: emit! / emit-error! short-circuit (no envelope allocation, no delivery) for any event whose :frame tag matches a frame so marked. This is the frame-scoped sibling of the handler-scoped :rf.trace/no-emit? above — the same suppression boundary, keyed on the frame rather than the in-scope handler.

(rf/make-frame {:id :rf/xray :rf.trace/frame-no-emit? true})   ;; <- tool / inspector frame

The flag is the framework-level escape hatch for inspector tools (Xray, Story, re-frame2-pair) that render their own UI inside a dedicated frame. That UI's reactive substrate emits :sub/run + :view/render on every panel render; because the retain-N ring is process-global, an inspector's self-instrumentation would otherwise evict every application event from the buffer it inspects (an inspector ring otherwise fills 200/200 with :rf/xray events and zero app events). Marking the tool frame trace-disabled means tool frames produce no trace at all, while application frames are unaffected.

Semantics:

  • What's suppressed. Every trace + error emit tagged with the marked frame — :rf.event/dispatched, :rf.event/run-start / :rf.event/run-end, :rf.event/db-changed, :rf.sub/run, :rf.view/render, :rf.frame/created for the frame itself, and every :rf.error/* whose :frame is the marked frame. The framework's emit sites already thread :frame onto these tags, so the gate keys on (:frame tags).
  • Mechanism (one canonical predicate). re-frame.trace/frame-trace-disabled? is the single source of truth; frame construction reads the config flag and calls re-frame.trace/set-frame-no-emit!. No call site hardcodes a frame id (e.g. :rf/xray). Honoured on first registration and surgical re-registration so a hot-reload can flip it either way.
  • Production elision. The gate sits inside the same interop/debug-enabled? when as the rest of the emit substrate, so it DCEs out of :advanced production builds (which emit no trace at all).

Where trace emission lives

The framework emits trace events from these call sites:

  • events.cljc:rf.error/effect-map-shape; :rf.error/effect-handler-bad-return; the registration-time throws :rf.error/reg-event-bad-interceptors, :rf.error/reg-event-bad-middle-slot, and :rf.error/reg-event-bad-arity for malformed reg-event interceptor-chain shapes; and the retired-name hard errors :rf.error/reg-event-db-removed, :rf.error/reg-event-fx-removed, and :rf.error/reg-event-ctx-removed (the diagnostic stubs that recognise the removed public names and throw, naming reg-event / reg-interceptor).
  • subs.cljc:rf.sub/create, :rf.sub/run (the pure compute-sub form — base shape only, no value-change/cascade attribution; see the :rf.sub/run op-type entry above); :rf.error/no-such-sub and :rf.error/sub-exception for failure paths.
  • subs/memo.cljc:rf.sub/run per true recompute on the reactive path, enriched with value-change + cascade attribution (:rf.sub/value-changed? / :rf.sub/prev-value / :rf.sub/value / :rf.sub/cascade? / :rf.sub/cause-sub; dev-only; the wire-value slots :rf.sub/prev-value / :rf.sub/value are emitted raw and redacted downstream by the re-frame.classification/project-sub-tags chokepoint — NOT by elide-wire-value, whose container deref would break glitch-free reaction layering; and the :rf.sub/run op-type entry above); :rf.sub/skip per memo-hit (input value-equal to last-seen → user body suppressed; and Spec 006 §Invalidation algorithm).
  • subs/cache.cljc:rf.sub/dispose per cache-slot eviction (closed-enum :rf.sub/reason :no-more-derefers / :hot-reload / :cache-clear / :frame-destroy; and the :rf.sub/dispose op-type entry above). Single-fire under CAS-winner contention. The :frame-destroy reason is emitted from dispose-all-for-frame-destroy!, which re-frame.frame/destroy-frame! invokes via late-bind.
  • trace/cascade.cljc:rf.cascade/captured (focused-event-only per-epoch cascade-DAG aggregator;). Fires at end-of-epoch from epoch.cljc/settle! via the :trace.cascade/capture-for-epoch! late-bind hook when the installed focus predicate matches.
  • fx.cljc:rf.fx/do-fx per drain step (op-type :rf.fx; the emit's :tags additionally carries :rf.event/fx (the vector the handler returned) and :rf.event/db-present? (boolean — was the handler's return-map's :db slot supplied?) so consumers can align run rows with handler returns without re-reading the interceptor context; the :db VALUE is intentionally NOT stamped — slice changes already ride :rf.event/db-changed. Both slots sit under :tags alongside :frame, consistent with the payload-shaped tag convention), :rf.fx/handled per dispatched fx, :rf.fx/override-applied, :warning :rf.fx/skipped-on-platform, :rf.error/fx-handler-exception, :rf.error/no-such-fx, plus :rf.machine.spawn/spawned and :rf.machine/destroyed.
  • cofx.cljc:rf.cofx/run (op-type :rf.cofx; emitted on the success branch of an ambient supplier that ran during context assembly, inside the supplier's scope binding, carrying :rf.cofx/id + :rf.cofx/value (the PRODUCED value, redacted by marks) + :rf.cofx/arg (the requirement-arg of a parameterized [id arg] requirement, omitted otherwise) + :rf.cofx/elapsed-ms); :rf.cofx/generated (op-type :rf.cofx; slice-B.7 — emitted when a declared-absent generator-backed recordable fact's generator runs at processing-start, inside the cofx scope binding, carrying :rf.cofx/id + :rf.cofx/value (the produced value, redacted by the same classification chokepoint) + :rf.cofx/arg); the cofx error family — the registration-time rejections :rf.error/cofx-name-collision, :rf.error/cofx-registration-invalid, and :rf.error/cofx-request-invalid, the delivery-time errors :rf.error/unregistered-cofx / :rf.error/missing-required-cofx / :rf.error/coeffect-exception (a supplier or generator throw during context assembly) / :rf.error/cofx-value-invalid (slice-B.7 — a supplied / replayed / generated recordable value failed its :schema, a production hard error), and the removed-API hard error :rf.error/inject-cofx-removed (the canonical per-error definitions are the §Error event catalogue rows; the retired draft dispatch opt :rf.world/inputs earns no dedicated error id — it rides the generic :rf.warning/unknown-dispatch-opt at the dispatch boundary, not from cofx.cljc, per Conventions §The tombstone rule), :warning :rf.cofx/skipped-on-platform (emitted when a registered cofx's :platforms excludes the active platform; mirrors :rf.fx/skipped-on-platform per 011 §Effect handling on the server).
  • router.cljc:rf.event :rf.event/run-start and :rf.event :rf.event/run-end (the pipeline-run markers; both also carry the redundant :rf.trace/phase :run-start / :run-end tag), :rf.event :rf.event/dispatched, :rf.event :rf.event/db-changed, :rf.event :rf.event/db-noop (the commit-level app-db no-op signal — a :db effect that left app-db unchanged; the complement of db-changed), :warning :rf.warning/db-nil-coerced (emitted from the commit path when a handler returned {:db nil}; the nil is coerced to {} and the diagnostic flags the accidental-wipe-vs-deliberate-clear distinction), :rf.error/handler-exception, :rf.error/drain-depth-exceeded, :rf.error/no-such-handler, :rf.error/no-frame-context (emitted when a frame-scoped op carries no frame stamp and runs under no scope), :error :rf.error/legacy-runtime-root (emitted from the frame-state commit path when a stray legacy :rf/runtime root is detected at the top of app-db — the two-partition migration boundary, a hard error; the partition makes the {:db fresh-map} clobber structurally impossible — per Conventions §The legacy :rf/runtime root and), :rf.error/dispatch-sync-in-handler, :rf.error/frame-destroyed, :rf.error/flow-eval-exception, :rf.frame/drain-interrupted (lifecycle event emitted when the ordinary drain observes its exact incarnation's destruction claim; its dropped count combines claim-time and check-time removals; per 002 §Edge cases worth pinning).
  • frame.cljc:rf.frame/created, :rf.frame/re-registered, :rf.frame/destroyed, :rf.machine.lifecycle/destroyed, :rf.error/on-destroy-handler-exception (the dedicated :on-destroy-throw category; rides the always-on error-emit axis via the :error-emit/dispatch-on-error late-bind hook so the discriminable teardown signal survives production), :warning :rf.warning/teardown-hook-exception (the single emit site is record-teardown-failure! — the one trace/emit-error! call that funnels every best-effort teardown-step failure during destroy-frame!; it is reached from safe-call-hook! (optional late-bound cleanup hooks) and safe-teardown-step! (ordered direct-call steps), and directly from the two epoch hooks notify-epoch-listeners! (:epoch/on-frame-destroyed) + snapshot-epoch-terminal-evidence! (:epoch/snapshot-frame-destroyed), whose own catch routes through it with :where :safe-call-hook! as the diagnostic LABEL, not the emit site — teardown continues best-effort, the warning is the breadcrumb that a cleanup step leaked).
  • trace/tooling.cljc:warning :rf.warning/trace-buffer-unrecognised-opts (emitted from configure-trace-buffer! when (configure! {:trace-buffer ...}) is handed an opts map without a usable :events-retained — the retired {:depth N} shape or a negative / non-numeric value; the call is a no-op and retention stays at its current default, so the warning is the loud-not-silent signal that the knob did nothing; per §Retention contract — the single knob :rf.trace/events-retained).
  • registrar.cljc:rf.registry/handler-registered, :rf.registry/handler-replaced, :rf.registry/handler-cleared, :warning :rf.warning/missing-doc (emitted once per (kind, id) pair when a reg-* registration omits :doc; per 001 §:doc is dev-warned when absent and).
  • machines.cljc + machines/transition.cljc + machines/lifecycle_fx.cljc + machines/timer.cljc + machines/parallel.cljc (per the file split: machines.cljc is a thin façade and emits land in the four sub-namespaces) — :rf.machine/event-received, :rf.machine/transition, :rf.machine.microstep/transition (one per microstep on :always-driven cascades, per 005 §Trace events), :rf.machine/snapshot-updated, :rf.machine.lifecycle/created, :rf.machine.spawn/spawned (fx-substrate spawn observation) + :rf.machine.lifecycle/spawned (registrar-substrate spawn observation — both emitted from machines/lifecycle_fx/spawn.cljc; see §Two-axis machine observation), :rf.machine/system-id-bound, :rf.machine/system-id-released (per 005 §Named addressing via :system-id), :rf.machine.timer/scheduled, :rf.machine.timer/fired, :rf.machine.timer/cancelled, :rf.machine.timer/stale-after, :rf.machine.timer/skipped-on-server (under SSR; per 005 §SSR mode), :rf.machine/guard-evaluated (emitted from the unified evaluate-guard helper at every user-declared guard call site in machines/transition.cljc; :tags {:machine-id <id> :guard-id <kw-or-fn> :input {:data <data> :event <event-vec>} :outcome :pass | :fail}; the synthesised always-true returned by resolve-guard for a nil guard-ref does NOT emit), :rf.machine/action-ran (emitted from run-action for every user-declared action invocation; :tags {:machine-id <id> :action-id <kw-or-fn> :input {:data <data> :event <event-vec>} :outcome <return-value> | :ok | :rf.error/action-threw :exception <Throwable on the throw path>}; success-with-nil-return collapses to :ok; the throwing path emits one trace with :outcome :rf.error/action-threw + :exception before propagating the result/fail), :rf.machine.event/unhandled-no-op (the benign no-op when no transition matched — machines/transition.cljc for flat / compound, machines/parallel.cljc for the parallel-region aggregate; op-type :rf.machine, NOT an error), :rf.error/machine-spawn-unregistered-type (the always-on fail-closed reject of a spawn naming an unregistered :machine-id — emitted by machines/lifecycle_fx/spawn.cljc on both the single-:spawn and :spawn-all join-init paths), plus the machine-error categories.
  • routing.cljc:rf.route/planned (the R0 plan projection, emitted at each door's commit branch — routing/navigate.cljc and routing/url_change.cljc, both through the one routing/resolve.cljc projection-to-tags mapping), :rf.route/prefetched + :error :rf.error/prefetch-bad-address (the warm-mode intent-preload summary and its two pre-planning rejections; the emit site for both is routing/prefetch.cljc — the ONE routing op that is deliberately not part of an activation drain, so it is accompanied by no :rf.route/planned / nav-token / activation pair), :rf.route/fragment-changed (fragment-only navigation), :rf.route/registered / :rf.route/cleared / :rf.route/activated / :rf.route/deactivated (lifecycle pair), :rf.route/navigation-blocked (leave block), :rf.route/entry-denied (terminal entry denial), :rf.route.nav-token/allocated, :rf.route.nav-token/stale-suppressed, :rf.fx/skipped-on-platform (route-fx platform skips), :warning :rf.warning/route-shadowed-by-equal-score, :error :rf.error/can-leave-non-boolean, :error :rf.error/can-enter-non-boolean, :error :rf.error/unsupported-scroll-strategy (the closed-vocabulary :rf.nav/scroll strategy reject; the emit site is routing/scroll.cljc's CLJS handler branch, and it rides the always-on error-emit axis via re-frame.error-emit/emit-error-both! so the rejection survives a schemas-less goog.DEBUG=false build — rf2-2hkfy).
  • flows.cljc:rf.flow/registered, :rf.flow/computed, :rf.flow/skip, :rf.flow/cleared, :rf.flow/failed (per 013 §Flow tracing). All carry :op-type :flow.
  • resources/*.cljc (the optional resources artefact, per the file split — emits land across resources/events.cljc, resources/registry.cljc, resources/timers.cljc, resources/route.cljc, and resources/ssr.cljc) — the :rf.resource/* lifecycle trace family (all :op-type :rf.event except the two :warning-level :rf.resource/* clock-skew rows) plus one :rf.warning/*-namespaced row (:rf.warning/resource-load-more-owner-ignored, the load-more owner-drop diagnostic): :rf.resource/registered (registry.cljc, first-time reg-resource, frame-agnostic — symmetric with :rf.route/registered), :rf.resource/owner-attached / :rf.resource/cache-hit (fresh-skip serve) / :rf.resource/deduped (in-flight join) / :rf.resource/work-started (the work-LEDGER row / transport request started — :status :running + :superseded) + :rf.resource/fetch-started (the cache ENTRY's status transition — emitted alongside work-started on the same start; events.cljc ensure path), :rf.resource/work-abort-requested, :rf.resource/work-completed, :rf.resource/succeeded, :rf.resource/failed (first-load) / :rf.resource/refresh-failed (background-refresh), the infinite-feed load-more family :rf.resource/load-more (events.cljc — a load-more issued the next-page fetch: feed key, resolved :page-param, :page-index / :page-count of the APPEND, generation, work id) / :rf.resource/page-appended (a page fetch succeeded and was appended: :page-index, new :page-count, derived :next-page-param, :terminal?) — the pagination cursor (:page-param / :next-page-param) is an app-:next-page-param-derived free tag that can carry a record id, so on OFF-BOX trace egress it is owner-classified: the family-level projector (re-frame.resources.trace-egress) tokenizes it to a CONTENT-FREE {:rf/redacted <shape>} — a closed-vocabulary shape tag and a count, never a digest of the cursor (016 §The off-box redaction token carries no enumerable content) — when the row's resource OWNER is non-:serialize (sensitive / large / derived-sensitive / unregistered fail-closed — the SAME disposition that governs the row's :resource/key), riding verbatim for a plain feed; the trusted-local :include-sensitive? opt-in lifts it (Spec 016 §Xray and AI tooling / 015 §Data-Classification) / :rf.resource/page-failed (a load-more page fetch FAILED — the THIRD error channel: the feed is KEPT at :loaded and records :page-error; distinct from a first-load :rf.resource/failed and a whole-feed :rf.resource/refresh-failed; carries :status-before / :status-after / :page-error) / :rf.resource/load-more-skipped (a load-more no-op: :reason :no-feed (no page-0 yet) / :no-next-page (terminal — nil cursor) / :in-flight (joined a live page fetch, no new generation)), the :warning :rf.warning/resource-load-more-owner-ignored row (events.cljc load-more path — a load-more given a non-route :owner: the owner is IGNORED, NOT attached to :active-owners / :owner-index, so no durable owner leaks, and the page still fetches + appends; emitted once on EVERY branch — issue / skip / dedupe / no-feed — when (some? :owner)), :rf.resource/invalidated, :rf.resource/refetch-decision (the free :scope tag these two carry — as does :rf.resource/removed below, and every other row in the family whose :scope is a concrete resolved scope — or an unresolved {:from-db …} reference, on the clear-scope fail-closed warning — sitting outside a scoped key; re-frame.resources.trace-egress is the authority on which rows those are — is NOT owner-classified the way the pagination cursor above is, and deliberately so: an invalidation sweep spans owners and a clear-scope teardown outlives them, so the row carries no :resource/key to read an owner claim from. The same family-level projector applies its SHAPE-driven fail-closed default instead: :rf.scope/global is a scalar and rides verbatim; a [tier {identity}] scope keeps its TIER keyword, so attribution survives, and tokenizes the identity MAP; a {:from-db …} reference tokenizes WHOLE, its resolver-id attribution surviving on the sibling :from-db tag. Every one of those tokens is the CONTENT-FREE {:rf/redacted <shape>} the cursor gets, so per-scope joins do NOT survive off-box — distinctness across sensitive scopes is surrendered deliberately rather than bought with an enumerable digest, and the trusted-local :include-sensitive? opt-in is the only lift to the raw scope), :rf.resource/revalidate-scan (focus/reconnect scan summary; the per-entry refetches emit their own :rf.resource/work-started / fetch-started), :rf.resource/owner-released, :rf.resource/stale-fired / :rf.resource/gc-fired / :rf.resource/gc-skipped / :rf.resource/poll-fired (events.cljc timer-fire path; poll-fired carries the poll-tick :decision:polled / :coalesced / :paused-hidden / :no-owner / :no-entry) + :rf.resource/stale-scheduled / :rf.resource/gc-scheduled / :rf.resource/poll-scheduled (timers.cljc arm path), :rf.resource/removed (events.cljc + registry.cljc), :rf.resource/stale-suppressed (the SINGLE suppression op), :rf.resource/route-plan (route.cljc — the ONE summary row per resource plan; this row has two shapes, and the discriminator is :plan-cause, whose only member is :prefetch. Activation shape (:plan-cause ABSENT — a route-entry plan on a navigation commit): {:route-id <LEAF target> :nav-token <token> :branch [parent-most … leaf] :ensured <count of ADDED identities — real ensures> :kept <count of ADOPTED identities — owner handoff, no fetch> :removed <count of prior-plan identities this plan drops> :blocking [<scoped resource key> …] :identities [<scoped resource key> …] :ensured-identities [<scoped resource key> …] :kept-identities [<scoped resource key> …] :removed-identities [<scoped resource key> …]} plus :redundant-children when the branch produced any (a vector of {:resource :scoped-key :ancestor {:route-id :local-id} :child {:route-id :local-id}} advisories — the redundant-child advisory in 016 §Effective parent-chain resource plans) and :plan-error true on a failed plan. :ensured + :kept equals the size of :identities by construction, and each of the three counts is the size of its like-named vector: the vectors are the identity PARTITION the counts summarize, so ONE row answers which identity was ensured / kept / removed on this navigation without diffing two consecutive rows. They are named for what the runtime DID rather than for the diff — a retained-but-unusable identity takes the ordinary ensure path, so a vector named :added would disagree with the :ensured count beside it. :identities carries the planner's GROUPED PLAN ORDER (post-dedupe, each collapsed identity exactly once, in the order the plan executes), :ensured-identities / :kept-identities are that same order filtered. :removed-identities promises no meaningful order, and deliberately so — removal is not an ordered operation (a plan releases the whole prior owner in ONE effect), and no prior-plan order is even available to report: the routing handoff records a plan's identities as the unordered map {<key-id> <scoped-resource-key>} at [:rf.runtime/routing :resource-plan <nav-token>] (Spec-Schemas §:rf/runtime-db) and hands that map back as the next activation's previous-identity input. The row is instead a pure function of the removal membership: it is derived from that map and emitted in the CANONICAL order of the CEDN-1 key-id the cache is already keyed on, so the same removal membership yields the same vector for every caller shape and on every host, and :removed is its size by construction. Deriving from the carrier's own iteration would NOT achieve that — a small ClojureScript map is array-map-backed and iterates in insertion order, so a sequential caller's ordering would walk straight back out — which is why the canonical sort, not carrier iteration, is the guarantee. The vector rides exactly as :blocking does, which is likewise a membership answer with no promised order. Every one of these vectors carries scoped resource keys, :blocking included, so none of them is named by the family projector's slot roster and all of them egress through its SHAPE-driven fail-closed default rather than riding raw. :blocking is the AT-COMMIT subset that still has to resolve, so an already-fresh blocking requirement is absent from it (the route projects :idle rather than a transient :loading). A FAILED plan reports the atomicity rule directly on the row: :ensured 0, :kept 0, :blocking [], :identities [], :ensured-identities [], :kept-identities [], and every prior identity under :removed / :removed-identities — no partial ensure was dispatched, and the prior owner was still released. Warm shape (:plan-cause :prefetch — a warm-mode intent preload): {:route-id <destination> :plan-cause :prefetch :branch [parent-most … leaf] :ensured <count of unique requirements, all OWNERLESS ensures>} plus :plan-error true on a failed warm plan. The warm shape carries none of :nav-token / :kept / :removed / :blocking / :identities / the three identity vectors / :redundant-children — a preload owns no prior plan, allocates no nav-token, and cannot change readiness, so there is no diff to report and no blocking set to write. :plan-cause and :nav-token are therefore mutually exclusive on this row, as are :plan-cause and :blocking / :identities / the identity partition), and the SSR / restore-reconcile family :rf.resource/hydrated / :rf.resource/hydrate-refetch (per-entry refetch-plan row) / :rf.resource/restored plus the two :warning :rf.resource/hydrate-clock-skew / :warning :rf.resource/restore-clock-skew rows (ssr.cljc — a hydrated/restored entry's absolute :stale-at is ahead of the live clock, freshness ambiguous until the next live-owner ensure resolves it), plus the resource error / registration-throw categories catalogued in §Error event catalogue (:rf.error/resource-*). NOTE :rf.resource/ensure / refetch / remove / window-focused / network-reconnected / invalidate-tags / release-owner are dispatched EVENT IDs (they appear only as the :rf.event/dispatched event vector), NOT emitted :rf.resource/* trace operations. Xray defines the family's closed op set + per-op semantic class in Xray spec 024 §The :rf.resource/* trace family; the framework consumer surface is 016 §Xray and AI tooling.
  • schemas.cljc:rf.error/schema-validation-failure (from validate-app-schema! / validate-event! / validate-fx! / validate-sub!; there is no injection-time validate-cofx! — the live cofx schema surface is re-frame.cofx's :rf.error/cofx-value-invalid), :rf.error/malformed-schema (from validate-app-schema! when a registered schema form is structurally malformed and the validator throws — isolated per-entry, surfaced distinctly, fail-closed candidate rejection; per 010 §App-db schemas), :warning :rf.warning/schema-validator-unavailable (emitted once per process from reg-app-schema / reg-app-schemas when :schemas/malli-validate is unbound AND the framework-default validator is still installed; per 010 §Recommended soft-pass and), :warning :rf.warning/schema-walker-opaque (emitted once per process from reg-app-schema / reg-app-schemas when the registered schema is a non-vector form — registry-ref keyword, compiled m/schema object, or other opaque value — so the walker cannot introspect per-slot :sensitive? / :large? flags; per 010 §The :schema value is opaque to re-frame and).
  • router.cljc:rf.error/malformed-schema (defensive backstop: the candidate-validation guard emits this category — with :rollback? true — and REJECTS the candidate transition fail-closed (rf2-uhk9ko; the retired treat-as-pass arm was a fail-OPEN bypass) if a wholesale validator-machinery throw reaches its catch, so a throwing validator can neither install unvalidated state nor go invisible; a malformed REGISTERED schema is handled upstream per-entry in schemas.cljc and does not reach here).
  • spec.cljc:rf.error/schema-validation-failure :where :event :source :boundary (the DEV TRACE half, from the :rf.schema/at-boundary interceptor; per 010 §Production builds and). The interceptor stamps :rf/boundary-rejected? rather than emitting the always-on half itself, so router.cljc's pipeline tail (emit-boundary-rejection-record!) owns the structural record that survives production — one emit site for the dev and production enforcement routes both.
  • events.cljc:rf.error/at-boundary-missing-schema (thrown from reg-event-* when :rf.schema/at-boundary is attached to a handler whose metadata-map carries no :schema; per 010 §Production builds and).
  • events.cljc:rf.error/reg-event-bare-interceptor (thrown from reg-event when a BARE interceptor — a map carrying :before / :after — is handed where metadata :interceptors was required, e.g. (reg-event id mw/some-interceptor handler) instead of (reg-event id {:interceptors [mw/some-interceptor]} handler); an interceptor is a map, so the bare form would read as the metadata-map and silently drop the chain — the loud rejection is the realisation of Conventions §No silent swallow); and the retired-name hard errors :rf.error/reg-event-db-removed / :rf.error/reg-event-fx-removed / :rf.error/reg-event-ctx-removed.
  • interceptor_registry.cljc (registered interceptors — reference-only) — the registration- and resolution-time throws :rf.error/invalid-interceptor (reg-interceptor* — a malformed descriptor, or a migration-value :id mismatch), :rf.error/unregistered-interceptor (resolve-ref — an event/frame chain references an unregistered interceptor id; validated at reg-event / make-frame registration and re-guarded at dispatch-time chain assembly), :rf.error/invalid-interceptor-ref (resolve-ref / resolve-chain — a chain entry that is neither a ref nor an interceptor value), :rf.error/inline-interceptor-removed (resolve-chain, and events.cljc at registration — an INLINE interceptor value in a chain position; chains are reference-only), and :rf.error/interceptor-factory-arity (resolve-ref / resolve-factory — a parameterized ref to a non-factory, a bare ref to a factory, or a factory that cannot build for the arg). All thrown ex-info, not traces — dev-trace-only registration / chain-assembly validation.
  • ssr.cljc:rf.ssr/hydration-mismatch (the hiccup-tier emit: carries :failing-id to discriminate body-mismatch from head-mismatch, plus :server-hash / :client-hash; per 011 §Hydration-mismatch detection and 011 §Mismatch detection — head), :warning :rf.warning/multiple-status-set, :warning :rf.warning/multiple-redirects, :rf.error/sanitised-on-projection.
  • re-frame.ui.runtime (via re-frame.ui.client/hydrate-root*) — :rf.ssr/hydration-mismatch (the compiled-tier emit, rf2-6z1i2): a compiled re-frame.ui root has no hashable structural render-tree, so it verifies by React-native ADOPTION — a React-recoverable error in the hydration adoption window (before the root's phase flip) is surfaced through the root's onRecoverableError. This reports only the divergences React recovers from (text / structural), not attribute-only mismatches, which produce no trace on this tier (see the catalogue row's boundary note below). Same category as the hiccup tier, tier-discriminated by :where re-frame.ui/hydrate-root (NO :server-hash / :client-hash); per 011 §Hydration-mismatch detection (the two-tier split). Emitted via re-frame.trace/emit!, interop/debug-enabled?-gated + DCE'd, exactly like :rf.ssr/phase-flip.
  • epoch.cljc:rf.epoch/snapshotted per dequeued event (one per epoch), :rf.epoch/restored on restore success, :rf.epoch/db-replaced on pair-tool injection (replace-frame-state!, the ONE partial-map mutator) success, plus the six restore-failure categories and the four injection failure categories (:rf.error/replace-frame-state-bad-keys, :rf.epoch/replace-during-drain, :rf.epoch/replace-schema-mismatch, :rf.epoch/replace-history-disabled), plus :rf.epoch.cb/silenced-on-frame-destroy emitted once per destroy-continuum for a (frame-id, cb-id) pair on the destroy-cascade boundary (carrying :observed-gen; see §The delayed-silence emission linearization law), plus :rf.epoch.cb/listener-exception (op-type :error) emitted once per broken-listener invocation when an epoch listener throws — isolation contract still holds (sibling listeners and the runtime continue), the trace is the alarm so devtools surface the failure rather than silently dropping it.
  • views.cljs:rf.view/render per registered-view render (per Spec 004D §Render-tree primitives). Per the same wrapper additionally fires :rf.view/rendered carrying run attribution (:rf.view/id, :frame, :rf.view/render-key, plus — when an in-flight run buffer is available — :rf.view/cause-event-id and :rf.view/cause-subs). Per :rf.view/rendered ALSO carries :rf.view/mount? (a boolean — true on the component instance's first render, false on every subsequent re-render) and, when the view derefs any subs, :rf.view/deref-subs (the vector of subscription query-vectors THIS view read during the render — its OWN per-view read-set, distinct from the run-wide :rf.view/cause-subs which over-reports). Per the wrapper ALSO emits the new :rf.view/unmounted op when a registered-view instance tears down (carrying :rf.view/id, :frame, :rf.view/render-key). The emit sites sit inside the substrate-agnostic views.cljs frame-aware-view wrapper, so every adapter (Reagent ratom watch chain, UIx hooks) composes them; the new ops/fields ride the same per-render / per-instance-teardown path. :rf.view/rendered is capped at 100 per run with a one-shot :rf.view/rendered-cap-reached marker (carries :frame + :rf.view/dropped-after) to bound the per-run buffer's heap budget for full-page re-render storms (:rf.view/unmounted is NOT capped — one emit per instance teardown). Consumers (Xray Reactive / Views panels) walk the per-run buffer's :rf.view/rendered entries to graph cause→effect attribution and read :rf.view/mount? / :rf.view/deref-subs / :rf.view/unmounted to label each view's mount-vs-rerender-vs-unmount ACTION and its per-view reactive REASON; tools that already consumed :rf.view/render for render-count metrics continue to work unchanged.
  • adapter/context.cljs:rf.error/frame-context-corrupted (function-component _currentValue read observed a non-coercible shape;).
  • frame.cljc (provider-arg validation) — :rf.error/bad-frame-provider-arg (a public frame-provider whose :frame is non-nil but neither a keyword nor a live frame value — API-shrink #1, rf2-csbbwu normalized :frame to accept a frame value alongside a keyword id; rides the always-on error-emit axis via the :error-emit/dispatch-on-error late-bind hook, then throws. Validated at every public provider entry point — Reagent re-frame.views.provider/frame-provider and the shared UIx-spine core re-frame.substrate.spine/build-frame-provider-element — BEFORE the value reaches React Context, so a bad provider arg is distinct from absence (:rf.error/no-frame-context) and from a disturbed reader-side read (:rf.error/frame-context-corrupted); recovery :supply-frame-target). The split frame-boundary components additionally validate their mount opts at the entry points (diagnostic-channel, thrown ex-info, via re-frame.views.frame-boundary): the ENSURE component (rf/frame-root {:id …}) rejects a missing/non-keyword :id with :rf.error/frame-root-missing-id, a stray :frame with :rf.error/frame-root-given-frame, and a mounted :id/opts change with :rf.error/frame-root-reconfigured; the SCOPE-only component (rf/frame-provider {:frame …}) rejects an absent frame with :rf.error/frame-provider-frame-absent and a stray :id with :rf.error/frame-provider-given-id.
  • substrate/adapter.cljc:error :rf.error/write-after-destroy emitted by the replace-container! wrapper when called with a nil container (the frame was destroyed mid-drain or before a scheduled write fired; the underlying adapter's replace-container! is NOT invoked). Rides the always-on error-emit axis via the :error-emit/dispatch-on-error late-bind hook — the substrate cannot static-require re-frame.error-emit. Per 006 §replace-container! and.
  • http_managed.cljc + http_encoding.cljc (the HTTP artefact ships eight http_*.cljc files; emits cited here come from http_managed.cljc unless noted) — :warning :rf.http/cljs-only-key-ignored-on-jvm, :info :rf.http/retry-attempt, :info :rf.http/aborted-on-actor-destroy (per 014 §Abort on actor destroy), :info :rf.http.interceptor/registered, :info :rf.http.interceptor/cleared, :error :rf.error/http-interceptor-failed (request-side interceptor :before threw, per 014 §Middleware), plus the Spec 014 failure categories.

User code can also emit traces — re-frame.trace/emit! is public and re-exported as rf/emit-trace-event!.

Production builds: zero overhead, zero code

Dev-side instrumentation is a development-only concern. In production builds the trace surface, the dev-time arms of the schema validation surface (Spec 010) — the validate-*! family that a programmer's own :schema declarations feed — and the registrar's hot-reload trace emit (:rf.registry/handler-{registered,replaced,cleared}) are all compile-time eliminated through a single shared gate. The closure compiler's dead-code elimination removes the gated branches, and nothing behind that gate reaches a production binary.

What sits behind the gate is settled by what the check is for, not by who declared the schema it reads (per C-000.35). An ordinary registration diagnostic is a development aid, so it elides. A check the framework relies on to keep a promise of its own — the :rf.schema/at-boundary interceptor, a recordable coeffect's durable-value contract, a declared route's shape, the reserved :rf.server/* effects' own arguments — was never behind the gate and behaves identically in both builds, whoever wrote the schema it consults: the recordable coeffect's :schema comes off the programmer's own reg-cofx, and survives because of where the framework applies it. The always-on error- and event-emit substrates sit outside it too; §What IS available in production enumerates what a release build still carries. Surviving is not the same as reporting, and the two do not always travel together: a check can run in a release build while the rich dev trace above it elides.

The mechanism: re-frame.interop/debug-enabled? (alias of goog.DEBUG)

The CLJS implementation uses one shared flag — an alias of the standard goog.DEBUG closure-define — for every dev-only branch:

;; src/re_frame/interop.cljs
(def ^boolean debug-enabled? "@define {boolean}" ^boolean goog/DEBUG)

Every framework-internal dev branch — trace/emit!, trace/emit-error!, schemas/validate-app-schema!, schemas/validate-event!, schemas/validate-fx!, schemas/validate-sub!, and the registrar/{register!,unregister!,clear-kind!} trace emits — wraps its body in (when interop/debug-enabled? ...). With :advanced compilation and :closure-defines {goog.DEBUG false}, Closure constant-folds the gate and DCEs every dependent allocation: trace maps, listener iteration, malli calls, error reason strings, the Performance API bridge.

;; user's shadow-cljs.edn — production build
{:builds {:app {:target           :browser
                :output-dir       "..."
                :compiler-options {:closure-defines {goog.DEBUG false}}}}}

(Most production CLJS builds already set goog.DEBUG=false; re-frame2 piggybacks on the canonical CLJS production flag rather than introducing its own.)

The gate must be the outermost form of the body. (when interop/debug-enabled? ...) and (if interop/debug-enabled? <body> <else>) constant-fold reliably; (when (and X interop/debug-enabled?) ...) does NOT — Closure can't statically rule out X, and the dead branch survives into the bundle. The verifier (see §Production-elision verification) catches that mistake.

A reachable but dead branch in a production bundle:

  • Allocates no trace event maps.
  • Holds no listener registry beyond the (small) defonce cells (which carry {} and 0).
  • Never invokes listener predicates.
  • Excludes the per-frame trace rings (the event-keyed payload).
  • Excludes the Performance API bridge.
  • Excludes the schema validation entry points and their malli/explanation calls.

How users opt in (dev builds)

CLJS dev builds default to goog.DEBUG=true — every gate stays live with no extra configuration. A user who wants trace machinery in a :advanced artefact (rare) can flip the flag explicitly:

;; shadow-cljs.edn — :advanced build with trace kept in
{:closure-defines {goog.DEBUG true}}

User-side listener registration

User-side (rf/register-listener! ...) calls should also elide in production. Wrap them with the same predicate the framework uses:

(when ^boolean re-frame.interop/debug-enabled?
  (rf/register-listener! :trace :my/listener callback-fn))

In production (goog.DEBUG=false), re-frame.interop/debug-enabled? is the constant false, the when is dead, and the entire registration is elided.

The same pattern applies to register-epoch-listener!, trace-buffer, clear-trace-buffer!, and (rf/configure! {:trace-buffer {:events-retained N}}) — every dev-only call site in user code should sit under the when ^boolean re-frame.interop/debug-enabled? guard.

JVM builds

Cross-reference: see Security.md §Production gates — SSR / webhook / long-running JVMs facing untrusted input MUST set the gate false explicitly so dev-side trace enrichment elides in production. The runtime gate below is the JVM mirror of CLJS goog.DEBUG; both surfaces compose with the always-on substrates above.

JVM has no :advanced and no compile-time DCE. The JVM half of the interop layer:

;; src/re_frame/interop.clj — JVM-side gate read once at ns-load
(def debug-enabled?
  (read-debug-flag))   ;; defaults to `true`; opt-out via property / env

…reads the gate ONCE at namespace load. The default is true (dev parity), but the gate is explicitly overridable for the SSR production posture — the JVM-side counterpart to CLJS goog.DEBUG=false.

Opt-out vocabulary. Two input sources, read in this order, with the JVM system property winning on conflict:

  1. Java system property re-frame.debug — set on the JVM command line with -Dre-frame.debug=false.
  2. Environment variable RE_FRAME_DEBUG — set in the process environment.

Both accept the conventional false-y vocabulary case-insensitively: false, 0, no, off, empty string. Whitespace is trimmed. Anything else — including absent / unset — leaves the flag at its default true. The vocabulary is intentionally conservative: only the documented opt-out strings disable the gate, so an accidental typo (disabled, nope) leaves the dev posture alive rather than silently misconfiguring.

What disabling the gate suppresses. With re-frame.debug=false set BEFORE re-frame.interop loads, every JVM-side dev surface drops to its no-op floor — the same shape CLJS :advanced + goog.DEBUG=false builds achieve via Closure DCE:

  • Trace emission (emit! / emit-error! / the queue-time :rf.event/dispatched emit) is silent.
  • The per-frame trace rings accumulate nothing.
  • register-listener! listeners receive no events.
  • The epoch artefact (per Tool-Pair §Time-travel) records no :frame-state-before/:frame-state-after/:trace-events payloads, fires no register-epoch-listener! listeners, and refuses restore-epoch! / the pair-tool injection surface (replace-frame-state!).

What remains live (always-on by construction). Disabling the gate does NOT silence the production-survivable surfaces:

  • The event-emit substrate (the :events stream of register-listener!, per §Event-emit listener) keeps firing.
  • The error-emit substrate (the :errors stream of register-listener!, per §Error-emit listener) keeps firing.
  • Schema validation, the registrar, and the dispatch loop itself are unaffected.

Those surfaces are explicitly always-on per their owning specs — they exist precisely for the SSR / production posture and would defeat their purpose if a debug-gate flip silenced them. They run with the :sensitive? substrate-level enforcement described in §Privacy / sensitive data in traces.

Set the flag before re-frame loads. The Var reads its value at ns-load time, then JIT-inlines into the per-call when interop/debug-enabled? checks. Late mutation via alter-var-root! works for tests (and is the canonical way to flip the gate within a test) but does not retroactively elide already-allocated infrastructure.

The motivating concern is the audit finding: an SSR / headless JVM process running re-frame2 should not, by default, retain user input in per-frame trace rings or epoch history. Apps that ship a JVM artefact for production should set -Dre-frame.debug=false in their deployment. The dev / test posture is unchanged.

Production-elision verification

The contract above is enforced by an automated test in CI:

  1. implementation/core/test/re_frame/elision_probe.cljs is a probe namespace that exercises every gated surface — register-listener!, emit-trace-event!, the per-frame trace rings (trace-buffer / clear-trace-buffer! / (configure! {:trace-buffer {:events-retained N}})), validate-{app-db,event,sub-return,cofx}!, register! / unregister! / clear-kind!, the epoch surface (register-epoch-listener! / epoch-history / restore-epoch! / (configure! {:epoch-history …})), plus a representative dispatch-sync flow. The probe roots the dead-code-elimination graph at every surface so a leak surfaces in the bundle.
  2. implementation/shadow-cljs.edn declares two :advanced builds with re-frame.elision-probe/run as the entry point:
  3. :elision-probe:closure-defines {goog.DEBUG false} (production)
  4. :elision-probe-control:closure-defines {goog.DEBUG true} (control)
  5. implementation/scripts/check-elision.cjs greps both bundles for sentinel strings drawn from the gated branches (schema reason fragments and :rf.registry/* trace operation keywords). The contract:
  6. Production bundle: every sentinel MUST be ABSENT.
  7. Control bundle: every sentinel MUST be PRESENT.
  8. The CI workflow runs npm run test:elision (shadow-cljs release elision-probe elision-probe-control && node scripts/check-elision.cjs) on every push/PR.

The control build is what gives the test teeth: without it, a refactor that moved a sentinel string out of a gated branch would silently turn the negative assertion into a vacuous pass. With both bundles checked, any change that either breaks elision or loses methodology signal fails CI loudly.

When a future surface is added (e.g. epoch history per Tool-Pair §How AI tools attach), it follows the same pattern:

  • Wrap its dev-only body in (when interop/debug-enabled? ...), outermost.
  • Touch the surface from re-frame.elision-probe so the DCE graph reaches it.
  • Add a sentinel to DEV_ONLY_SENTINELS in check-elision.cjs (a string literal or keyword name that only the gated branch contains).

Production debugging: what remains

The elision contract above is uncompromising — in a :advanced build with goog.DEBUG=false, the entire trace surface disappears. That decision is correct for binary-size and hot-path cost, but it has consequences for post-mortem debugging that this section makes explicit so users aren't surprised when a production incident leaves them with thin tooling.

What is NOT available in a default production CLJS build

A :closure-defines {goog.DEBUG false} :advanced build carries no trace machinery. Concretely, the following surfaces have been DCEd and the runtime cannot reach them at all:

  • register-listener! / unregister-listener! — listener registration is a no-op because the gate around trace/emit! is constant-folded out. Even if user code registered a listener at boot (which it shouldn't, per §User-side listener registration), nothing would ever invoke it.
  • The per-frame trace rings (trace-buffer, clear-trace-buffer!, (configure! {:trace-buffer {:events-retained N}})) — pulling "the last N events from a prod session" is not supported. The ring's swap! site is inside the same elision gate.
  • register-epoch-listener and the per-event :rf/epoch-record assembly — the record assembly, ring-buffer append, and listener publication run inside the trace surface and elide with it, so a production build produces no records. (The pure projected-record egress transform is itself ungated and stays callable on an already-held or synthetic record; a production build simply assembles no record for it to project.)
  • Every :rf.error/*, :rf.warning/*, :rf.info/*, :rf.fx/*, :rf.ssr/*, and :rf.epoch/* trace event documented in §Error event catalogue. They are not emitted, not buffered, and not deliverable to any listener. (The corpus-wide error-emit surface — the :errors stream of register-listener! — is a documented exception: it rides a small always-on error-emit substrate that survives goog.DEBUG=false, fanning out one tight record per promoted runtime :rf.error/* (the categories meeting the promotion criterion, not every production-reachable one). See §What IS available in production below.)
  • Source-coord enrichment (:rf.trace/trigger-handler), :rf.trace/dispatch-id / :rf.trace/parent-dispatch-id correlation, :rf.event/origin tagging — all ride the trace event and elide with it.
  • Schema validation (:rf.error/schema-validation-failure) and registrar hot-reload notifications (:rf.registry/handler-registered and siblings) — same gate, same elision. One arm of the first is narrower than it looks. The :rf.schema/at-boundary interceptor's check is not gated and still runs in this build (per 010 §Production builds), and neither is the always-on record the router's tail fans for it: what elides is only the rich emit-error! dev trace carrying the offending value. So a boundary rejection in this build both happens and reports — as a structural record on the :errors stream and :outcome :rejected on the :events one. See the §Error event catalogue paragraph on the category.
  • The Xray-MCP server and the re-frame2-pair server (per Tool-Pair.md). These are dev-only tools that attach to the trace surface; they are not designed for, and not shippable to, production. The Xray preload artefact must not be on a production build's classpath; the re-frame2-pair server lives in its own dev-only artefact for the same reason.

What IS available in production

Cross-reference: see Security.md §Production gates for the framework-wide threat-model entry — the two always-on substrates below are the production-survivable surface; everything in §What is NOT available in a default production CLJS build is dev-only.

Five surfaces survive elision and are the canonical production-debugging fallbacks:

  1. The event-emit listener surface (register-listener! / unregister-listener! with the :events stream — see API.md §Event-emit) — runs through a small always-on event-emit substrate (parallel to the error-emit substrate in #2) that is NOT gated by re-frame.interop/debug-enabled?. The router fans out one record per processed event after the cascade settles. The record is intentionally tight — {:event <vector> :event-id <kw> :frame <kw> :time <millis> :outcome <kw> :elapsed-ms <int>} — enough discriminator for production event observability (event-id, frame, outcome, latency); not enough for causal reconstruction (:rf.trace/dispatch-id correlation, :rf.trace/parent-dispatch-id, source-coord ride the dev-only trace surface and elide with it). :outcome reports the dispatch result across every cascade-failure path, not just the interceptor-chain exception, so a dispatch that aborted is never mis-reported as a clean :ok to an off-box shipper: :ok (clean settle — db committed, flows ran, :fx walked), :error (the interceptor chain — handler or interceptor — threw), :rolled-back (candidate :db / machine-data schema validation REJECTED the transition BEFORE install per 010 §Per-step recovery row 4 — the container was never written and keeps its pre-handler value; flows and :fx were skipped. :rolled-back is the STABLE public vocabulary for transaction rejected; it does not imply a physical write-pair — rf2-uhk9ko. This member has no producer in a production build. The record itself is always-on, but its only producer is the candidate validator, which is dev-only per 010 §Production builds — so in a release build a candidate that violates a registered schema simply installs and the dispatch reports :ok. Do not wire a production alert to a quiet :rolled-back count and read it as "no schema violations"; it is quiet by construction — rf2-bkvu5), :flow-error (a flow's :output threw per 013 §Failure semantics rule 3 — the cascade halted before :fx), or :rejected (the :rf.schema/at-boundary interceptor REFUSED the event's payload against the handler's :schema per 010 §Production builds — the handler never ran, though entered interceptors still unwound in full; rf2-mwv4e. Neither existing non-:ok value fits: :error means the interceptor chain threw, which it did not, and :rolled-back means a candidate transition was refused before install, but no candidate ever existed because the handler never produced one. Keeping :ok preserved a known lie — the stream's own contract is that an aborted dispatch is never reported as a clean settle. :rejected is the exact COMPLEMENT of :rolled-back on the production question, and reading the two together is what stops the warning above generalising to the whole vocabulary. :rolled-back has no producer in a release build; :rejected does, because boundary validation is ungated per 010 §Production builds — so this is the outcome to alert on for hostile or malformed input at an untrusted ingress. Boundary validation is not the only schema check a release build keeps — what may be elided is settled by what the check is for, not by who declared the schema it reads, and the framework's other load-bearing checks are ungated alongside it (per C-000.35, restated with the full set at the head of this section). It is, however, the only one this :outcome vocabulary names in its own right: the others throw on their own paths, and a throw reports :error. It is paired with one always-on :rf.error/schema-validation-failure record (:source :boundary) on the :errors stream, which carries the identifiers; the offending value never egresses). The chain-exception path reports :error even when a downstream rejection would also apply: it is the proximate, most-actionable signal, and a chain throw short-circuits the :db commit so there is no candidate to validate. :rejected is the LOWEST-priority discriminator for the same reason read the other way — a chain throw, a flow throw or a candidate rollback during the unwind all still win, and it is reported only when the boundary skip is the whole story. The :event vector is passed through re-frame.elision/elide-wire-value ONCE before fan-out with off-box defaults (large → :rf.size/large-elided; sensitive → :rf/redacted), so listeners can ship the wire payload to a hosted observability back-end (Datadog, Honeycomb, Sentry, …) without further shaping. Per-listener exceptions are caught inside the substrate so a buggy listener cannot break the cascade or block sibling listeners. Listener registration sites SHOULD use ^boolean re-frame.interop/debug-enabled? as a belt-and-braces gate alongside the user's explicit config flag:
(when (and (= "production" (:env config))
           (not ^boolean re-frame.interop/debug-enabled?)
           (:api-key config))
  (rf/register-listener! :events
    :datadog/forward
    (fn [event-record]
      (datadog/track event-record))))

Catches the "accidentally deployed a dev bundle with prod config" bug class. 2. The error-emit listener surface (register-listener! / unregister-listener! with the :errors stream — see API.md §Error-emit) — sibling of #1, runs through the always-on error-emit substrate. NOT gated by re-frame.interop/debug-enabled?. The runtime fans out one record per catalogued promoted runtime :rf.error/* event — handler / interceptor / cofx exceptions, flow exceptions, fx-handler exceptions, reserved-fx typed throws, reactive- and compute-sub-resolution exceptions, the machine action / guard exception :rf.error/machine-action-exception (a throwing action / guard / :on-done / destroy-:exit action — a NON-EVENT union record fanned through the :error-emit/dispatch-error-record hook, since machines ships above core's require graph), and the invalid-operation categories :rf.error/frame-destroyed, :rf.error/no-such-handler, :rf.error/no-such-sub, :rf.error/no-such-fx, :rf.error/unregistered-cofx, :rf.error/override-fallthrough, the suppressed-write category :rf.error/write-after-destroy, and the teardown-discrimination category :rf.error/on-destroy-handler-exception. (Dev-only-validation / registration-time categories — dev schema checks, machine-unresolved-guard — stay dev-trace-only and do NOT survive elision; that is correct, not a gap.) The frame-teardown report :rf.error/frame-teardown-failed also rides this surface (per §Observability channels and the promotion criterion); being a frame-lifecycle fact rather than a per-event error, its record is frame-keyed and carries a :hook-failures vector instead of the per-event :event / :event-id slots — one bounded record per destroy, flushed through a finally-shaped boundary so a partial teardown still ships it. For the per-event error categories the record is intentionally tight — {:error <kw> :event <vector> :event-id <kw> :frame <kw> :time <millis> :exception <ex> :elapsed-ms <int>} — enough discriminator for production error observability (failing event-id, frame, exception object, latency); not enough for causal reconstruction (:rf.trace/dispatch-id, source-coord, :rf.trace/trigger-handler ride the dev-only trace surface and elide with it). Attribution is a RULE, not a per-category enumeration: every always-on error record carries :failing-id <kw> naming the failing COMPONENT's own code identifier whenever that component is DISTINCT from (or more specific than) the dispatched event — plus, where the category defines them, :reason <string> and category-specific structural identifiers. The interceptor / cofx / machine categories are instances of the one rule: :rf.error/interceptor-exception (:failing-id = the failing interceptor id), :rf.error/coeffect-exception (:failing-id = the failing cofx supplier id), and :rf.error/machine-action-exception (:failing-id = the throwing action / guard keyword, PLUS :state = the active state path — machine-local code identifiers in the same privacy class as :event-id). The :event-id slot carries the EVENT id for these categories, so without :failing-id an off-box shipper would see the category but not WHICH interceptor / cofx / action / guard failed (the classified component id otherwise rides only the dev-trace tags, which DCE under goog.DEBUG=false). :failing-id is present whenever a distinct component id exists — for :rf.error/handler-exception and the :rf.error/sub-* categories the failing id already EQUALS :event-id (the handler IS the event; the sub-id rides :event-id), so the tight shape is unchanged there; an anonymous inline machine action / guard (no keyword identity) falls back to the actor instance id, with :state still attributing WHICH state's transition threw. The :event vector is passed through re-frame.elision/elide-wire-value ONCE before fan-out with off-box defaults (large → :rf.size/large-elided; sensitive → :rf/redacted). This is the single error-observability surface; recovery is the framework's typed per-category default and is NOT app-steerable (there is no per-frame :on-error recovery policy). Per-listener exceptions are caught inside the substrate so a buggy listener cannot block siblings or the cascade. Listener registration sites SHOULD use ^boolean re-frame.interop/debug-enabled? as a belt-and-braces gate alongside the user's explicit config flag, symmetric with the event-emit pattern in #1:

The corpus-wide listener carries the raw :exception; the frame-owned sink route PROJECTS it. The :errors stream of register-listener! is the advanced integration API for off-box post-mortem shippers (Sentry / Honeybadger / Rollbar), which need the host throwable and its stack — so the corpus-wide record carries the raw :exception object. This is the documented exception to the always-on axis's "structured data only — never raw values" rule (§The promotion criterion): the :event vector is elided through the wire-walker, but the opaque :exception rides raw for the shipper. The NORMAL production observation surface is the frame-owned :observability :errors sink (Spec 015 §Frame-owned observability sink policy): EVERY production-reachable :rf.error/* record routes there ALONGSIDE the listener fan-out, where the runtime PROJECTS the record under the owning frame's classification and the sink's egress profile BEFORE the sink sees it (sensitive paths redacted, :exception dropped under :rf.egress/public-error). This holds for BOTH the event-centric records (dispatch-on-error!observability/route-error!) and the NON-EVENT union records — the frame-teardown report and the promoted SSR categories (dispatch-error-record!observability/route-error-record!, which lifts the flat category slots onto a projected :tags tree-key so a :hook-failures entry's nested exception ex-data is redacted under frame policy). A FRAMELESS record (:frame nil — the pre-frame SSR hydration-parse path) reaches the corpus-wide listener only: it carries no frame-owned sink policy by definition.

Off-box shippers wire the FRAME-OWNED sink, not the raw listener. The off-box production path for Sentry / Honeybadger / Rollbar is the frame's :observability :errors sink (Spec 015 §Frame-owned observability sink policy): declare it on the frame config, register the concrete sink fn with register-observability-sink!, and the runtime hands the sink an already-PROJECTED record (sensitive paths redacted, :exception dropped under :rf.egress/public-error) — no sink-local redaction, no raw owner-local data crossing the trust boundary.

;; 1. Declare the frame's error-observability policy.
(rf/make-frame
  {:id :app/main
   :observability {:errors [{:sink :my-app.sinks/sentry
                             :rf.egress/profile :rf.egress/off-box-observability}]}})

;; 1b. Classify durable app-db paths from a handler's commit-plane effect
;;     (EP-0025 — there is no frame `:sensitive {:app-db …}` annotation; a
;;     handler returns the `:sensitive` effect alongside its `:db` write).
(rf/reg-event :app/login-succeeded
  (fn [_ [_ token]]
    {:db        (assoc-in {} [:auth :token] token)
     :sensitive [[:auth :token]]}))

;; 2. Register the concrete sink fn (gated belt-and-braces). The record is
;;    ALREADY projected — ship it as-is.
(when (and (= "production" (:env config))
           (not ^boolean re-frame.interop/debug-enabled?)
           (:dsn config))
  (rf/register-observability-sink!
    :my-app.sinks/sentry
    (fn [projected-record]
      (sentry/capture-event projected-record))))

The raw :errors stream of register-listener! remains the advanced corpus-wide integration API — reach for it only for an intentionally cross-frame hook (one fan-out across every frame) or a record the sink routing does not carry (a FRAMELESS :frame nil record). It delivers an UNPROJECTED record (the :event vector is wire-elided, but the :exception rides raw and no frame egress policy is applied), so raw owner-local data can leave a frame here — only an advanced integration that genuinely needs the host throwable + stack and accepts that posture should use it:

;; ADVANCED corpus-wide hook — unprojected, cross-frame. Not the off-box default.
(when (and (= "production" (:env config))
           (not ^boolean re-frame.interop/debug-enabled?)
           (:dsn config))
  (rf/register-listener! :errors
    :sentry/corpus-forward
    (fn [error-record]
      (sentry/capture-exception (:exception error-record)
                                {:tags {:event-id (:event-id error-record)
                                        :frame    (:frame error-record)}}))))

Use #1 and #2 together for an intentionally corpus-wide events+errors hook; for the per-frame production case, prefer the frame :observability sink above. 3. The Performance API channel (per §Performance instrumentation) — gated on the independent re-frame.performance/enabled? goog-define, default off. A production build that wants timing observability flips {:closure-defines {re-frame.performance/enabled? true}}; the bracket sites at the four hot paths (:event, :sub, :fx, :render) emit User-Timing measure entries that any PerformanceObserver — including the host APM's — reads via performance.getEntriesByType('measure'). This is the production observability surface re-frame2 ships and supports. 4. The SSR error-projector boundary (per 011 §Server error projection) — on the server (JVM/SSR), re-frame.interop/debug-enabled? is hardcoded true (per §JVM builds), so the trace surface is live. The runtime emits structured :rf.error/* traces, the registered error projector consumes them, and the locked :rf/public-error shape is written to the HTTP response. Apps with an SSR tier get the full trace + projection pipeline server-side independent of the client-side bundle's elision. 5. Native browser machinery — uncaught exceptions still reach window.onerror / window.onunhandledrejection. A re-frame2 event handler that throws in production still surfaces there; what's missing is the structured :rf.error/handler-exception shape, the :rf.trace/dispatch-id correlation, and the :rf.trace/trigger-handler coord — those rode the trace surface. Prefer the error-emit listener (#2) for structured access to the failing handler's id and the exception.

Observability decision matrix — five surfaces × three postures

The prose above catalogues the surfaces in elision-framing — what disappears under goog.DEBUG=false and what survives. Users wiring up observability typically arrive with the opposite framing: "which surface do I use for this use case?" This subsection flips the framing and pins, for each of the five observation surfaces, the production posture, the record shape, and the use cases the surface serves.

The framework exposes five observation surfaces:

  1. Raw trace listenerregister-listener! / unregister-listener! (§Listener API).
  2. Assembled-epoch listenerregister-epoch-listener! / unregister-epoch-listener! (§Assembled-epoch listener, Tool-Pair §Time-travel).
  3. Event-emit listenerregister-listener! / unregister-listener! (the :events stream) (API.md §Event-emit).
  4. Error-emit listenerregister-listener! / unregister-listener! (the :errors stream) (API.md §Error-emit).
  5. Performance API channelperformance.measure brackets (options-bag form, cleared after emit) (§Performance instrumentation).

Each surface sits in exactly one of three production postures:

  • dev-only DCE — gated on re-frame.interop/debug-enabled? (alias of goog.DEBUG, default true in dev / false in :advanced prod CLJS, default true JVM with -Dre-frame.debug=false opt-out). Compile-time eliminated in production; allocates zero in the bundle. Per §Production builds.
  • always-on — runs through a small substrate that survives goog.DEBUG=false (and survives -Dre-frame.debug=false JVM-side). Tight record shape, post-elision (large → :rf.size/large-elided; sensitive → :rf/redacted), per-listener exceptions isolated. Designed for production observability without preserving the dev-only trace surface.
  • opt-in goog-define — gated on an independent compile-time flag distinct from goog.DEBUG. Default off; consumer flips the flag explicitly via :closure-defines. Production bundles that don't opt in carry zero instrumentation; those that do retain the surface even with goog.DEBUG=false.

Posture × surface matrix

Surface Dev (goog.DEBUG=true) Nightly (goog.DEBUG=true + events-retained tuned) Production (goog.DEBUG=false) Posture
1. Raw trace listener (register-listener!) live — full structured trace stream, every :op-type (:rf.event, :rf.sub, :rf.fx, :error, :warning, :rf.machine/*, :rf.flow/*, …), dev-side enrichments (:rf.trace/trigger-handler source-coord, :rf.trace/dispatch-id / :rf.trace/parent-dispatch-id correlation, :rf.event/origin tag) live — same as dev; tune per-frame event retention via (rf/configure! {:trace-buffer {:events-retained N}}) (or per-frame :rf.trace/events-retained metadata) for long-tail traces elidedemit! gate constant-folded; registration is a no-op, listener never invoked; zero allocation in bundle dev-only DCE
2. Assembled-epoch listener (register-epoch-listener!) live — one :rf/epoch-record per dequeued event / epoch (per Tool-Pair §Time-travel); :frame-state-before / :frame-state-after / :trace-events payload; (rf/configure! {:epoch-history {:depth N :trace-events-keep N :redact-fn fn}}) controls retention and per-record redaction live — bump :depth for longer post-mortem windows; :redact-fn applies only at off-box egress inside projected-record — never at ring-append / listener fan-out (the ring and this listener retain the raw record) elided — projection runs inside the trace surface and elides with it; epoch ring records nothing, listeners never fire, restore-epoch! / the pair-tool injection surfaces refuse dev-only DCE
3. Event-emit listener (register-listener! :events) live — one tight record per processed event: {:event :event-id :frame :time :outcome :elapsed-ms}, post-elision live — same record shape; no tuning knobs live — survives goog.DEBUG=false; identical record shape and elision; per-listener exceptions isolated; consumer SHOULD belt-and-braces (when (not ^boolean re-frame.interop/debug-enabled?) …) registration to catch dev-bundle-with-prod-config bug class always-on
4. Error-emit listener (register-listener! :errors) live — one tight record per catalogued promoted runtime :rf.error/* event (handler / interceptor / cofx / flow / reserved-fx exceptions, reactive- & compute-sub exceptions, the parametric sub-input materialization categories :rf.error/sub-input-fn-exception / :rf.error/sub-input-fn-bad-return, :rf.error/frame-destroyed, :rf.error/no-such-handler, :rf.error/no-such-sub): {:error :event :event-id :frame :time :exception :elapsed-ms}, post-elision. The single error-observability surface; recovery is the framework's typed per-category default, not app-steerable (the per-frame :on-error recovery policy was removed) live — same record shape live — survives goog.DEBUG=false; identical record shape and elision; per-listener exceptions isolated always-on
5. Performance API channel default offre-frame.performance/enabled? defaults to false; bracket sites elided. Apps that want timing in dev opt in via :closure-defines {re-frame.performance/enabled? true} and read via performance.getEntriesByType('measure') or DevTools Performance panel default off — same as dev; opt in for nightly perf regression catches default off — bracket sites DCEd. Apps that want production timing observability opt in via the same :closure-defines flag; brackets at the four hot paths (:event, :sub, :fx, :render) emit User-Timing measure entries readable by any PerformanceObserver including the host APM opt-in goog-define

Posture-row reading. A surface in the dev-only DCE row is gone from production bundles — no listener, no allocation, no overhead. A surface in the always-on row keeps firing under goog.DEBUG=false; the record is tight, post-elision, exception-isolated. A surface in the opt-in goog-define row is gone by default but recoverable in production without preserving the full trace surface — the consumer flips one independent compile-time flag.

Use case × surface routing

The same five surfaces map onto the canonical observability use cases. The table below pins, for each use case, the surface that fits and the surface that does NOT (with the reason — typically posture mismatch or wrong record shape).

The five surfaces are the substrate; the normal production-monitoring entry point sits on top of them. For an app shipping handled-event metrics and error records off-box (Datadog / Sentry / Honeycomb / a custom pipeline), the first surface to reach for is not a raw listener — it is the frame :observability sink declared with register-observability-sink! (per 015 §Frame-owned observability sink policy), which routes an already-projected record and lowers onto surfaces #3 / #4 below. Drop to the raw :events / :errors streams of register-listener! directly only for an intentionally corpus-wide hook (one fan-out across every frame rather than per-frame policy) or a record the sink routing does not carry. The #3/#4 "Recommended surface" cells in the table below name the always-on substrate a production use case rides; the frame :observability sink is the declarative entry point on top of that substrate, and is the preferred wiring for the per-frame case. The full consumer-facing ordering — frame sink first, raw listeners second, dev register-listener! never a production wire, SSR projector server-tier-only — is the guide §16 entry-point hierarchy.

Use case Recommended surface Why this one What to avoid
Real-time UI dashboard (re-frame-10x style) — live run view, per-domino timing, error highlighting in dev #1 raw trace listener + #2 epoch listener (composed) Need every :op-type event with dev-side enrichments (:dispatch-id correlation, source-coord, :origin). Tools like re-frame-10x consume both: raw stream for the timeline, epoch records for the structured per-run slice. Dev-only is the right posture (the dashboard isn't shipped to production). Don't use #3 / #4 — the tight record shape strips correlation fields the dashboard needs. Don't use #5 — Performance API is timing-only, no semantics.
Off-box APM forwarder (Datadog, Honeycomb, New Relic) — ship event throughput + latency to a hosted backend, including in production #3 event-emit listener + #4 error-emit listener Always-on posture is mandatory (the forwarder MUST run in production). Tight record shape is the contract — already post-elision, already wire-shaped. Per-listener exceptions isolated. Don't use #1 — it's elided in production. Don't use the dev-only stream then "promote" via goog.DEBUG=true in prod just to get APM — that ships the entire trace surface for no benefit.
Post-mortem error monitor (Sentry, Rollbar, Honeybadger) — capture handler exceptions with frame + event-id context, ship to hosted backend #4 error-emit listener The corpus-wide listener rides the always-on error-emit substrate; fires in production. Receives {:error :event-id :frame :exception …} tight shape — enough for the monitor's tags / extra fields. It observes; recovery is the framework's typed per-category default (not app-steerable). Don't rely on window.onerror alone — it sees the bare exception without re-frame2's structured frame/event-id context. Don't use #1 in production — it's elided.
Performance budget (CI perf-regression gate, real-user monitoring) — measure event / sub / fx / render timing against a budget #5 Performance API channel Designed for this use case. Production-survivable via the independent re-frame.performance/enabled? flag; surfaces in DevTools Performance panel and the host APM's PerformanceObserver; zero overhead when not opted in. Don't use #1 — it's elided in production. Don't use #3 — the :elapsed-ms field is event-level only; #5 brackets sub / fx / render too.
Custom recorder (in-app debug overlay, story-runner-style replay capture) #2 epoch listener Each record is a raw assembled per-run slice (:db-before, :db-after, :trace-events — causal replay material, not projected). A local-only recorder may append it as-is; any record that then egresses to a log, tool, or process boundary must first be routed through projected-record (where the frame/profile projection and the :redact-fn apply — never at ring-append / listener fan-out). (rf/configure! {:epoch-history {:depth N :redact-fn fn}}) controls retention. Dev-only is the right posture for a debug overlay. Don't use #1 — raw trace stream requires per-run grouping logic the consumer would have to reimplement.
Framework's own SSR error projection — turn runtime errors into the locked :rf/public-error HTTP-wire shape on the JVM/SSR tier #4 error-emit listener (per 011 §Server error projection) Production-required surface (an SSR error is by definition a production-survivable concern). Always-on substrate survives both goog.DEBUG=false (CLJS) and -Dre-frame.debug=false (JVM, when the operator opts out per Security.md §Production gates). Don't route SSR error projection through #1 — it's gated by interop/debug-enabled?, which an SSR JVM facing untrusted input is explicitly directed to disable. Routing through #4 keeps the projector firing under both postures.

Combining surfaces

The five surfaces are independent — registering a listener on one does NOT register on the others. Common compositions:

  • Full dev observability: #1 + #2 + #5. Raw stream feeds the dashboard, epoch listener feeds the recorder / pair tool, Performance API feeds DevTools timing.
  • Full production observability: #3 + #4 + #5. Event throughput + latency to APM (#3), error monitoring (#4), timing budget (#5).
  • Hybrid: app with both a dev-time dashboard AND a hosted production monitor uses #1 + #2 in dev (registered under (when ^boolean re-frame.interop/debug-enabled? …)) and #3 + #4 always. The dev-only registrations elide in production; the always-on registrations survive.

Tuning knobs by posture

Each posture row has a small set of runtime knobs (orthogonal to the elision gate itself):

Posture Knob Effect Surface(s) affected
dev-only DCE (rf/configure! {:trace-buffer {:events-retained N}}) Per-frame per-event ring for register-listener! late-attach (N=0 opts out of the ring entirely; default 50; per-frame override via the :rf.trace/events-retained frame-config key) #1
dev-only DCE (rf/configure! {:epoch-history {:depth N :trace-events-keep N :redact-fn fn}}) Epoch ring depth, per-record trace-event budget, per-record redaction hook for sensitive payloads #2
dev-only DCE (rf/configure! {:elision {:rf.size/threshold-bytes N}}) Per-payload size threshold for :rf.size/large-elided marker in trace records #1, #2 (records ride post-elision)
always-on none — record shape is fixed by contract Listeners receive identical record shapes in dev and prod #3, #4
opt-in goog-define :closure-defines {re-frame.performance/enabled? true} Enables the four performance.measure bracket sites (:event, :sub, :fx, :render) #5
opt-in goog-define :closure-defines {re-frame.performance/retain-entries? true} Skips the per-emit performance.clearMeasures so entries persist in the retained buffer for one-shot getEntriesByType('measure') readers (DevTools / console); default off — entries are delivered to observers then cleared #5

The goog.DEBUG flag (CLJS) and the -Dre-frame.debug system property / RE_FRAME_DEBUG env var (JVM) are not user knobs — they're the build-time/process-start gates that select the posture. Apps DO NOT toggle them per-request or per-session; once the bundle is compiled (or the JVM is started), the posture is fixed.

Off-box egress contract

Three of the five surfaces are designed to feed off-box (hosted) backends; the contract differs:

Surface Off-box ready? Record shape Privacy guarantee
#1 raw trace listener NO — dev-only; not present in production bundles. Apps SHOULD NOT ship trace records to a hosted backend in dev as a substitute for #3 / #4 (the record is much larger and carries dev-side fields irrelevant to APM) Full structured trace event with :tags open bag, source-coord, :dispatch-id correlation The dev-side register-listener! runs after :sensitive? substrate-level scrubbing per §Privacy / sensitive data in traces.
#2 epoch listener NO — dev-only; epoch records carry full :db-before / :db-after snapshots and are not sized for hosted ingestion Assembled :rf/epoch-record per Tool-Pair §Time-travel The listener delivers the raw record (causal replay material). The :epoch-history :redact-fn is a projection-side override applied only at off-box egress inside projected-record — never at ring-append / listener fan-out — so it does not redact what this listener receives.
#3 event-emit listener YES — tight record shape, post-elision, exception-isolated. Designed for direct hosted-backend forwarding {:event :event-id :frame :time :outcome :elapsed-ms} :event vector passed through re-frame.elision/elide-wire-value once before fan-out (large → :rf.size/large-elided; sensitive → :rf/redacted).
#4 error-emit listener YES — same posture and shape contract as #3 {:error :event :event-id :frame :time :exception :elapsed-ms} Same elision pre-fan-out as #3; :exception object is the JS / JVM throwable, not a serialised string.
#5 Performance API channel INDIRECT — User-Timing measure entries are consumed by host APMs through PerformanceObserver; the framework does not emit to a hosted backend directly. Entries are delivered to observers then cleared from the retained buffer (observer-first; see §Consumer access) Browser-native PerformanceMeasure entries with name / startTime / duration (no detail is set — the options-bag measure call carries only start / end timestamps) No payload — measures carry only timing, not user data.

The off-box egress contract above is the only documented production wire. Apps that need richer production observability than #3 / #4 / #5 provide must either (a) keep the dev-only trace surface in production via :closure-defines {goog.DEBUG true} (with the bundle-size cost — see §Production-elision verification) or (b) implement custom emission from their own handlers / interceptors / fx handlers.

Wiring an external error monitor (Sentry, Rollbar, Honeybadger, etc.)

The dev-side integration documented at §Composition with libraries routes structured trace events into the monitor:

;; Dev: full structured trace, captured before the runtime's default recovery.
(rf/register-listener! :trace
 :sentry/forward
 (fn [trace-event]
   (when (= :error (:op-type trace-event))
     (sentry/capture-event
      {:level      "error"
       :message    (-> trace-event :tags :reason)
       :tags       {:rf-operation  (name (:operation trace-event))
                    :rf-frame      (some-> trace-event :tags :frame name)
                    :rf-dispatch   (some-> trace-event :tags :rf.trace/dispatch-id str)
                    :rf-failing-id (some-> trace-event :tags :failing-id str)}
       :extra      (:tags trace-event)
       :fingerprint [(name (:operation trace-event))
                     (str (-> trace-event :tags :failing-id))]}))))

In a production CLJS build with goog.DEBUG=false, the register-listener! call and its body sit under the (when ^boolean re-frame.interop/debug-enabled? …) user-side guard (per §User-side listener registration) and elide entirely. The trace-listener fan-out (:rf.trace/dispatch-id correlation, :rf.trace/trigger-handler source-coord, the per-frame trace rings) is dev-only. Three integration patterns survive elision:

  • Recommended for structured fields: register the monitor through the corpus-wide :errors stream of register-listener! (per §Error observability). The listener rides the always-on error-emit substrate, NOT the trace surface — registered listeners fire under :advanced + goog.DEBUG=false. The listener receives the tight record ({:error :event :event-id :frame :time :exception :elapsed-ms} plus :source-coord for macro-registered handlers), forwards to the monitor, and observes only — recovery is the framework's typed per-category default. This is the recommended production-monitor integration. The substrate covers every promoted runtime :rf.error/* (the promotion-criterion set, not every production-reachable one — a caller-observed pure throw surfaces at the native error boundary instead); dev-side enrichments (:rf.trace/dispatch-id, :rf.trace/trigger-handler, per-frame rings) are not carried.
  • Native-SDK fallback: install the monitor's native browser SDK at the top of the bundle (Sentry.init({...})). It captures window.onerror, window.onunhandledrejection, and any explicit Sentry.captureException call wherever the app already has error-boundary plumbing. The trade-off is loss of re-frame2's structured fields — the monitor sees the bare exception, not the cascade context. Use this when the app already has wider-scope error-boundary plumbing or when handler-exception coverage alone is insufficient.
  • Opt-in to keep the trace surface: ship :advanced with :closure-defines {goog.DEBUG true}. The trace surface is preserved, the register-listener! sample above runs, and the monitor receives full structured events including dev-side enrichments (:dispatch-id, :rf.trace/trigger-handler, the per-frame rings). The cost is the trace machinery's bundle size (see §Production-elision verification for the size delta — the control bundle is the reference measurement). This is the explicit escape hatch for apps where post-mortem fidelity outweighs bundle weight.

Hot path in dev builds

Dev iteration matters; you don't want trace machinery to slow ordinary feedback loops. Two hot-path costs are present in dev:

  1. Trace-event allocation — building the trace map per emit.
  2. Listener invocation — invoking register-listener! callbacks once per emitted event.

Cheap-path discipline (dev builds only)

  • Listener registry is a single atom. Reading it is one deref.
  • No string formatting or other expensive work happens in framework emit code; tools format if they want to.
  • Listener invocation cost scales with listener count. Zero registered listeners means zero per-emit dispatch overhead beyond the registry deref. The per-frame trace ring always appends to the in-flight cascade's slot (its append is swap! plus a slot lookup), so the floor is one map allocation, one ring append, and one deref per emit; frameless emits skip the ring entirely (per the B3 ruling above) so their floor is the listener fan-out alone.

Performance instrumentation

The trace stream above is dev-only — too noisy for prod, gated on re-frame.interop/debug-enabled? (an alias of goog.DEBUG). Many apps still want a separate, default-off, prod-friendly timing channel: one that surfaces in Chrome DevTools' Performance panel alongside React renders, network, and paint, and that consumers (the host's APM, a custom PerformanceObserver, an in-app perf overlay) can read via the standard browser User Timing surface.

re-frame2 ships that channel through the browser's performance.measure (options-bag form, no marks — see §What gets bracketed), gated on a second compile-time constant — re-frame.performance/enabled? — that is independent of goog.DEBUG. The default is off; consumers opt in by flipping the goog-define via :closure-defines. Closure DCE then either keeps the bracket sites or elides them entirely; production binaries that don't ask for timing carry zero User-Timing instrumentation.

This is distinct from the trace surface above:

Axis Trace stream Performance instrumentation
Compile-time gate re-frame.interop/debug-enabled? (alias of goog.DEBUG) re-frame.performance/enabled?
Default on in dev (goog.DEBUG=true), off in prod off in both (enabled?=false)
Consumer register-listener! listeners, the per-frame trace rings, register-epoch-listener! performance.getEntriesByType('measure'), PerformanceObserver, Chrome DevTools Performance
Shape structured trace events (open maps with :operation / :op-type / :tags) User Timing measure entries (name, startTime, duration)
Where it runs both platforms (dev) CLJS only — JVM is a no-op

The two flags compose: a build that wants both flips both. A typical prod build has goog.DEBUG=false and either re-frame.performance/enabled? true (perf timing kept; trace elided) or false (everything elided).

The compile-time flags

;; src/re_frame/performance.cljc
(goog-define ^boolean enabled?        false)   ; the channel gate
(goog-define ^boolean retain-entries? false)   ; keep entries in the retained buffer

A consumer flips the channel gate in their shadow-cljs.edn / compiler-options:

{:builds {:app {:target           :browser
                :output-dir       "..."
                :compiler-options {:closure-defines {re-frame.performance/enabled? true}}}}}

Like goog.DEBUG, :advanced constant-folds the value, the gated branch DCEs, and the body collapses to its un-bracketed shape — for the perf surface that means each call site becomes a direct invocation of the body it brackets.

retain-entries? is a second, independent goog-define (default false). Leave it off for production (RUM) — entries are delivered to observers then cleared so the buffer does not grow. Flip it on (:closure-defines {re-frame.performance/retain-entries? true}) only for one-shot DevTools / console workflows that read the retained buffer via getEntriesByType('measure'). See §Consumer access.

What gets bracketed

The reference runtime brackets four hot-path call sites. Each runs inside a (performance/mark-and-measure :<bucket> <id> <body>) macro form so the bracket is a compile-time decision (the macro expands to (if enabled? <gated-bracket> (do <body>)), which Closure constant-folds):

Bucket Where Entry name
:event Event handler invocation (router's process-event* step that runs the interceptor chain) rf:event:<event-id>
:sub Subscription recompute (the body fn inside compute-and-cache!'s reaction) rf:sub:<sub-id>
:fx Per-fx walk-step (every entry processed by handle-one-fx, including reserved fx-ids :dispatch / :dispatch-later / :rf.fx/reg-flow / :rf.fx/clear-flow and user-registered fx) rf:fx:<fx-id>
:render Per-reg-view render (the wrapper emitted by reg-view*) rf:render:<view-id>

The bracket shape (when the flag is on at compile time):

start = performance.now()
try    <body>
finally
  performance.measure(<name>, { start, end: performance.now() })
  if (!retain-entries?) performance.clearMeasures(<name>)

Two design choices bound the entry buffer (per the observer-first contract, §Consumer access):

  • Options-bag measure, no marks. The bracket uses the options-bag form performance.measure(name, {start, end}) with numeric performance.now() timestamps rather than named marks. No performance.mark entries are ever allocated — the two marks per bracket were pure buffer growth with no documented consumer (nothing reads the :start / :end marks). This removes two-thirds of the per-bracket entry churn.
  • Clear after emit. Immediately after emitting the measure the bracket clears it by name (performance.clearMeasures(name)), unless the consumer flips the re-frame.performance/retain-entries? goog-define (default off). A live PerformanceObserver still receives the entry — observer callbacks fire at measure() time, before the clear — so timing is delivered to any attached observer / host APM; the retained buffer that getEntriesByType('measure') reads simply does not grow.

The try/finally ensures the measure entry is emitted (and delivered to observers) even when the body throws — observability does not become silent on the unhappy path. The thrown exception still propagates after the finally runs.

retain-entries? (a second goog-define, default false) is the escape hatch for one-shot DevTools / console workflows that read the retained buffer via performance.getEntriesByType('measure'): flip it on and the per-emit clear is skipped so entries persist. Long-running (RUM) sessions leave it off and read via a PerformanceObserver — see §Consumer access.

Naming convention

Every entry name uses the shape rf:<bucket>:<id>, so consumers filter by the rf: prefix without parsing per-bucket shapes. Keyword ids preserve their namespace:

rf:event:user/login
rf:sub:cart/total
rf:fx:dispatch
rf:fx:rf.http/managed
rf:render:my.app/page-header

Tools that want a per-bucket view split on the second :. The shape is stable: new buckets adopt the rf:<bucket>:<id> convention and are additive.

Consumer access

The channel is observer-first: entries are delivered, not retained. Each bracket emits its measure and then clears it from the retained buffer (unless retain-entries? is on — see §What gets bracketed), so the primary read is a PerformanceObserver, which receives the entry at emit time regardless of the clear:

// Live: a PerformanceObserver fires per emitted entry — the production
// (RUM) path. Delivery happens at measure() time, before the framework
// clears the entry from the retained buffer, so the observer sees
// every rf: measure even with buffer retention off (the default).
new PerformanceObserver((list) => {
  for (const e of list.getEntriesByType('measure')) {
    if (e.name.startsWith('rf:')) {
      // entry: { name, startTime, duration }
      sendToAPM(e);
    }
  }
}).observe({ type: 'measure', buffered: true });

// One-shot DevTools / console snapshot: only populated when the app was
// built with :closure-defines {re-frame.performance/retain-entries? true}.
// With retention off (the default) this returns [] — the entries were
// delivered to observers then cleared.
performance.getEntriesByType('measure')
  .filter(e => e.name.startsWith('rf:'));

Chrome DevTools' Performance panel renders the measures as named tracks alongside React renders, network, and paint — no custom UI required (the panel captures entries as they are emitted, so it too is unaffected by the post-emit clear).

Why clear after emit. The W3C User-Timing registry defines maxBufferSize as Infinite for mark and measure entries — the buffer is not bounded by the host. An always-on production channel that never cleared would grow the buffer without limit: a multi-hour RUM session would retain millions of entries and leak memory. re-frame2 therefore clears each measure after emit; the retained buffer is bounded by re-frame2, not the host. Long-running pages read via the PerformanceObserver above (which is unaffected by the clear) and offload to durable storage; they do not rely on the retained buffer. Consumers that genuinely want a retained buffer for one-shot inspection opt in via retain-entries?.

Production-elision verification

The bundle-isolation contract is enforced in CI by npm run test:perf-bundle (the dual of npm run test:elision):

  1. :examples/counter builds the standard counter example under :advanced with the perf flag off (the goog-define default).
  2. :examples/counter-perf builds the same source under :advanced with :closure-defines {re-frame.performance/enabled? true}.
  3. scripts/check-perf-bundle.cjs greps both bundles. The contract:
  4. Off bundle MUST NOT contain performance.measure, clearMeasures, or any "rf: entry-name fragment (and — since the bracket allocates no marks — MUST NOT contain performance.mark either, in both bundles).
  5. On bundle MUST contain performance.measure, clearMeasures, and the "rf: fragment.

Without the on bundle the off-bundle assertion would be vacuous — a refactor that moved the strings out of the gated branch would silently turn the negative grep into a false pass. The same dual-bundle methodology that gives the trace-surface elision contract its teeth (per §Production-elision verification) extends to the perf surface here.

A CLJS unit test (re-frame.performance-cljs-test) asserts the observer-first contract directly at the macro level: with the flag on and retain-entries? off, the retained buffer stays empty after repeated brackets (the clear-after-emit leak fix), and zero rf: mark entries are ever allocated (the options-bag form). The emission call-site coverage lives in the nightly re-frame.performance-emit-nightly-test (which flips retain-entries? true so it can read the entry names synchronously).

JVM scope

The Performance API is browser-only. The JVM half of re-frame.performance:

  • Defines enabled? as ^:const false so the macro expansion's (if enabled? ...) is statically dead and the JVM body runs as if instrumentation were absent.
  • Expands mark-and-measure to (do body...) — pure pass-through, no instrumentation overhead.

JVM artefacts (headless tests, SSR, Pedestal/Ring services using re-frame2 for state) that want timing should reach for the host's profilers (clj-async-profiler, JFR, async-profiler).

Forward compatibility for tools

External tools consume re-frame2 through stable surfaces. Production builds elide the entire trace surface; everything in this section is dev-only.

Stable surfaces consumed by every tool

Surface Stability
register-listener! / unregister-listener! Preserved
Synchronous, event-at-a-time delivery Preserved
Trace event shape (:id, :operation, :op-type, :time, :tags) Preserved exactly
:op-type discriminator vocabulary (:rf.event, :rf.sub, :rf.fx, :rf.view, :rf.frame, :rf.machine, :warning, :error, ...) Preserved; new values additive
:tags for op-type-specific data (:frame (bare carve-out), :rf.trace/event-id, :rf.event/v, :db-before, :db-after, :rf.trace/dispatch-id, :rf.trace/parent-dispatch-id, :rf.event/origin, ...) Preserved
Hoisted top-level fields (:source, :recovery) Preserved
re-frame.interop/debug-enabled? (alias of goog.DEBUG) Preserved
Compile-time elision via goog.DEBUG=false + :advanced Preserved
Public registrar query API (registrations/handler-meta/frame-ids/frame-meta/app-db-value/frame-state-value/sub-topology/sub-cache) See 002 §The public registrar query API
Hot-reload notifications (:rf.registry/handler-registered, :rf.registry/handler-cleared, :rf.registry/handler-replaced, :rf.frame/created, :rf.frame/destroyed) Trace events

Capabilities tools depend on

  • Multi-frame UI — frame selector; per-frame trace slicing via (get-in ev [:tags :frame]); per-frame app-db via (app-db-value id).
  • Epoch-per-event semantics — each dequeued event is its own epoch (per 002 §Drain versus event); a drain may settle several events run-to-completion, but each (incl. an :fx-dispatched child or the frame-init event) yields its own record. Per-run correlation rides on :dispatch-id / :parent-dispatch-id (per §Dispatch correlation). The fully-assembled :rf/epoch-record (per Spec-Schemas) provides the structured projection.
  • Machine trace types:op-type values (:rf.machine/transition, etc.) for state-machine activity.
  • Per-frame override visibility:fx-overrides/:interceptor-overrides are inspectable via (frame-meta id).

Programmatic interaction surfaces

  • Generate test cases from trace history.
  • Suggest refactors based on registry inspection.
  • Drive interactions via dispatch-sync.
  • Snapshot state, modify, restore.
  • Read state in any frame; frame-ids enumerates them.

The compile checker report

Not every stable surface a tool consumes is a runtime one. Freehand's compile checker analyses a view declaration read-only, before the compiled tier has been selected for it, and answers stable EDN carrying the view id, its source coordinates, its current lowering, the grammar version it was checked against, whether it is eligible, and — when it is not — findings each carrying a stable id, coordinates, the offending form, a reason, and a recovery ladder. The shape and its laws are owned by 004D §The read-only checker; what this Spec records is where the ids in it come from, because "stable ids/source/recovery" is a diagnostics obligation and a tool author reading this catalogue is entitled to an answer rather than a silence.

The checker mints no ids. A finding carries the analyzer's own :rf.ui.compile/<kebab-id> — the id the build would fail with on the same form — and the recovery ladder the compiler already serves. One roster with two consumers: a checker with a parallel id set would be a second grammar to drift, and a finding that disagreed with the build failure for the same form would be worse than no finding.

Those ids carry no error-catalogue row, by construction rather than by exception. :rf.ui.compile/* is reserved in Conventions as a compile-time only namespace: every id in it is raised at macroexpansion, nothing in it is ever emitted at runtime, and none of it is a trace event. The error/warning catalogue below is a contract about runtime-emitted events, so a compile-tier id has nothing to be catalogued as — the same reason 004D §Compile-tier warnings gives for the warning roster. Because the checker adds no id, it adds no catalogue obligation either.

Surface Stability
The report's six fields (:view-id, :source, :current-lowering, :target-grammar, :compile-eligible?, :findings) Preserved
The finding's five fields (:id, :source, :form, :reason, :recovery) Preserved
Finding ids The analyzer's :rf.ui.compile/* roster; new values additive
:reason A closed roster of unqualified keywords; several ids may share one; new values additive
:recovery A closed roster, ordered most specific first, always ending in :keep-interpreted
:target-grammar The version keyword the body was checked against — a report read later says which language answered

JVM vs. CLJS scope

All trace functionality is dev-build only — production builds elide the entire trace surface on both platforms.

Capability (dev builds) JVM CLJS
Trace event emission
register-listener! / unregister-listener!
register-epoch-listener! / unregister-epoch-listener!
Per-frame trace rings (trace-buffer)
Hot-reload trace events
Performance API instrumentation (rf:event:* / rf:sub:* / rf:fx:* / rf:render:* measures) ✓ (default-off; see §Performance instrumentation)
Xray panel itself
re-frame-pair attachment

Trace data is just data; both platforms emit it during dev. The Performance API bridge is browser-specific; everything else works headless.

Handler-scope: the in-scope reading at emit time

Every handler-execution boundary the runtime crosses (the router's process-event! step, a sub recompute, an fx dispatcher, a cofx injector, a view render wrapper) publishes the same five-slot handler-scope reading to the trace stream so emit! / emit-error! can hoist the relevant pieces onto each emitted event. The reading travels through ONE dynamic Var — re-frame.trace/*handler-scope* — bound to a HandlerScope record with five slots (the §Canonical slot set below is the authoritative slot vocabulary; this table is the at-a-glance summary):

Slot Carries
:trigger-handler Registration coord of the in-scope handler — {:kind :id :source-coord {...}} or nil when no source-coord is stamped. Hoisted as the top-level :rf.trace/trigger-handler field on every emit (success and error paths).
:call-site Compile-time invocation coord of the surface reached through its macro form (dispatch, dispatch-sync, subscribe) — {:ns :file :line :column} or nil for fn-form callers. Hoisted as :rf.trace/call-site on every emit (success and error).
:dispatch-id (HandlerScope slot) Per-event correlation id — allocated once per dequeued event at router.cljc's process-event! (the epoch unit; one per dispatch, incl. each :fx-dispatched child) and merged into :tags :rf.trace/dispatch-id of every event emitted inside that one event's run. :raise/:always microsteps ride the triggering event's id. (The internal scope-record slot keeps the short name; the emitted trace tag is the namespaced :rf.trace/dispatch-id.)
:sensitive? Boolean. True when the router computed a schema-derived sensitive-path overlap for the in-scope handler (the handler-meta annotation has been removed). Emitted events get a top-level :sensitive? true stamp; absent reads as false (per §Privacy / sensitive data in traces).
:no-emit? Boolean. True when the in-scope handler's registration meta carries :rf.trace/no-emit? true. emit! / emit-error! short-circuit (no envelope allocation, no listener fan-out) when bound true.

Composition

The innermost handler-scope binding wins for the meta-derived slots (:trigger-handler / :sensitive? / :no-emit?). The :call-site and :dispatch-id slots are inherited from the parent scope unless the new scope explicitly overrides them — call-site originates at macro expansion time and rides through nested scopes; dispatch-id is allocated once per cascade and survives the handler-chain → sub recompute → fx → cofx descent. The constructor and binding macros in re-frame.trace (with-handler-scope, with-call-site, with-dispatch-id+call-site) handle inheritance automatically.

For :rf.fx/handled specifically: the runtime rebinds :trigger-handler to the fx handler's own registration meta around the fx body's invocation and the success-path emit that follows — consumer tools jump to the reg-fx site, not the enclosing event handler. Reserved fx-ids (:dispatch, :dispatch-later, :rf.fx/reg-flow, :rf.fx/clear-flow) have no registration site of their own; their :rf.fx/handled traces carry the enclosing event handler's coord (the outer binding).

Production elision

The whole trace surface compiles out via the outer (when interop/debug-enabled? ...) gate in emit! / emit-error!, so all *handler-scope* reads are dead code under :advanced + goog.DEBUG=false. The :trigger-handler slot is not separately elided — there is no second gate that selectively drops the slot while keeping the rest of the event — but it rides only on emitted trace events, which the whole-surface gate elides in default production. Production-surviving source coordinates for error observability come from a separate always-on channel, not from this trace slot: error-emit/dispatch-on-error! carries a tight :source-coord looked up from the always-on error-coords-by-id registry (per §:rf.trace/trigger-handler and Spec 001 §Source-coordinate capture), which is not a TraceEvent and does not carry the :rf.trace/trigger-handler slot.

Canonical slot set — the stable contract

The HandlerScope record's slot set is a stable contract consumed at every emit site (build-event in re-frame.trace) and at every binding site (the router's process-event!, fx / cofx dispatchers, sub recompute wrappers, view render wrappers, plus surface macros dispatch / dispatch-sync / subscribe). Downstream tools (Story, Xray, re-frame2-pair, 10x) read the hoisted slots off emitted events; the table below is the authoritative slot vocabulary they may rely on.

Slot Value shape Origin Inheritance
:trigger-handler {:kind :id :source-coord {:ns :file :line :column}} or nil. :kind is one of #{:event :sub :fx :cofx :view :machine :flow :route :error-projector}; :source-coord is whatever the registrar slot's meta carried (omitted for programmatic registrations). Read off the in-scope handler's registration meta by handler-scope-from-meta at scope-bind time. Innermost wins (meta-derived).
:call-site {:ns :file :line :column} or nil. Macro-expansion coord stamped by the surface form (dispatch, dispatch-sync, subscribe). Nil for fn-form callers. Stamped by the surface macro via with-call-site or with-dispatch-id+call-site. Inherited from parent scope unless the new scope explicitly overrides.
:dispatch-id Opaque scalar (process-monotonic counter, UUID, or any value with the §Dispatch correlation uniqueness contract). Nil outside any in-flight cascade. Allocated once at queue time by router.cljc's enqueue!; published into the scope by with-dispatch-id+call-site on entry to process-event!. Inherited from parent scope unless the new scope explicitly overrides.
:sensitive? Boolean. True iff the router computed a schema-derived sensitive-path overlap for the in-scope handler (see §Privacy / sensitive data in traces). The legacy handler-meta :sensitive? annotation has been removed in favour of path-marked classification. Computed in the router's prepare-handler-ctx and threaded onto the scope-meta as :rf/sensitive? for handler-scope-from-meta to lift into the scope's :sensitive? slot. Innermost wins (scope-derived).
:no-emit? Boolean. True iff the in-scope handler's registration meta carries :rf.trace/no-emit? true. Read off the in-scope handler's registration meta by handler-scope-from-meta at scope-bind time. Innermost wins (meta-derived).

Slot values are nil when unbound. Consumers reading a slot off an event MUST treat absent and nil identically (nil-safe access).

Emit-side hoist contract — which slot rides which trace

build-event (in re-frame.trace) reads *handler-scope* once per emit and lifts the slots onto the trace envelope according to a fixed per-slot contract. The table below pins the mapping; per-slot variations live in §The error event shape and §Privacy / sensitive data in traces and are summarised here:

Slot Hoisted as When Notes
:trigger-handler top-level :rf.trace/trigger-handler every emit (success and error) when bound Omitted entirely when unbound (no placeholder data). Per §:rf.trace/trigger-handler.
:call-site top-level :rf.trace/call-site every emit (success and error) when bound Per the hoist widened from error-only to all emits — the Event lens and any consumer rendering jump-to-source on success-path events (:rf.event/dispatched, :rf.fx/do-fx, :rf.fx/handled) needs the dispatch-site coord on the cascade entry, not just on errors. Omitted entirely when unbound. Per §:rf.trace/call-site.
:dispatch-id (HandlerScope slot) :tags :rf.trace/dispatch-id every emit when bound and :tags does not already supply one Caller-supplied :tags :rf.trace/dispatch-id wins. Per §Dispatch correlation.
:sensitive? top-level :sensitive? true every emit when scope is sensitive and :tags :sensitive? does not supply its own reading Caller-supplied :tags :sensitive? wins (queue-time :rf.event/dispatched computes its own reading before scope is bound). Absent reads as false. Per §Privacy / sensitive data in traces.
:no-emit? not hoisted Acts as a short-circuit signal: emit! / emit-error! skip envelope construction and listener fan-out entirely when bound true. The slot never appears on any emitted event. Per §Trace-emission opt-out.

Extension contract — adding a new slot

The HandlerScope slot set is closed; adding a sixth concern (e.g. a hypothetical :tenant-id for multi-tenant audit) is a coordinated edit that crosses the implementation and this spec. To add a slot X, all of the following must change in the same change-set:

  1. The defrecord. re-frame.trace/HandlerScope's positional slot list gains X (constructors ->HandlerScope callers update; all explicit ->HandlerScope literals in with-call-site and with-dispatch-id+call-site add the new positional arg).
  2. The meta-derived reader (if X is meta-derived). handler-scope-from-meta reads the slot off the registrar meta map at scope-bind time, with the same nil-when-absent convention as :sensitive? / :no-emit?.
  3. The inheritance rule (if X inherits from parent scope). inherit-scope adds a (nil? (:X new-scope)) (assoc :X (:X parent)) branch — mirror of the :call-site / :dispatch-id branches. Slots that are purely meta-derived (innermost-wins) need no inherit-scope change.
  4. The emit-side hoist (if X rides emitted events). build-event reads the slot and stamps it on the envelope — either at the top level (with a reserved namespace, e.g. :rf.tenancy/tenant-id) or under :tags. Pin the per-slot rule in the §Emit-side hoist contract table above. Slots that are pure short-circuit signals (like :no-emit?) skip this step.
  5. This canonical slot list. The two tables above (§Canonical slot set and §Emit-side hoist contract) gain a row for X.
  6. Reserved namespace (if X is hoisted under a new namespace). Per the :rf/* single-root scheme in Conventions, any new top-level event field uses a reserved sub-namespace (:rf.<area>/<slot>); allocate the namespace in Conventions.md §Reserved namespaces.

Existing slots are never repurposed — value shape and hoist mapping are frozen. Renaming a slot or changing a slot's value shape is a breaking change to every trace consumer and is out of scope for this contract.

History — why one record, not five Vars

The reading is carried by one HandlerScope record bound to one Var: one binding-frame allocation per scope, one Var to mock in tests, one record-field edit when a new concern lands.

Error contract

Errors that occur during runtime execution are emitted as structured trace events, with a defined :op-type and a Malli-schemed :tags payload. This satisfies AI-first property P7 (machine-readable errors) and gives every consumer of the trace stream a consistent error surface.

This section is the authoritative model for re-frame2's error taxonomy. Per-feature specs (010, 011, 012, etc.) reference categories defined here; the §Error event catalogue below is the single source of truth for category names, payload shapes, and recovery defaults. Two axes carry the structured information:

  • :op-type — universal severity discriminator (:error or :warning). Consumers branch on severity without parsing the prefix.
  • :operation — namespaced category keyword (:rf.error/<category>, :rf.fx/<category>, :rf.ssr/<category>, :rf.warning/<category>, :rf.epoch/<category>). The prefix carries domain provenance; the suffix names the specific category.

Observability channels and the promotion criterion

Graduated from EP-0008. This subsection is the authoritative home for which channel a failure rides and what must ride the always-on axis; the EP is the record of why.

For failure categories, re-frame2 has three observability channels with different production guarantees. Two were named before (the diagnostic trace surface and the always-on error-emit listener); this contract names all three normatively and states the rule for moving a category onto the production-survivable one.

  1. The causal channel — effects-as-data, replayable, part of the semantic value. It is the program: a :dispatch / :fx entry is data the run executes, not a log line. Never elided.
  2. The diagnostic channeltrace/emit! (every :op-type trace event, the :rf.error/* / :rf.warning/* / :rf.fx/* / :rf.ssr/* / :rf.epoch/* catalogue, the per-frame trace rings, source-coord enrichment). Ambient, framework-wide; production-elided — Closure DCE under goog.DEBUG=false (CLJS :advanced), runtime-gated on re-frame.debug (JVM). For development eyes and tools.
  3. The always-on error axis — the error-emit substrate (the :errors stream of register-listener!, surface #4, per §What IS available in production). Production-survivable: it is NOT gated by re-frame.interop/debug-enabled?, so it survives :advanced + goog.DEBUG=false (CLJS) and -Dre-frame.debug=false (JVM), fanning out one tight record per promoted :rf.error/* (the categories meeting the promotion criterion, not every production-reachable one) to app-registered shippers (Sentry / Honeybadger / Rollbar).

Production guarantees, once:

  • CLJS :advanced + goog.DEBUG=false DCEs the diagnostic channel entirely (per §Production builds).
  • The JVM gate (re-frame.debug / RE_FRAME_DEBUG, per §JVM builds) defaults on — "production-elided" means elidable, not elided by default. A production JVM SSR / tooling process that does not set -Dre-frame.debug=false runs the full dev diagnostic surface. Production JVM deployments MUST set it explicitly. (This is the one place the JVM default-on caveat is stated; the per-channel sections cross-reference here rather than re-hand-waving "moot in production.")
  • The causal channel and the always-on error axis survive both gates.

The promotion criterion

A failure category MUST ride the always-on error axis when all three legs hold:

  1. Production-reachable — it can occur in a production build, not exclusively as dev-time misuse (registration-shape rejections, dev-only schema validation, and the like stay diagnostic: production never re-runs those paths).
  2. Locally invisible — the failure leaves the process in a state nobody standing at the call site can read back. Two shapes qualify:
  3. (a) Contract breach or resource leakage — leaked handles, skipped teardown, suppressed writes, corrupted invariants. The damage outlives the call, and the next operation cannot see it.
  4. (b) A refusal whose only local effect is the ABSENCE of the action — a fail-closed gate that declines as a no-op: nothing thrown, nothing returned, the cascade continuing exactly as if the call had never been made. The call site therefore receives no value with which to distinguish refused from never asked.

Excluded under both, and this is the line the legs draw: a malformed input the caller can observe and fix at the call site. "Observe" is the operative word — a guard that THROWS or returns a failure value has already told the caller, so the diagnostic channel suffices. :rf.server/safe-redirect carries the contrast inside one effect: its CR/LF/NUL gate throws, so the caller is told and the record rides :rf.error/fx-handler-exception; its scheme / host gate no-ops, so before promotion the identical class of refusal reached nobody. Two halves of one security surface, and only the silent half needed leg 2(b). 3. Silence compounds — the failure's cost grows with process lifetime or recurrence (long-lived SSR, tooling hosts, retry loops).

Categories failing any leg stay on the diagnostic channel.

Leg 2(b) names a shape the axis already carried. It was written as limb (a) alone, which literally excluded every fail-closed refusal on the axis — :rf.error/machine-spawn-unregistered-type, :rf.error/unsupported-scroll-strategy, the :rf.schema/at-boundary arm of :rf.error/schema-validation-failure, and the three :rf.error/safe-redirect-* rejections are refusals, not leaks. Each was promoted on the same reasoning nonetheless: the refusal is correct, and its correctness is exactly why it is invisible. Naming (b) reconciles the criterion with the catalogue rather than granting any of them an exception (rf2-rprfg). Categories on the always-on axis carry structured data only (error id, ids/keys, frame) — never raw values; the axis is subject to the same egress redaction posture as every off-box surface (per §Privacy / sensitive data in traces).

Category kind follows the channel. The always-on axis is contractually :rf.error/*-only (one tight record per promoted :rf.error/*, per §What IS available in production); it is not widened to warnings. A failure fact that meets the criterion but is surfaced only as a :rf.warning/* diagnostic is on the wrong channel — promotion names the production-survivable fact as a new :rf.error/* category with a typed per-category default :recovery from the §Recovery contract vocabulary (the recovery may stay :ignored — the channel, not the recovery, is what promotion changes).

Promotion is not a blind per-item rename. Where a single always-on emission would otherwise fan out one record per item — the frame-destroy case, where a teardown recipe runs many hooks — the criterion is satisfied by a single bounded report naming the higher-level fact, with the per-item detail carried as a payload vector. This is the corpus idiom: the Spec 016 trace family settled the same fan-out with single summary rows (:rf.resource/route-plan, :rf.resource/revalidate-scan) plus per-item detail on ordinary diagnostic traces. The report-vs-per-item choice is scoped to the always-on axis only — the diagnostic channel keeps its per-item rows at their causal positions (dev, DCE'd in production).

Channel-promotion catalogue rows

The promotion criterion has two graduated catalogue entries to date — one per leg-2 limb; the rest of the catalogue is graded against the criterion.

  • Frame-teardown failures. Frame destroy runs a best-effort recipe of optional late-bound cleanup hooks plus a few guarded direct teardown steps (notably the :frame/notify-machine-destruction! machine cascade); a teardown step throwing is production-reachable (long-lived SSR / tooling), is a resource-leakage class (skipped teardown — leaked request data, orphaned timers, cross-request contamination the next operation cannot see locally), and compounds with process lifetime. All three legs hold, so the fact rides the always-on axis as :rf.error/frame-teardown-failed carrying a :hook-failures vector (:recovery :ignored — teardown stays best-effort). The per-hook detail stays on the diagnostic channel as :rf.warning/teardown-hook-exception at its causal positions, funneled through the shared record-teardown-failure! emit boundary the teardown catch sites (safe-call-hook!, safe-teardown-step!, and the two epoch hooks) all route through (dev, DCE'd in production). See the §Error event catalogue rows for both.

Emit-safety contract (finally-shaped flush). The always-on :rf.error/frame-teardown-failed report MUST be emit-safe on a partial teardown: the hook-failure entries are accumulated during the teardown walk and flushed through a finally-shaped emission boundary, so that if teardown itself aborts after (say) hook 3 of 7, the entries collected so far still ship the report. The single-report shape therefore does not sacrifice incremental delivery against a mid-teardown collapse — the one genuine advantage per-hook emission would have had. The report is emitted at most once per destroy (whether teardown completes or aborts).

  • Security-boundary refusals — the graduated example of leg 2(b), and the reason (b) is written down (rf2-rprfg). :rf.server/safe-redirect's five-step gate is the case in full: it is production-real and rejects correctly under -Dre-frame.debug=false, so the mitigation was never in doubt. What was missing is that the rejection is a no-op — the fx returns nil and the response simply carries no redirect — so an attacker-supplied ?next=javascript:alert(1) produced no shipper event, no metric and no frame-owned record, and the refusal was indistinguishable in production from the redirect never having been attempted. Leg 1 holds (untrusted ingress is a production-only path), leg 2(b) holds (the refusal's only local effect is the absent redirect), leg 3 holds emphatically (a single rejected target is noise; recurrence is what turns it into visible probing, and recurrence is precisely what silence destroys). The three categories :rf.error/safe-redirect-invalid-url, :rf.error/safe-redirect-scheme-rejected and :rf.error/safe-redirect-host-disallowed therefore ride the always-on axis. Their siblings on the same limb — :rf.error/machine-spawn-unregistered-type, :rf.error/unsupported-scroll-strategy, and the :rf.schema/at-boundary arm of :rf.error/schema-validation-failure — are each promoted on the same reading.

Record discipline, and it is stricter here than elsewhere on the axis. A security refusal's payload is caller-untrusted by construction — that is what the gate exists for — so the closing sentence of the criterion ("structured data only … never raw values") is not a redaction obligation on this class but a projection obligation. The record is BUILT FROM a closed allow-list of framework-owned values rather than scrubbed down to one, because a scrub is a deny-list and a deny-list over an arbitrary attacker-supplied input is one component away from the next leak. And a closed set of keys is not yet a closed set of values: a slot holding a parsed component of caller input is still holding caller input, whatever its name implies. Both halves were learned the expensive way on this family — first a carrier-scrubbed URL that leaked userinfo, path and value-less query keys, then a parsed :scheme / :host that leaked sentinels and let a prober drive unbounded metric cardinality. The test to apply: could a caller choose the bytes in this value, or the number of distinct values it can take? If either, it is not structural. The safe-redirect record's settled shape — #{:frame :recovery :reason :scheme-class}, every value a framework keyword or the frame's own id — is the worked example.

The error event shape

All error trace events are open maps with these required keys:

{:id        any                                  ;; unique trace id
 :operation :rf.error/<category>                 ;; specific category, see below
 :op-type   :error                               ;; the universal discriminator for errors
 :time      timestamp                            ;; emit time, host clock
 :source    keyword?                             ;; (when present) the trigger source — :ui, :after-timer, :http, :machine-action, ... (full enum: Spec-Schemas §:rf/dispatch-envelope)
 :recovery  keyword?                             ;; :no-recovery, :replaced-with-default, :skipped, ...
 :rf.trace/trigger-handler                       ;; (when present) the in-scope handler at emit time
   {:kind         #{:event :sub :fx :cofx :view}
    :id           keyword
    :source-coord {:ns sym? :file string? :line int? :column int?}}
 :rf.trace/call-site                             ;; (when present) invocation coord stamped by the
   {:ns sym? :file string?                       ;; macro form. Dev-only — elided under
    :line int? :column int?}                     ;; :advanced + goog.DEBUG=false.
 :tags      {:category    :rf.error/<category>   ;; same as :operation, for consumer convenience
             :failing-id  any                    ;; the registered id that failed (event id, fx id, sub id, view id, etc.)
             :reason      string                 ;; one-sentence human description
             :frame       keyword?               ;; (when known) the frame the failure happened in
             ...}}                               ;; category-specific keys

:source and :recovery are top-level fields hoisted out of :tags by the runtime; both are present on every error event. :frame rides under :tags (every emit site that knows the frame supplies it there). The :tags payload's category-specific keys are documented per category below, and each category has a registered Malli schema so consumers can validate / branch on the payload safely.

The thrown-error shape — the :rf.error/id ex-data contract

Most runtime failures emit a trace event (the shape above) and let the run recover. A minority are thrown — a registration is rejected, an optional artefact is absent, a delegation surface is reached before (rf/init! …). These surface as ex-info rather than as trace events (the catalogue's "Surfaced as a thrown ex-info, not a trace" rows). Thrown errors carry their own canonical ex-data shape so a single consumer path — Xray's error widget, the pair-tool overlay, an error listener, a try/catch in user code — reads one discriminator slot uniformly regardless of which surface threw.

The discriminator slot is :rf.error/id — a :rf.error/<category> keyword from the §Error event catalogue. This is the single normative discriminator for thrown errors; the four-slot skeleton below is the canonical shape every framework throw conforms to. Every (throw (ex-info …)) site in the runtime is built through the central builder re-frame.error/throw-error! / re-frame.error/thrown-ex-info (the re-frame.error.cljc chokepoint), which DERIVES the message from :reason + the :rf.error/id token and sets the canonical ex-data shape — so the human message and the machine discriminator are derived from one source and cannot drift, and a keyword-only message is structurally impossible to emit:

(error/throw-error!
  <category-kw>                       ;; :rf.error/id — CANONICAL DISCRIMINATOR, :rf.error/<category>
  'rf/<surface>                       ;; :where — the user-facing fn symbol that threw
  "<one human-actionable sentence>"   ;; :reason — public concept + expected fix + key context
  {:recovery <disposition>            ;; :no-recovery / :fix-registration / :skipped / … (default :no-recovery)
   :extra    {}})                    ;; surface-specific payload: :flow / :route-id / :machine-id / :received / :cycle / …

;; The builder produces this canonical ex-info:
(ex-info
  "<reason> [:rf.error/<id>]"          ;; message LEADS with the human sentence, TRAILS the [:rf.error/<id>] token
  {:rf.error/id <category-kw>          ;; CANONICAL DISCRIMINATOR — the SOLE machine pivot
   :where       'rf/<surface>          ;; the user-facing fn symbol that threw
   :recovery    <disposition>          ;; :no-recovery / :fix-registration / :skipped / …
   :reason      "<one sentence>"       ;; the required human sentence (also leads the message)
   })                                 ;; + surface-specific payload merged on top

Required slots on every thrown runtime error: :rf.error/id, :where, :recovery, :reason. Surface-specific payload (:flow, :bad-entries, :cycle, :installed, :attempted, :received, …) merges on top.

The human-message policy — two channels, one property. (ex-message e) is a human-actionable sentence; (:rf.error/id (ex-data e)) is the sole machine discriminator. The two channels are separate, and both remain machine-locatable:

  • (ex-message e) is a human-actionable one-line sentence naming the public concept, the expected fix, and key context — e.g. "rf/init! cannot continue because no adapter is installed; require an adapter ns and install it before boot. [:rf.error/no-adapter-installed]". It is no longer the bare stringified keyword. A reader of a raw REPL/browser/CI exception gets an actionable message without having to render ex-data.
  • The message carries a trailing [:rf.error/<id>] token so a log line or raw stack trace still grep-pivots to a stable category from the message alone — the only thing the old "category-from-message" property bought, retained without making the keyword the whole message. This is the refinement: the message now leads with the human sentence and retains the category as a bracketed trailing token.
  • The message string is non-normative — stable in MEANING, not bytes. Tools and tests MUST NOT branch on it or assert exact-equality against it. Greppability is via the bracketed token (substring / thrown-with-msg? regex), never via whole-string equality.
  • :rf.error/id is the SOLE canonical machine discriminator. (:rf.error/id (ex-data e)) returns the keyword for structured branching. Tools case / condp on this slot, never on the message. Machine branching never depended on the message, so this separation is correctness-neutral.

:reason is the required structured human sentence that both lands in the :reason slot and leads the derived message; there is no separate :rf.error/message slot — it would duplicate :reason. Renderers show the message (or :reason) as the title and :rf.error/id as the category badge.

The :where slot names the user-facing surface fn symbol ('rf/reg-flow, 'rf/make-state-container) so a grep-for-symbol lands on the call site in user code; :recovery reuses the §Recovery contract vocabulary plus :fix-registration (the caller fixes their registration map and retries); :reason is one human-readable sentence naming what failed and the fix. A conformance test (re-frame.error/keyword-only-message? + message-has-id-token?) rejects any framework throw whose message regresses to a bare keyword or drops the token.

This shape is the throw-side companion of the trace-event shape above. A category that can both throw and emit (e.g. a registration rejection that is also catalogued) uses :operation on the trace event and :rf.error/id on the thrown ex-info — the same :rf.error/<category> keyword in both slots, so a consumer reads one vocabulary across both surfaces. The pairing with re-frame.core-artefact/defwrapper (per Conventions §single-import contract) and the missing-artefact wrappers (:rf.error/<feature>-artefact-missing) is the canonical reference.

One discriminator slot

Every thrown framework error carries its category under the single ex-data slot :rf.error/id (a :rf.error/<category> keyword); no other discriminator slot exists — not :error, not :type, not :kind, and never the keyword doubling as the human :reason. (The v1→v2 slot mapping lives in MIGRATION.)

:rf.trace/trigger-handler — naming the in-scope handler

The optional top-level :rf.trace/trigger-handler slot names the handler whose execution produced the trace event and carries its registration-site source-coord. Tools (Xray, pair, IDE jump-to-source) render click-to-jump links from this field — given a trace event, the user lands on the line of code that defined the responsible handler.

The slot rides on every trace event emitted while a handler is in scope, not just errors. Success-path traces — :rf.fx/handled, :rf.machine/transition, :rf.event/db-changed, :rf.fx/do-fx, ... — carry the in-scope handler's registration coord too. This lets consumer tools render jump-to-source links from any trace event in a cascade, not just errors. The error-path and success-path emit shape is identical — same field name, same nested map, same top-level placement.

Coverage is keyed off "is a handler currently in scope at emit time?":

Emit context :rf.trace/trigger-handler present? Carries
Inside an event handler's interceptor chain Yes The event handler's coord
Inside a cofx fn body Yes The cofx's coord
Inside an fx handler body Yes The fx handler's coord
Inside a sub recompute (body fn) Yes The sub's coord
Inside a view render Yes The view's coord
Inside a machine transition (machines register as event handlers) Yes The machine's coord
At outermost dispatch with no handler resolved (:rf.error/no-such-handler) No
At depth-exceeded drain rollback (:rf.error/drain-depth-exceeded) No
At registration-time emits outside any handler (:rf.registry/handler-registered, :rf.frame/created) No

For :rf.fx/handled specifically: the slot carries the fx handler's own registration coord (not the enclosing event handler that produced the :fx vector). The runtime rebinds the handler-scope's :trigger-handler slot (per §Handler-scope) to the fx handler's meta around the fx body's invocation and the success-path emit that follows, so consumer tools jump to the reg-fx site — where the fx's logic actually lives — not the event handler upstream. Reserved fx-ids (:dispatch, :dispatch-later, :rf.fx/reg-flow, :rf.fx/clear-flow) have no registration site of their own; their :rf.fx/handled traces carry the enclosing event handler's coord (the outer binding).

The :source-coord payload is whatever the registrar slot's metadata holds. Macro-driven registration (reg-event-*, reg-sub, reg-fx, reg-cofx, reg-view, reg-machine, reg-flow, reg-route, reg-app-schema, reg-error-projector) stamps :ns / :file / :line / :column flat onto the meta map at compile time; the trigger-handler builder picks those keys off and re-nests them under :source-coord. Programmatic / REPL registrations bypass the macro path and carry no coord — in that case the entire :rf.trace/trigger-handler slot is omitted rather than populated with placeholder data (better no field than poison-data).

Production elision: the slot is NOT separately elided. The trace surface as a whole is gated by re-frame.interop/debug-enabled? per §Production builds — when a trace event is emitted at all, the trigger-handler field rides along on it when bound. There is no second gate that selectively drops the field while keeping the rest of the event. Apps that keep the trace surface in production (rare; opt in by setting goog.DEBUG=true on the :advanced build) get the trigger-handler coord along with every emitted event. Apps using the default goog.DEBUG=false :advanced build get neither the field nor the surrounding trace surface — the entire (when interop/debug-enabled? ...) branch DCEs.

Consumer access: read (:rf.trace/trigger-handler event) for the map, (get-in event [:rf.trace/trigger-handler :source-coord]) for the coord, (get-in event [:rf.trace/trigger-handler :id]) for the handler's id. No new namespace is required to read the slot.

:rf.trace/call-site — naming the invocation line

The optional top-level :rf.trace/call-site slot is a sibling of :rf.trace/trigger-handler (not nested) and names the invocation line of the user-facing surface that triggered the trace event — the (rf/dispatch [:bad-event]) line, the (rf/subscribe [:bad-sub]) line, the (rf/dispatch-sync [:throws]) line. Where trigger-handler answers "where is the failing handler defined?", call-site answers "where is the failing handler called?" Tools render two clickable links per error: registration-site jump (trigger-handler) and invocation-site jump (call-site).

Shape (flat map, mirrors :source-coord under :rf.trace/trigger-handler):

{:ns     <sym>     ;; the calling namespace
 :file   <string>  ;; the source file, per `:file` resolution
 :line   <int>     ;; the line of the macro form
 :column <int>}    ;; optional refinement

The macro forms of three user-facing surfaces stamp the call-site at compile time; the same name's plain-fn value form (Convention A on CLJS, per Conventions §Convention A — rf2-m90brg retired the dispatch* / dispatch-sync* / subscribe* facade twins) or the owning ns fn directly (re-frame.router/dispatch! / -dispatch-sync!, re-frame.subs/subscribe) does not stamp:

Surface Macro (stamps) Fn-form (no stamp)
Dispatch (queued) dispatch (call position) dispatch (value position) / re-frame.router/dispatch!
Dispatch (sync) dispatch-sync (call position) dispatch-sync (value position) / re-frame.router/dispatch-sync!
Subscribe subscribe (call position) subscribe (value position) / re-frame.subs/subscribe

For dispatch / dispatch-sync, the call-site rides through the dispatch envelope and is bound around process-event! so errors emitted inside the handler chain (handler exception, the declared-coeffect errors :rf.error/unregistered-cofx / :rf.error/coeffect-exception, no-such-fx, schema validation failures) attach the call-site of the dispatch that triggered the cascade — the user lands on the line they wrote, not somewhere deep in framework code. For subscribe, the macro binds the Var around the synchronous miss path so :rf.error/no-such-sub and :rf.error/frame-destroyed carry the invocation coord. (Coeffect delivery is no longer a surface macro: a handler declares :rf.cofx/requires and the runtime's cofx dispatcher runs the value-returning supplier inside the dispatch's call-site binding, so a supplier-throw trace carries the originating dispatch line — inject-cofx is removed, see the :rf.error/inject-cofx-removed row in §Error contract.)

Coverage:

Reached through :rf.trace/call-site present?
Macro form (dispatch, subscribe, dispatch-sync, call position) Yes
Fn form (the same name in VALUE position, or the owning ns fn directly — re-frame.router/dispatch! / -dispatch-sync!, re-frame.subs/subscribe) No
Higher-order use ((map dispatch xs)dispatch read as a value, not called) No
View-render injected dispatch / subscribe locals (per reg-view) Yes (view-level) — the macro injects the reg-view definition-site coord into the capture-frame (below)
capture-frame ops built with :dispatch-opts / :subscribe-call-site (the reg-view injection path) Yes (view-level) — the op merges the supplied coord into its owning-ns dispatch / subscribe call
Bare captured capture-frame ops ((:dispatch (rf/capture-frame)) / (:subscribe (rf/capture-frame)), no coord) No — the returned op delegates through re-frame.router/dispatch! with no coord

View-level call-site for the reg-view-injected handle ops. Inside a reg-view body the lexically-bound dispatch / subscribe are the :dispatch / :subscribe ops of a single render-time capture-frame, which shadow the coord-capturing macros for the whole body (they are LEXICAL locals, not the re-frame.core facade Var — qualified rf/dispatch in the same body still resolves the macro/value-alias pair unshadowed). A view's on-click #(dispatch [...]) therefore reaches re-frame.router/dispatch! directly without a macro stamp — its trace would otherwise carry no call-site and classify as :source :unknown. To close the gap the reg-view macro injects the view's definition-site coord into the handle via its :dispatch-opts and :subscribe-call-site (it builds (rf/make-capture-frame (rf/current-frame-id) {:dispatch-opts {:source :ui :rf.trace/call-site <view-coord>} :subscribe-call-site <view-coord>})). The :dispatch op merges {:source :ui :rf.trace/call-site …} below the captured :frame and any per-call opts; the :subscribe op wraps its subs/subscribe call in with-call-site so the synchronous miss path (:rf.error/no-such-sub, :rf.error/frame-destroyed) carries the coord. The view body is spliced verbatim — no code-walking, no rewriting of user view code. Coord precision is view-level: "go to code" lands on the reg-view definition, not the exact #(dispatch …) line. The handle's render-time frame capture is preserved unchanged — the dispatch routes to the render frame, not a click-time :rf/default fall-through.

Production elision (Q3=B): dev-only. Each macro expands to (if interop/debug-enabled? <stamping-branch> <no-stamping-branch>); under :advanced + goog.DEBUG=false the closure compiler constant-folds the gate to false and the entire stamping branch DCE's — the literal {:rf.trace/call-site {...}} map vanishes from the bundle. The reg-view handle injection rides the same gate: each injected arg is (if interop/debug-enabled? <dev-coord-or-opts> <prod>) where the :dispatch-opts prod branch is exactly {:source :ui} (the :rf.trace/call-site keyword + coord DCE; :source :ui survives because dispatch! reads it unconditionally) and the :subscribe-call-site prod branch is nil. Apps using goog.DEBUG=true builds (or any JVM build) get the field; the default :advanced + goog.DEBUG=false production build does not — the elision-probe (per §Production builds) asserts the "rf.trace/call-site" string fragment is absent from the production bundle. The trace surface itself is still gated; this is an additional compile-time gate that strips the call-site machinery even when the trace surface is kept live.

The mechanism is "compile-time map + handler-scope bind + emit read." The macro produces a literal map at compile time; the runtime publishes it on the :call-site slot of re-frame.trace/*handler-scope* around the underlying owning-ns fn call (re-frame.router/dispatch! / -dispatch-sync!, re-frame.subs/subscribe) (or threads the value through the dispatch envelope so process-event! binds it for the handler chain, per §Handler-scope); build-event reads the slot and hoists it onto every emitted event (success and error) when bound. The queue-time :rf.event/dispatched emit additionally wraps its trace/emit! in a with-call-site binding sourced from the envelope's :call-site slot, so the enqueue trace carries the dispatch-site coord even though process-event!'s cascade-wide binding hasn't fired yet. No new namespace or registry; consumer access is (:rf.trace/call-site event).

the hoist widened from error-only to every emit (success and error). The Event lens redesign and any consumer rendering jump-to-source on success-path events (:rf.event/dispatched, :rf.fx/do-fx, :rf.fx/handled, :rf.event/db-changed, :rf.sub/run, :rf.machine/transition) needs the dispatch-site coord on the cascade entry, not just on errors. The semantics match the trigger-handler widening: better one consistent rule than two paths to remember.

:rf/default? — framework-auto-wrapped interceptor flag

reg-event wraps the user's handler into a single framework interceptor — :rf/event-handler (the one wrapper id for every event handler) — before appending it to the user-supplied :interceptors chain. The wrapper appears in (rf/handler-meta :event id) :interceptors alongside the user's own interceptors; consumer tools (Xray, the Event lens, IDE inspectors) frequently want to surface ONLY the user's chain — the framework auto-wrapper is implementation detail, not user-authored configuration worth showing.

the auto-wrapper carries :rf/default? true on its interceptor map. Self-describing — tools filter without a hardcoded id allowlist:

(->> (rf/handler-meta :event :my/event)
     :interceptors
     (remove :rf/default?))                ;; → only the user's interceptors

Shape and reservation:

  • The flag is a top-level boolean on the interceptor map (the same map the chain stores).
  • :rf/default? is owned by the framework under the :rf/* reserved namespace (per Conventions §Reserved namespaces).
  • User-supplied interceptors MUST NOT set :rf/default? true — the slot identifies framework-injected entries only.
  • Absent (or false) means "user-authored." Tools that branch on the flag treat absent and false identically (nil-safe access).

Production elision: the flag rides on a registry-meta surface (handler-meta) — not on a trace event — so the trace-surface DCE gate does not apply. The flag is one keyword + one boolean per registered event, lives in process memory only, and is consumed by dev tooling that itself does not ship to production (the framework's own dispatch path does not branch on it).

:rf.handler/source — DEBUG-gated handler form-source capture

reg-event captures the WHOLE form the user wrote — (reg-event :id ...) — as a string at macro-expansion time and stamps it into the handler's registry metadata under :rf.handler/source. Tools (Xray's Epoch panel, the Event lens, IDE inspectors) render the captured source inline so the operator can read what code ran without leaving the browser to chase a file:line link. Per Xray Spec 021 §9.1 (the Epoch panel's HANDLER step surfaces the source inline alongside the handler invocation).

Scope: the WHOLE form — macro name, id, optional metadata-map middle slot, and the handler-fn body — rides under one slot. The capture is mechanically pr-str of &form at expansion time, so every documented reg-event shape round-trips without special-casing.

Consumer access:

(->> (rf/handler-meta :event :my/event)
     :rf.handler/source)                 ;; → string or nil

Shape and reservation:

  • Value is a string (the pr-str of the user-written form) or absent.
  • The keyword is owned by the framework under the :rf.handler/* reserved namespace (per Conventions §Reserved namespaces).
  • Absent on programmatic / REPL registrations that bypass the macro path (call re-frame.events/reg-event directly as a fn).
  • User-supplied :rf.handler/source in the registration metadata-map overrides auto-capture, mirroring the :ns/:line/:file override semantics of Spec 001 §Source-coordinate capture so code-gen pipelines can stamp the originating source.
  • Coverage is scoped to reg-event only at the top-level registration's :event registry slot. Other reg- surfaces (reg-sub, reg-fx, reg-cofx, reg-flow, reg-route, reg-view, reg-app-schema, reg-error-projector, reg-head, reg-http-interceptor) do not stamp the slot — their primary tooling surface today is (:ns/:file/:line) → open-in-editor. reg-machine is a partial exception: it does NOT carry :rf.handler/source on the top-level :event slot, but it DOES capture per-guard and per-action fn-source — co-located onto each :guards / :actions entry inside the machine's own :event registration spec (as :source-code), not under any separate registry kind. Machine guards/actions are not registrar kinds (the closed registry-kind set is :event / :sub / :fx / :cofx / :view / :frame / :route / :head / :error-projector / :flow); their :rf.handler/source is derived on demand* from the enclosing :event spec when a tool addresses (rf/handler-meta :machine-guard [machine-id guard-id]) / (rf/handler-meta :machine-action [machine-id action-id]). There is no :machine-guard / :machine-action side-table — (rf/registrations :machine-guard) returns {}. See Spec 005 §:machine-guard / :machine-action handler-meta surfaces and Spec 001 §Registry model. Widening to the remaining surfaces is a follow-up; the Xray Epoch panel and Xray Machine Inspector focused-transition lens are the load-bearing consumers.

Production elision (CLJS): DEBUG-gated, two-layer. The macro emission wraps the bound source-string in (if interop/debug-enabled? <pr-str-of-form> nil); the registrar-side merge in re-frame.events/merge-form-source is wrapped in (if-not interop/debug-enabled? m ...). Under :advanced + goog.DEBUG=false Closure constant-folds both gates and DCEs (a) the literal source-string bytes from the macro expansion, AND (b) the :rf.handler/source keyword's reachability from the merge assoc slot. The elision-probe (per §Production builds and scripts/check-elision.cjs) asserts both absences against the production bundle.

Production elision (JVM): always-on. re-frame.interop/debug-enabled? is dev-default-true on the JVM; the bundle-size argument doesn't apply to SSR / test / tooling builds. The JVM-side macro emission carries the source string into the registry meta unconditionally — JVM clojure -M:test users can read (:rf.handler/source (rf/handler-meta :event id)). The re-frame.debug=false JVM property (per §JVM builds) flips the same gate and elides capture at registration time.

The mechanism is "compile-time pr-str + dynamic-var thread + registrar merge." The macro produces a pr-str literal at compile time; the binding form publishes it on re-frame.source-coords/*pending-form-source* around the underlying register-event! call; register-event! reads the var via merge-form-source and assocs it into the registered handler's metadata. No new namespace or registry slot beyond the registrar; consumer access is (:rf.handler/source (rf/handler-meta :event id)).

Error namespace convention — six prefix shapes

Error categories use six distinct namespace prefixes:

Prefix Meaning Example
:rf.error/<category> A genuine runtime error: a contract was violated. :rf.error/handler-exception, :rf.error/no-such-sub
:rf.fx/<category> An fx-substrate event that rides the error envelope but is not necessarily a failure. :rf.fx/skipped-on-platform
:rf.cofx/<category> A cofx-substrate event that rides the error envelope but is not necessarily a failure. :rf.cofx/skipped-on-platform
:rf.ssr/<category> An SSR-substrate event with its own diagnostic shape (server-vs-client divergence, hash mismatches). :rf.ssr/hydration-mismatch
:rf.warning/<category> A misuse the runtime can recover from but wants surfaced. :rf.warning/plain-fn-under-non-default-frame
:rf.epoch/<category> Time-axis tooling (epoch buffer, time-travel) diagnostics. :rf.epoch/replay-conflict

The prefix carries domain provenance that consumers branch on. :rf.fx/ marks "fx substrate emitted this"; :rf.ssr/ marks "SSR substrate emitted this"; :rf.warning/ marks "this is recoverable." Routing on the prefix is cheap (string-prefix dispatch) and matches how tools consume the stream.

The :op-type field carries the universal severity discriminator (:error / :warning) so consumers that want severity branching get it without parsing the prefix. :op-type answers how serious is this?; the prefix answers which subsystem owns it?.

This convention is stable: new error categories adopt one of the five existing prefixes. New ad-hoc prefixes are not part of the contract.

Error event catalogue

Co-edit invariant. Every :rf.<area>/<category> error / warning / advisory event MUST land as a row in this catalogue in the same PR as the owning Spec change that emits it. The vocabulary is closed: an entry referenced from a feature Spec (002, 005, 006, 010, 011, 012, 013, 014, Tool-Pair, or 009 itself) without a matching row here is a contract bug, not a deferred follow-up. Reviewers MUST reject PRs that introduce a new category without the co-edit. Per Conventions §Error-id and warning-id grammar (which reserves the prefixes; this catalogue owns the per-category grammar).

This is the single normative catalogue of every error / warning / advisory event the re-frame2 runtime emits. Every entry combines the six axes a consumer needs: :operation (the category keyword), :op-type (severity discriminator), Channel (which observability channel the category rides), trigger / meaning, default :recovery, and :tags payload keys. Each row's "Per [N]" cross-link names the owning Spec section — the emit-site of record — which carries the surrounding rationale and edge-case rules.

The Channel column is the graduated classification. Its value is one of two (the causal channel is data, not a catalogue row):

  • always-on — the category rides the production-survivable always-on error-emit axis (surface #4): it meets the promotion criterion and survives goog.DEBUG=false / -Dre-frame.debug=false, fanning a tight record to registered error shippers. Always-on categories are exactly the promoted runtime :rf.error/* set enumerated in the paragraph below — the subset meeting the promotion criterion, NOT every production-reachable runtime category (a production-reachable category surfaced only as a caller-observed pure throw-error! stays diagnostic).
  • diagnostic — the category rides the dev-only trace surface, DCE'd in CLJS :advanced production and JVM-gated on re-frame.debug. Every :rf.warning/* advisory, every :rf.epoch/* rejection, every registration-time / dev-only-validation :rf.error/*, and the :rf.fx/* / :rf.cofx/* / :rf.ssr/* substrate events are diagnostic (the SSR error categories reach the public boundary through the JVM-side projector per §Server error projection, where re-frame.debug is dev-default-on, not through the CLJS always-on axis). A thrown ex-info rejection the caller observes at its own call site is diagnostic-channel for catalogue purposes — it is not delivered to the error-emit listener — whether a registration-shape rejection (the "Surfaced as a thrown ex-info" rows) or a production-reachable runtime pure throw-error! the caller resolves at the call site (:rf.error/custom-element-conflict, :rf.error/dispatch-disconnected, :rf.error/flush-convergence-exceeded).

Every emitted category therefore carries a Channel; a conformance test pins it — every emitted category appears in this catalogue with a channel, and every always-on category is exercised through the error-emit listener in at least one test (so promotion is real, not documentary).

The catalogue is the single source of truth for the error categories, their channel, and their default recovery. Per Spec-Schemas §:rf/error-event and Spec-Schemas §Per-category :tags schemas, the per-category Malli :tags schemas are canonicalised in Spec-Schemas — one schema per row below. The category vocabulary is stable: existing categories cannot be renamed or removed; new categories are added by extending the operation namespace (per Spec-ulation).

Production-elision applies to the dev trace surface uniformly, and the Channel column is what tells you whether a row is on it: every diagnostic row loses its emission, and the dev-time validation behind it, per §Production builds. The always-on rows keep theirs. Nor does elision reach the checks the framework relies on to keep its own promises — Spec 000 §Contract C-000.35 settles what may be elided by what the check is for rather than by who declared the schema it reads, so an ordinary registration diagnostic elides while a load-bearing check runs in every build. This section grounds that clause's equivalence promise: what goes is diagnostic, so removing it cannot change frame state or event order. And surviving is not the same as reporting — a row can name a check that runs in production while the payload-bearing trace above it elides, which is why the Channel column and the row's own prose both have to be read.

OUT-OF-CATALOGUE. Two emitted :rf.* categories are NOT error-event-catalogue rows; the co-edit invariant excludes them. (1) :rf.route/navigation-blocked (and its terminal sibling :rf.route/entry-denied) ride the :rf.event op-type — they are dispatched user-event lifecycle traces (the no-op default events a :can-leave block / :can-enter denial dispatch, per 012 §Navigation blocking), NOT error / warning / advisory categories. Like the rest of the :rf.event/* and :rf.<feature>/<lifecycle> event-vocabulary it appears only as a :rf.event/dispatched event vector and belongs to the dispatched-event family, not this error catalogue. (2) :rf.warning/plain-fn-under-non-default-frame-once is RETIRED here (the strikethrough row below — the always-on :rf.error/no-frame-context covers the case). The source does not emit the category and the conformance scan's out-of-catalogue-allow-list is EMPTY. The category stays out of the live catalogue.

The always-on error-emit listener (surface #4) is the exception: it survives goog.DEBUG=false and delivers one tight record per catalogued promoted (always-on) runtime :rf.error/* (the handler / interceptor / cofx / flow / fx / reserved-fx / reactive- & compute-sub-exception categories, the parametric sub-input materialization categories :rf.error/sub-input-fn-exception / :rf.error/sub-input-fn-bad-return, the invalid-operation categories :rf.error/no-frame-context, :rf.error/bad-frame-provider-arg, :rf.error/frame-destroyed, :rf.error/no-such-handler, :rf.error/no-such-sub, :rf.error/no-such-fx, :rf.error/unregistered-cofx, :rf.error/override-fallthrough, the suppressed-write category :rf.error/write-after-destroy (the replace-container! choke point's nil-container drop — the write-path partner of :rf.error/frame-destroyed), the teardown-discrimination category :rf.error/on-destroy-handler-exception (the dedicated :on-destroy-throw signal), plus the frame-teardown report :rf.error/frame-teardown-failed — one bounded record per destroy carrying a :hook-failures vector, per §Observability channels and the promotion criterion; plus the machine action / guard exception :rf.error/machine-action-exception — a throwing action, a throwing guard (which converges on the same surface), an :on-done callback, or a destroy-time :exit action — a NON-EVENT union record (structural-only, carrying :failing-id = the action/guard keyword + :state = the active state path) fanned through the SAME :error-emit/dispatch-error-record hook, since machines ships above core's require graph; plus the machine fail-closed spawn reject :rf.error/machine-spawn-unregistered-type — a runtime spawn of an UNREGISTERED :machine-id (no inline :definition) is rejected fail-closed and emits this NON-EVENT union record (structural-only: :machine-id / :frame / :reason) via the :error-emit/dispatch-error-record hook so an off-box shipper sees a refused spawn under goog.DEBUG=false; plus the drain-depth halt :rf.error/drain-depth-exceeded (rf2-fcbrjo) — the run-to-completion drain hitting its :drain-depth limit (a runaway / infinite dispatch cascade) is inherently DATA-dependent and PRODUCTION-only, so before promotion it went silent under goog.DEBUG=false; it now fans a NON-EVENT union record (structural-only: :depth / :queue-size / :last-event-id / :tail-event-ids (the cycle-evidence ring of the last K settled ids — the repeating suffix names the runaway cycle) / :dropped-event-ids / :rollback?) via the :error-emit/dispatch-error-record hook, with the rich human :reason prose staying on the DCE'd dev trace; plus the promoted SSR error categories:rf.error/ssr-render-failed, :rf.error/ssr-streaming-writer-failed, :rf.error/malformed-hydration-payload (both the hydrate-handler path AND the pre-frame FRAMELESS parse sub-path, the latter carrying :frame nil per the :rf.error/no-frame-context frameless precedent), :rf.error/ssr-head-resolution-failed, :rf.error/sanitised-on-projection, :rf.error/ssr-ring-error-view-failed, :rf.error/hydration-frame-id-mismatch (the :rf/hydrate HANDLER's direct-dispatch-sync frame-id-mismatch guard; the boot helper hydrate! validates + THROWS pre-dispatch as the diagnostic-channel sibling, but a direct dispatch bypasses the boot check and hits only the handler, which fails closed and emits the always-on record), and :rf.error/ssr-ring-response-status-invalid (rf2-gblft — the Ring materialiser's fail-closed :status rewrite; a non-integer status turns the app's 200 into a 500, and until promotion that flip's only signal was a dev-bus warning, so a production host answered 500 with nothing on either axis. FRAMELESS, :frame nil, because the materialiser is a pure map-to-map fn) — each production-reachable on a long-lived JVM SSR host or a goog.DEBUG=false client build where the dev trace is elided. These eight SSR categories are NON-EVENT records: they ride the general re-frame.error-emit/dispatch-error-record! union-record helper (the non-event sibling of dispatch-on-error!, shared with the teardown report), NOT the event-centric per-dispatch path. The recoverable-degradation members (:rf.error/ssr-head-resolution-failed, :rf.error/ssr-ring-error-view-failed), the post-commit members (:rf.error/ssr-streaming-writer-failed, :rf.error/sanitised-on-projection) and the post-resolution materialiser member (:rf.error/ssr-ring-response-status-invalid) are NON-PROJECTING — the SSR error-emit-projection-listener skips them, so promotion changes what off-box SHIPPERS see, never what the WIRE does. Two SSR resource categories — :rf.error/resource-ssr-blocking-timeout and :rf.error/resource-route-blocking — are DEMOTED (kept diagnostic): their failure is recorded in observable resource/route state, so they fail the promotion criterion's leg 2 under BOTH limbs — nothing leaks, and the refusal is not invisible because the state says so — and their named home is the resources trace family + the observability-sink routing, not this axis (see their catalogue rows). Plus the closed-vocabulary scroll reject :rf.error/unsupported-scroll-strategy (rf2-2hkfy) — the :rf.nav/scroll handler's default branch, which is the rejection's ONLY leg on a host without the optional schemas artefact (with it, the :fx-args gate rejects the same value one step earlier). Because that leg exists precisely for the schemas-less configuration, emitting it through the DCE'd dev trace alone left a schemas-less PRODUCTION host with no scroll and no record — the accepted-and-ignored silence rf2-px26m had just removed. It now fans through emit-error-both! like the other production-reachable runtime rows, so an off-box shipper sees the refused strategy under goog.DEBUG=false; the rich :reason prose rides the record (a caller-authored config value, not user data). Plus the three safe-redirect rejections:rf.error/safe-redirect-invalid-url, :rf.error/safe-redirect-scheme-rejected and :rf.error/safe-redirect-host-disallowed (rf2-6jqa8). :rf.server/safe-redirect's five-step gate is production-real and rejects correctly under -Dre-frame.debug=false — that half was never in doubt — but the rejection is a silent no-op on the wire (the fx returns nil and the response simply carries no redirect), and until promotion it reported ONLY through the debug-gated trace/emit-error!. So on a production JVM an attacker-supplied ?next=javascript:alert(1) produced no shipper event, no metric and no frame-owned :observability :errors record: a security team could not see open-redirect probing against their own app. Note the asymmetry that motivated it — the CRLF / NUL gate on the SAME fx THROWS, so it rides :rf.error/fx-handler-exception and has always been always-on. Two halves of one security surface with opposite production observability. Each of the three now fans a NON-EVENT union record through the :error-emit/dispatch-error-record hook (the malformed-hydration-payload sibling — a rejected redirect is not a dispatched-event failure). :location is caller-untrusted BY CONSTRUCTION — that is the entire reason this fx exists as the sibling of the caller-trusted :rf.server/redirect — and a rejected target routinely looks like ?next=https://evil.example.com/cb?token=…, so the tag map is built ONCE for both axes with the EP-0015 egress scrub applied to :location INSIDE the builder: the query / fragment carrier VALUES are redacted while the structured path, the scheme and the host are kept. Building once is what stops the production record and the dev trace disagreeing about the same rejection — but the two axes do not carry the same map, and the difference is the whole point. That scrubbed :location is DEV-TRACE-ONLY; the always-on record carries no URL, and no component of one. The blanket EP-0015 carrier scrub does string surgery after the first ? or #, which is right over the app's OWN URL space (the route-miss :url of :rf.error/no-such-handler :kind :route) and is not fail-closed over an arbitrary attacker-supplied FOREIGN one: userinfo (https://alice:pw@host/), the whole path (a reset token is the canonical opaque path-borne secret) and value-less query keys all sit LEFT of the carriers and rode out verbatim — four secrets demonstrated shipping against HEAD under -Dre-frame.debug=false before the fix. Widening the deny-list would leave little but the scheme and host anyway and would still be one URL component from the next leak, so the always-on record is instead BUILT FROM a closed allow-list: #{:frame :recovery :reason :scheme-class}. Built from, not filtered down to — an unrecognised tag then has no path into the record even in principle — and by NAME as well as by slot, since :scheme-class is a classification of the diagnostics' :scheme rather than a copy of it. A closed set of keys is not yet a closed set of values, and this record is the corpus's worked example of the difference. The shape that first replaced the scrubbed URL carried the PARSED :scheme and :host as strings, on the reasoning that a parsed component is structural. It is not: parsing locates a substring in the grammar and says nothing about who wrote it. A scheme is any ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) (RFC 3986 §3.1), so s3cr3t-probe-token:payload reached the non-http(s) arm carrying the sentinel; a rejected host is by definition one the app did NOT authorise, so https://s3cr3t-reset-token.evil.example/ shipped that name whole. Both also let a prober drive cardinality — a fresh host per request writes unbounded distinct values into a metrics dimension, the records meant to reveal a flood becoming one. So :scheme now arrives as :scheme-class, a lookup into the framework's own closed scheme vocabulary (:javascript / :data / :vbscript / :http / :https, else :other) that folds case first so an alternating-case prober cannot fragment one spike across buckets, and :host is dropped outright: on every arm that carried it, it is a name the app did not authorise, the redirect is already refused so nothing is left to block, and it was the record's last unbounded caller-authored string — :reason keeps the discrimination an operator acts on. :allowlist is absent for its own reason — the application's own security configuration, unbounded policy data naming the boundary being probed, which :reason :not-in-allowlist discriminates without disclosing. Every value the record can carry is therefore a framework-owned keyword or the frame's own id. The source of truth is re-frame.ssr.egress/safe-redirect-record-slots (with the vocabulary at re-frame.ssr.egress/scheme-classes, pinned to the gate's own scheme sets by a parity test), and both are pinned closed by re-frame.ssr-safe-redirect-production-test. All three are NON-PROJECTING, and that half is load-bearing rather than incidental. They are members of re-frame.ssr.error-listener/non-projection-eligible-errors, so the SSR error-emit-projection-listener never buffers them for status projection: the rejection ships a record and NEVER stamps a status. Were they projection-eligible, the default projector's :else arm would map the buffered record to the locked generic 500 and ?next=javascript:alert(1) would become a trivial denial of service — the exact inverse of the promotion's intent (mutation-proved: removing the three entries turns all three wire arms 500 under -Dre-frame.debug=false). The skip sits at the SHARED projection chokepoint, so both postures answer alike; before it, a rejected safe-redirect already stamped 500 in DEV through the trace-buffering path while production answered 200 — a dev/prod wire asymmetry on a security surface, closed by the same change. Plus one arm of :rf.error/schema-validation-failure — the :rf.schema/at-boundary rejection (:source :boundary, :where :event; rf2-mwv4e). The category's other arms stay diagnostic, so this is the :rf.error/no-such-handler shape rather than a whole-category promotion: the Channel column is per-category, and the arm-level nuance rides the row's prose. Spec 010 keeps the boundary check UNGATED because it is the production answer for untrusted ingress, so the rejection was always real in production; what was missing was the signal — and worse, the skipped handler produced no :db, so the always-on :events record read :outcome :ok for a refusal. The rejection now fans a NON-EVENT union record through the :error-emit/dispatch-error-record hook from the router's pipeline tail, off the :rf/boundary-rejected? marker the interceptor stamps — one emit site for both enforcement routes, so a rejection can never produce two records — and that same marker settles the dispatch :outcome :rejected. The record is STRUCTURAL-ONLY (:where / :source / :event-id / :failing-id / :schema-id / :frame / :recovery, and nothing payload-derived); the paragraph below says why that is stricter than a scrub rather than weaker. Unlike the three safe-redirect rejections above, this record IS projection-eligible, deliberately: a boundary rejection on a server frame is exactly a 400 (RFC 9110 §15.5.1), the SSR default projector's existing :where :event arm already answers one, and letting it project is what closes the silent-200 SSR hole the promotion was filed against. This set is exactly the rows the Channel column below marks always-on. Registration-time / dev-only-validation categories (the :rf.error/machine-* registration rejections — e.g. :rf.error/machine-spawn-bad-shape, :rf.error/machine-spawn-all-bad-shape; NOTE the runtime reject :rf.error/machine-spawn-unregistered-type is the exception — it is always-on per above, not a registration-time category — :rf.error/reg-sub-bad-args, :rf.error/at-boundary-missing-schema, the :rf.warning/* advisories, the :rf.epoch/* rejections) stay dev-trace-only, because they fire on dev-only paths production never reaches, so there is nothing to survive. :rf.error/schema-validation-failure is no longer among them: its validate-*! arms are dev-only in exactly that way, but its boundary arm is not, and that arm is the one promoted above. The :recovery column below is the runtime's built-in, framework-owned recovery; it is NOT app-steerable (there is no per-frame :on-error recovery policy — see §What IS available in production).

:rf.error/schema-validation-failure is promoted one arm at a time, and the split is worth stating rather than leaving the row to carry it alone. Most of the category's arms are genuinely dev-only — the validate-*! family in schemas.cljc elides with the rest of the diagnostic channel, and production never re-runs those paths. The :rf.schema/at-boundary arm does not. The boundary interceptor is the production answer for untrusted ingress — that is the whole point of it — and per 010 §Production builds its check runs on every build. The rejection was therefore always real in production: the handler is skipped and the bad payload never reaches app-db. What did not survive was the signal. The emit was trace/emit-error!, gated on interop/debug-enabled?, so under :advanced + goog.DEBUG=false a boundary rejection reported nothing on either axis — and because the skipped handler produced no :db, the always-on :events record for that dispatch read :outcome :ok. A refusal reported as a clean settle. Production-reachable and production-observable are different claims, and this arm was the first with the former and without the second. Legs 2 and 3 of the promotion criterion are graded now (rf2-mwv4e): a refused payload at an untrusted ingress is a contract breach the next operation cannot see locally, and its silence compounds across a long-lived SSR host under probing, which is exactly when it matters. Leg 1 it plainly met all along. So the arm rides the always-on axis, the row below reads always-on, and the dev-only arms are named in the row's own prose.

The always-on record is structural-only, and that is STRICTER than a scrub rather than weaker. A validation failure's natural detail is the value that failed — which is why the dev trace carries :value, :received and :explain. At a boundary that value is attacker-controlled or user-private by definition, and it can carry secrets under keys the declared schema never anticipated, so no key-wise redaction rule can be trusted over it: the record is built from identifiers the framework already knows (:where / :source / :event-id / :failing-id / :schema-id / :frame / :recovery) rather than filtered down from the diagnostic tags. :reason is omitted for the same reason and not by oversight — the dev trace's :reason interpolates the offending value into its prose. The dev/production difference that remains is one of DETAIL, not existence: local debugging keeps the payload, and an off-box shipper learns which event, in which frame, failed which schema.

Reading the two right-hand columns. This paragraph is the single statement of which slots a row's :tags cell omits, and why each is omitted; Spec-Schemas §Per-category :tags schemas cites it rather than restating it.

Read which surface the row documents first. The sixth column is headed :tags because most rows emit a trace event, but the catalogue carries three kinds of row, and the column names the payload of whichever surface that row's category actually reaches:

  • Trace-event rows — the category emits through re-frame.trace/emit! / emit-error!. The column lists the keys that genuinely ride under :tags on the wire, and the two row-inherited omissions below apply.
  • Thrown-ex-info rows — the "Surfaced as a thrown ex-info, not a trace" rows. Their payload is the flat ex-data map built by re-frame.error/throw-error!; there is no :tags map at all, so the column names ex-data slots. Per §The thrown-error shape, :rf.error/id, :where, :recovery and :reason are required on every thrown runtime error — so :recovery on such a row states the contract rather than breaking it.
  • Always-on union-record rows — the category fans a pre-built record through re-frame.error-emit/dispatch-error-record!. That record is flat too, and both projection listeners (the SSR error-emit-projection-listener, the frame-sink route-error-record!) lift every non-summary slot — :recovery among them — onto a synthesised :tags, so a consumer does read it there.

A category that reaches two surfaces states both in one cell; :rf.error/derived-container-replaced is the model (`:reason`; thrown ex-data also carries `:rf.error/id`, `:where`), and :rf.error/unsupported-scroll-strategy splits its cell by axis.

On a trace-event row, two slots are row-inherited: re-frame.trace/build-event supplies them centrally, so no row rosters them and a trace-event row that lists one is a row defect, not a counterexample. They are inherited for DIFFERENT reasons, and the difference is exactly what a consumer needs in order to know where to read each:

  • :recovery is envelope-level — never a :tags key at all. Emit sites supply the disposition inside the tags map, but build-event STRIPS it and hoists it to the envelope top level on every branch (the :error branch additionally defaults it to :no-recovery). The Default :recovery column is where a row's disposition is stated; restating it under :tags names a key no consumer can read there. Per Spec-Schemas §:rf/error-event, where the envelope types :recovery at the top level.
  • :category IS a :tags key on an error envelope — it is builder-owned, not envelope-level. build-event synthesizes {:category <operation>} into :tags on the :error branch only, so a consumer does read it there and every per-category schema declares it; what makes it row-inherited is that it is the same key with the same derivation on every such row, never a fact a row contributes. On a :warning / :info / run-body envelope there is no [:tags :category] at all — the category rides the top-level :operation. Consumers should branch on :operation (present on every envelope) rather than [:tags :category]. Per Spec-Schemas §:rf/error-event, where the envelope types :category as present-on-:error.

Note this makes an :op-type reading subtle for a handful of :rf.warning/* rows whose emit site routes through trace/emit-error! (:rf.warning/sub-input-dispose-exception, :rf.warning/sub-arg-cache-fragmentation, :rf.warning/restore-quiesce-hook-exception, :rf.warning/teardown-hook-exception, :rf.ssr/hydration-mismatch): the :op-type column records the semantic severity the category carries, while the built envelope's own :op-type is :error (and so those five DO carry [:tags :category]). For :rf.ssr/hydration-mismatch this applies to the hiccup-tier emit only; the compiled-tier emit (rf2-6z1i2) routes through plain trace/emit! with a :warning envelope, exactly like :rf.ssr/phase-flip, so it carries no [:tags :category]. The category keyword on :operation is the discriminator in both readings.

The :tags column is pinned, not documentary. Spec-Schemas §Per-category :tags schemas declares one canonical *Tags schema per trace-event row, and a conformance test diffs the pair as key sets: every key a schema declares must be named in its row's cell, minus the two row-inherited slots above. A category that gains a payload key without gaining the cell entry fails the gate — which is what stopped this column being the one the ratchet never reached. Two things follow for authors. A key must be listed, not merely mentioned: a keyword appearing only inside a cross-reference link in the cell is prose about the key and does not document it. And a firing diff does not automatically convict the row — a schema declaring a key the category never actually carries is the defect, and that fix belongs in Spec-Schemas.

:operation :op-type Channel Trigger / meaning Default :recovery :tags
:rf.error/handler-exception :error always-on The event handler itself threw (the terminal :before that invokes the registered reg-event-{db,fx,ctx} body). Scoped to the handler: a throw from a coeffect supplier (context assembly) or a user interceptor in the same :before/:after chain emits its OWN component-attributed category (:rf.error/coeffect-exception / :rf.error/interceptor-exception) instead of collapsing into this one. The runtime reads the chain-captured component identity (:rf/interceptor-error's :id / :rf/cofx-id / :phase) and emits the matching category. Emitted by re-frame.router/emit-pipeline-exception!. Per 002 §Interceptor chain execution :no-recovery — the exception propagates; the cascade halts (no :db install, no :fx) :event (the event vector — the bare ERROR-tag spelling re-frame.router/emit-pipeline-exception! stamps, not the dispatch-pipeline :rf.event/v), :event-id, :failing-id (the event id), :handler-id (the event id), :frame, :phase (:before), :reason, :exception, :exception-message
:rf.error/coeffect-exception :error always-on A coeffect supplier threw while it ran during context assembly — the registered reg-cofx value-returning supplier raised. Distinct from :rf.error/unregistered-cofx (a declared id with no registration, the typo case): this is a registered ambient supplier whose body threw, which fails the event (the handler is skipped: no :db install, no :fx) exactly like a handler throw. Split out from :rf.error/handler-exception so the failure is attributed to the true failing cofx rather than mis-reported as the event handler. Emitted by re-frame.cofx from the satisfaction step; the supplier throw is captured (not re-propagated as a raw Throwable) so exactly one pipeline-exception trace surfaces. Per 001 §reg-cofx + 002 §Recordable coeffects :no-recovery — the handler is skipped (no :db install, no :fx) TWO emitters, and their tag sets differ. The chain-capture arm (re-frame.router/emit-pipeline-exception!): :event (the event vector — the bare ERROR-tag spelling), :event-id, :failing-id (the fully-qualified cofx id), :frame, :phase (:before), :reason, :exception, :exception-message. The EP-0017 supplier arm (re-frame.cofx): :rf.cofx/id, :failing-id (the same cofx id), :rf.trace/event-id (the event whose assembly failed), :frame (when the frame is known), :phase (:before), :reason, :exception
:rf.error/interceptor-exception :error always-on A user interceptor (one registered with rf/reg-interceptor and referenced by id from a reg-event metadata-map / frame-config :interceptors chain — its :before or :after threw) raised. The :phase tag discriminates :before (pre-handler) from :after (post-handler teardown / reshape). ANY chain throw — :before or :after — aborts the event atomically (no :db install, no :fx); an :after throw is a PRE-INSTALL throw under the deferred-commit contract (per 013 §Failure semantics). Split out from :rf.error/handler-exception so the failure is attributed to the throwing interceptor's :id and phase rather than mis-reported as the event handler. Excludes the framework's own auto-wrapper interceptors (the handler-wrapper → :rf.error/handler-exception; the cofx injector → :rf.error/coeffect-exception). The :source-coord tag carries the throwing interceptor's definition-site coord when it was built via the ->interceptor macro (which captures (meta &form) and rides the same absolutise path as the reg-* macros, per Spec 001 §Source-coordinate capture); absent for interceptors built via the ->interceptor* fn or for framework interceptors (nothing to jump to). Tools (Xray's Epoch INTERCEPTOR row) render a jump-to-source chip from it — parity with the event-handler / sub / view coords. Emitted by re-frame.router/emit-pipeline-exception!. Per 002 §Interceptor chain execution :no-recovery — the cascade halts; the :after pass still completes for teardown but no :db installs and no :fx run :event (the event vector — the bare ERROR-tag spelling, not the dispatch-pipeline :rf.event/v), :event-id, :failing-id (the interceptor :id), :frame, :phase (:before / :after), :source-coord ({:ns :file :line}, macro path only), :reason, :exception, :exception-message
:rf.error/machine-action-exception :error always-on A machine action body threw during a transition — OR a guard body threw (which converges on this same surface per 005 §:rf.machine/guard-evaluated), an :on-done callback threw, or a destroy-time :exit action threw (per 005 §Errors and Cross-Spec-Interactions §11). Distinct from :rf.error/handler-exception: the machine layer catches the throw and emits the machine-scoped category instead, so consumers see exactly one error per failure with full machine context. Always-on: rides the production-survivable error-emit axis (surface #4) as a NON-EVENT union record via the :error-emit/dispatch-error-record hook (machines ships above core's require graph), ALONGSIDE the dev error trace (DCE'd in production) — the same always-on-plus-dev-trace shape :rf.error/machine-spawn-unregistered-type carries; a 2am machine throw must still reach an off-box shipper on a goog.DEBUG=false build :no-recovery — the machine cascade halts atomically: the snapshot does not commit (pre-action [:rf.runtime/machines :snapshots <id>] slice is preserved), accumulated :fx from earlier slots in the same Level-2 cascade is dropped, and the :always microstep does not fire on the failed cascade :actor-id (the LIVE actor INSTANCE whose action threw; :machine-id is reserved for the registered TYPE), :failing-id (the throwing action / guard keyword — the always-on production attribution, per §What IS available §Attribution rule; falls back to the actor instance id for an anonymous inline fn), :handler-id (the machine id), :state (the active state path — the always-on attribution, same slot :rf.machine/guard-evaluated uses), :frame, :action-id, :state-path, :transition, :reason, :event (the bare ERROR-tag spelling), :exception, :exception-message, :exception-data (the last four are dev-trace-only — the always-on record is STRUCTURAL-ONLY, so the developer's arbitrary :exception-data, which may embed app secrets, does NOT ride it)
:rf.error/fx-handler-exception :error always-on A registered fx threw during effect resolution :no-recovery — the fx is skipped; run continues if other fx independent :rf.fx/id, :rf.fx/args, :failing-id (the fx-id), :frame, :reason, :exception, :exception-message, :rf.fx/from (keyword-redirect only — the ORIGINAL fx-id; see the :rf.fx/handled row)
:rf.error/sub-exception :error always-on A subscription's computation threw — via the reactive recompute path (subs/memo.cljc) OR the pure compute-sub resolution path (subs.cljc). Both paths are production-survivable through the always-on error-emit listener (surface #4) — a sub throwing mid-render-to-string under production hardening projects a fail-closed 5xx rather than a silent 200 (the reactive path frame-attributes via :frame; the pure compute-sub path has no reactive frame, so it surfaces to corpus-wide shippers but not the per-frame SSR projector). Recovery is the framework's built-in "return nil". The two arms are told apart by the PRESENCE of the :where tag (see the :tags column), not by its value — there is no :where :reactive :replaced-with-default — the sub returns nil; views see no value :sub-query, :rf.sub/id, :failing-id (the sub id), :where (the pure compute-sub arm only — the reactive recompute arm stamps no :where), :frame, :reason, :exception, :exception-message
:rf.error/no-such-sub :error always-on A subscription's :<- input refers to an unregistered sub (or a subscribe targets an unregistered sub-id). Production-survivable through the always-on error-emit listener (surface #4); recovery is the framework's built-in default for an invalid op. The internal observation port (006 §The internal observation port) adds a throwing emit surface for the SAME category — probe/acquire! on an unregistered ENTRY sub fan the always-on record then throw typed (internal fail-loud; the ViewCell maps the throw to the view error boundary), and an unknown input MID-GRAPH under a cold probe emits with :where :observation-cold-probe and substitutes nil exactly like the reactive graph. One condition, one catalogue id, two emit surfaces; the public recovery column is unchanged :replaced-with-default — the unresolved input is substituted with nil; the sub's body still runs (the observation-port ENTRY surface throws instead — same id, two surfaces) :rf.sub/id, :unresolved-input, :resolved-inputs, :frame, :where (observation-port surfaces only)
:rf.error/sub-cycle :error diagnostic A subscription's :<- input graph closes a dependency cycle — two-or-more subs list each other transitively (:a :<- [:b], :b :<- [:a]) or a sub lists itself (:self :<- [:self]). Detected at subscribe / compute-sub build time (the reactive build's per-thread under-construction stack, or the pure compute-sub's per-call memo), which unwinds the partial build BEFORE any cyclic reaction is cached rather than blowing the host stack with a raw StackOverflowError. Registration cannot catch it (the other sub may register later; parametric inputs are dynamic — subs/cache.cljc's transitive-dependent-closure already treats cyclic :<- graphs as an acknowledged input class). Classified like the flows typed-cycle precedent :rf.error/flow-cycle — dev-only DIAGNOSTIC (thrown-then-caught to unwind, surfaced on the trace channel, DCE'd under :advanced + goog.DEBUG=false). The :where tag discriminates the build path. Emitted by re-frame.subs. Per 006 §Subscription cache :replaced-with-default — the cyclic subscription recovers to a nil-yielding reaction (NOT cached, mirroring the no-such-sub miss), so a registration fix rebuilds cleanly :rf.sub/id, :rf.sub/query-v, :where (:subscribe / :compute-sub), :cycle (the closing-repeat sub-id path, e.g. [:a :b :a] or [:self :self])
:rf.error/read-after-release :error always-on The observation port's read was called on a handle AFTER release! — a SUBSTRATE bug, never an app error: the generated commit path current?-checks before reading and the render path probes, so the throw is unreachable in correct generated code (per 006 §The internal observation port §Handle semantics). The throw is armed in production too (it guards ownership-accounting corruption, not a dev convenience); the always-on record is fanned through surface #4 BEFORE the typed throw so a boundary-swallowed throw still reaches off-box shippers. Thrown by re-frame.substrate.observation/read :no-recovery — the read throws; the handle's owner must retarget through the staged commit path (the ViewCell maps the throw to the view error boundary) :rf.sub/query-v, :frame, :where (re-frame.substrate.observation/read)
:rf.error/reentrant-graph-op :error diagnostic The observation port's acquire! or release! was called from INSIDE the owner-notification fan-out (an on-change callback mutating graph ownership mid-notification) — forbidden because on-change is constant-work mark-dirty by contract (per 006 §The internal observation port §Callback and reentrancy rules). Dev-asserted: the guard binding + check sit behind interop/debug-enabled? and DCE under :advanced + goog.DEBUG=false (the fan-out marker itself is dev-only machinery). React-driven acquire/release — the calls inside the layout COMMITS the owner-notification schedules via mark-dirty, flushed when the pending render batch closes at a later host checkpoint (coalesced across a batch, decoupled from epoch count) — run after the fan-out has returned and never trip it. Ownership moves in COMMITS only — a render probes without acquiring — so naming renders here would contradict this port's own contract. Thrown by re-frame.substrate.observation :no-recovery — the op throws; defer the acquire/release to the commit the notification schedules (thrown ex-data: :rf.error/id, :where (re-frame.substrate.observation/acquire! / …/release!))
:rf.error/observation-malformed-target :error diagnostic A target-taking observation port op (probe / acquire!) received a target that violates the port's CLOSED target grammar — a non-map, an unknown :kind, missing/extra keys, a wrong-domain frame identity (an absent / non-keyword :frame-id), an empty / non-keyword-headed :query, or a supported-:kind-but-INCOMPLETE target (a bare {:kind :story-override} that would otherwise mint a nil-shaped static handle / observation). None of these could have come from resolve-target (the port's ONLY resolution point) — a substrate/consumer bug unreachable in correct generated code. The port validates the full closed shape per :kind BEFORE any host op (rf2-vxgfnd.183 broadened the rf2-vxgfnd.36 unknown-:kind-only default); without it a malformed target would reach (first query) / a frame-registry op (or the op's (case (:kind target) …) default) and leak a BARE host error (No matching clause, a ClassCastException, an NPE) the ViewCell error boundary cannot classify; the port is fail-loud and throws this typed category instead (per 006 §The internal observation port §Error contract). A pure throw-error! on the diagnostic channel — a corrupted target is a programming defect, not a production-runtime condition an off-box shipper acts on, so it does NOT fan the always-on axis (rf2-vxgfnd.36 / rf2-vxgfnd.183). Carries BOUNDED + NORMALIZED structural evidence only (rf2-vxgfnd.241 replaced the prior raw :kind + full key vector): the target's kind-class (a recognized :subscription / :story-override, else :unrecognized — never a raw or secret :kind value), its total key-count (an O(1) count), and which of the port's OWN known keys are present (known-keys-present — a fixed-vocabulary contains? probe, so an attacker's extra key is never named, hashed, or enumerated); never the field VALUES (a :story-override embeds an app value under :value). resolve-target — the port's ONLY resolution point — is a THIRD throwing surface for this same id (rf2-vxgfnd.241): it validates the incoming query-vector SHAPE before any sequence access ((first query-v)) and before minting a target, since a malformed query cannot yield a valid closed-grammar target; that rejection carries the query-class plus a vector's query-count, never the query CONTENTS. Thrown by re-frame.substrate.observation/probe / …/acquire! / …/resolve-target :no-recovery — the op throws; construct targets via resolve-target (thrown ex-data: :rf.error/id, :where; probe/acquire path — :kind-class, :key-count, :known-keys-present; resolve-target path — :query-class, :query-count. Bounded/normalized structural evidence only, never raw key/value or query material)
:rf.error/observation-malformed-handle :error diagnostic A handle-taking observation port op (read / release!) received a value that is NOT a real ObservationHandlenil, a map, or any arbitrary host object. read / release! field-access the handle state (handle-state) and then DEREF it, so an unvalidated non-handle would throw a raw NullPointerException (JVM) / untyped host error (CLJS) carrying no :rf.error/id the ViewCell error boundary can classify — the half-hardened boundary rf2-vxgfnd.183 closes (the target arm was typed by rf2-vxgfnd.36; this is the sibling HANDLE arm). The port validates at the shared handle boundary and throws this typed category BEFORE any field-access (per 006 §The internal observation port §Error contract). A DISTINCT diagnostic id in the SAME observation-malformed-* family as the target category (a handle is not a target, so it earns its own id — not a second family). current? AND owned? are EXEMPT — both are pure no-throw kept-check predicates that return false for a non-handle, never throwing (rf2-vxgfnd.241 made owned? TOTAL to match current?: a value that is not a live node handle simply owns no node, so it reads false rather than field-accessing the handle state and leaking a raw host error). A pure throw-error! on the diagnostic channel — a non-handle reaching a handle op is a programming defect, unreachable in correct generated code, so it does NOT fan the always-on axis. Carries BOUNDED structural evidence only (the argument's host TYPE, never the value). Thrown by re-frame.substrate.observation/read / …/release! :no-recovery — the op throws; handles come from acquire! (thrown ex-data: :rf.error/id, :where, :handle-type (the offending value's host type))
:rf.error/observation-port-version-mismatch :error always-on A compiled-view observation-port consumer loaded against a core whose re-frame.substrate.observation/port-abi-version differs from the version it compiled against — the port's explicit ABI drift guard (per 006 §The internal observation port §Scope). The port is adapter-internal and its consumers are never resolved independently of core, so skew is always a stale build: an in-tree consumer is built from the same commit as core, a published one ships on core's lockstep release train (R-6). A boot error, never undefined behaviour: the consumer asserts at load via assert-port-abi-version!, which fans the always-on record through surface #4 then throws typed :no-recovery — the boot assert throws; rebuild the consumer against this core (an in-tree consumer from the same commit; a published one from core's lockstep release train) :expected, :actual, :where (re-frame.substrate.observation/assert-port-abi-version!)
:rf.error/observation-retry-exhausted :error always-on The observation port's acquire! exhausted its bounded live-cache-displacement retry budget (max-displacement-retries) while the targeted frame incarnation stayed verifiably live — a pathological-but-legal storm of HMR sub re-registrations / explicit cache clears displaced the just-built canonical node in every build→canonical-check window (per 006 §The internal observation port). This is an acquire-path livelock, NOT a destruction: acquire! just PROVED the incarnation alive, so it reports this TRUTHFUL condition rather than lie :rf.error/frame-destroyed for a live frame (rf2-vxgfnd.79). Rides the always-on axis (surface #4) — fanned via emit-error-both! BEFORE the typed throw so a boundary-swallowed throw still reaches off-box shippers, exactly like the sibling :rf.error/frame-destroyed; the ViewCell maps the throw to the view error boundary. Thrown by re-frame.substrate.observation/acquire! :no-recovery — the acquire throws; retry the commit once the re-registration / cache-clear storm settles (the frame is live, not destroyed — a persistent storm is a registration / tooling bug to fix) :frame, :rf.sub/id, :rf.sub/query-v, :attempts (build attempts made = budget + 1), :max-retries (the budget), :frame-incarnation (:live), :where (re-frame.substrate.observation/acquire!)
:rf.error/observation-on-change-failed :error always-on A former-owner on-change callback threw during the observation port's HMR / disposal notification drain (drain-pending-disposals!) — a mark-dirty defect in the compiled-view consumer's ViewCell (per 006 §Disposal-notification callback failures). The drain contains each handle's notification in its own try/catch (a throwing owner never starves its siblings — full sibling drain) and re-throws the first escape AFTER the drain, but BOTH real boundaries discard that rethrow — the :hmr drain runs inside registrar's replacement hook (per-hook try/catch drops it) and the :disposed drain rides an unobserved interop/next-tick Future — so every escape is surfaced EXACTLY ONCE before the boundary swallows it (Spec 009's one-runtime-error law). Classification is by OPAQUE, channel-aware provenance (rf2-w55bh0), never a channel-blind fanned Boolean nor a reconstructible :rf.error/id-shape test: an escape whose source ALREADY covered the always-on axis (the port's own emit-error-both! emit-then-throw surfaces — read-after-release, the fail-loud probe/acquire throws, the ABI guard, the retry-exhausted throw, the acquire-recovery input-fn arms) keeps its source's record and is NOT double-reported on either channel; every OTHER escape — a source that covered only the diagnostic TRACE axis (the production-elided :rf.error/sub-cycle), a diagnostic-only thrown category (:rf.error/observation-malformed-target / …-malformed-handle / the dev :rf.error/reentrant-graph-op assert), a raw untyped consumer bug (TypeError / AssertionError / host RuntimeException), or an application ex-info SPOOFING a framework category — is wrapped in this stable catalogued id WITHOUT promoting its own category onto the always-on axis, carrying the original throwable as :exception. The wrapper rides the TWO-CHANNEL fan-out error-emit/emit-error-both! (rf2-q3fmqm): the always-on record (surface #4) for off-box shippers PLUS the dev diagnostic-trace event Xray's trace tooling consumes; under :advanced + goog.DEBUG=false the trace leg DCEs while the always-on record survives (exactly one always-on record, zero trace events). Emitted by re-frame.substrate.observation/drain-pending-disposals! :no-recovery — the notification failed; the escape is contained (siblings still notified) and surfaced for off-box shippers. The underlying bug is a defect in the observation-port consumer that registered the callback ALWAYS-ON record: :event (the former owner's query vector, wire-elided), :event-id (the entry sub id), :frame, :exception (the original throwable, as the record's cause), :source-coord ([:sub id] when the sub was macro-registered; omitted for a programmatic sub). DEV-TRACE :tags (canonical schema Spec-Schemas §ObservationOnChangeFailedTags): :category, :rf.sub/id, :rf.sub/query-v, :where, :cause (:hmr / :disposed), :exception, :exception-message, :reason (:recovery :no-recovery hoists to the trace-event envelope top-level)
:rf.error/reg-sub-bad-args :error diagnostic A reg-sub registration shape is not one of the three accepted forms (app-db reader / static :<- / parametric two-function (reg-sub id input-fn computation-fn)) — e.g. a non-fn where the input-fn or computation-fn was required, an unrecognised arg sequence, or a malformed :<- clause. Registration-time / dev-only validation (it fires on the registration path, which production never re-runs), so it stays dev-trace-only and does NOT ride the always-on production error-emit listener. Per 006 §Subscription input producers and API §reg-sub input-production modes :no-recovery — the registration is rejected; the malformed reg-sub is a programming error to fix :rf.sub/id, :received (the offending arg shape), :reason
:rf.error/sub-input-fn-exception :error always-on A parametric subscription's input-fn threw while materializing a concrete subscription node (the cache-miss / compute-sub resolution path, not the hot recompute path — the input-fn runs only at materialization). Production-survivable through the always-on error-emit listener (surface #4); recovery is the framework's built-in fail-closed. Per 006 §Subscription input producers :replaced-with-default — materialize a nil-yielding reaction when safe; do NOT silently treat the failure as "no inputs" :rf.sub/id, :rf.sub/query-v, :where (:reactive / :compute-sub), :frame, :reason, :exception, :exception-message
:rf.error/sub-input-fn-bad-return :error always-on A parametric subscription's input-fn returned a value that violates the input grammar — a scalar, map, bare keyword, reaction, derefable, malformed query vector, or any shape other than a vector of query vectors (per Conventions §reg-sub input grammar). Production-survivable through the always-on error-emit listener (surface #4); listener-only. A bad input return is NEVER silently treated as no inputs. Per 006 §Subscription input producers :replaced-with-default — materialize a nil-yielding reaction when safe; emit the structured error with the outer query vector and sub id :rf.sub/id, :rf.sub/query-v, :where (:reactive / :compute-sub), :returned (the offending return shape / class), :frame, :reason
:rf.error/schema-validation-failure :error always-on A :schema-validated value failed validation. Only one of the category's arms is on the always-on axis, and the Channel column is per-category, so the nuance rides here. The :rf.schema/at-boundary arm (:source :boundary, :where :event) fans a STRUCTURAL-ONLY non-event union record through the :error-emit/dispatch-error-record hook and survives goog.DEBUG=false (rf2-mwv4e); the dev-time validate-*! family (:app-db / :fx-args / :sub-return / :flow-output / :machine-data / :machine-output / :sub-override) stays dev-trace-only and elides with the rest of the diagnostic channel. That is the same partial-promotion shape as :rf.error/no-such-handler, whose row reads always-on while only its :kind :route arm was promoted. The always-on record is emitted from re-frame.router's pipeline tail (emit-boundary-rejection-record!) off the :rf/boundary-rejected? marker the interceptor stamps, so the dev and production enforcement routes share ONE emit site and a rejection can never produce two records; the same dispatch settles :outcome :rejected on the always-on :events record Per-:where — recovery is NOT uniform across the boundaries this one category spans; see the §Schema-validation-failure per-:where recovery table below. In brief: :event skips the handler; :app-db / :machine-data reject the candidate frame transition before it installs; :fx-args skips just the offending fx and continues; :sub-return / :sub-override surface nil and render on; :flow-output / :machine-output write / complete best-effort and proceed. Production builds elide the dev-time validate-*! arms entirely (per Spec 000 §Contract C-000.35), so those boundaries apply only in dev; the :rf.schema/at-boundary arm's check AND its record both run on every build. The category vocabulary is stable (per Spec-ulation), so the per-boundary variance is carried by :where + the mini-table rather than by forking the category Always-on record (axis 1, :source :boundary ONLY) — structural only: :where (:event), :source (:boundary), :event-id, :failing-id and :schema-id (all three the event id — the same three-slot shape the dev trace carries, so the two axes name the same fact identically), :frame (may be nil, per the :rf.error/no-frame-context frameless precedent) and :recovery, alongside the :error / :time the union-record helper assocs. NO payload-derived slot: no :value, no :received, no :explain, and no :reason — the dev trace's :reason interpolates the offending value. Dev trace (axis 2) — every arm, and it adds the payload: :where (:event/:sub-return/:app-db/:fx-args/:flow-output/:machine-data/:machine-output/:sub-override), :path, :value, :explain (Malli explanation map), :received (parallel to :value — the :machine-data/:machine-output/:app-db/:event/:sub-return/:fx-args arms) and the interpolated :reason on the boundary arm, plus the per-arm structural extras: :rf.sub/query-v (:sub-return only — the caller's query vector), :rollback? (:app-db/:machine-data/:machine-output — whether the candidate transition was rejected before install), :registered-path (:app-db only — the registration root), :machine-id and :phase (:machine-data/:machine-output only — the failing machine and its lifecycle position), and :schema (:machine-data/:machine-output only — the registered schema verbatim) — all DCE'd under goog.DEBUG=false
:rf.error/malformed-schema :error diagnostic A REGISTERED app-db (or machine-:data) schema is structurally malformed — a childless [:vector], an unknown op, etc. Malli validates schema FORMS lazily (at validate-time, not registration-time), so the bad form registers cleanly and then makes the registered validator THROW on the first candidate validation (rf2-uhk9ko — validation runs over the CANDIDATE frame transition, BEFORE install). validate-app-schema! isolates the throw PER-ENTRY: it surfaces this distinct category, fails CLOSED (REJECTS the candidate — the unvalidated state never installs), and keeps validating the frame's sibling schemas. Before this category the throw aborted the whole loop and was swallowed by the router's defensive (catch … true) as a silent validation PASS — installing an unvalidated commit with no trace and no rejection, AND disabling candidate validation (incl. the privacy-bearing redaction traces) frame-wide for as long as the bad schema stayed registered. Same fail-OPEN class as the path bypass, via the SCHEMA vector instead of the PATH vector. The router's defensive catch emits the SAME category — also fail-closed, :rollback? true, trace-then-REJECT (rf2-uhk9ko retired its treat-as-pass arm) — if a wholesale validator-machinery throw still reaches it, so a throwing validator can neither pass unvalidated state nor go invisible. Dev-only (the validation body is interop/debug-enabled?-gated). The :machine-output boundary catches the same throw at finalize time and PROCEEDS best-effort (:rollback? false — the machine already finished), per 005 §Completion-output validation :no-recovery — the malformed schema is a programming error; the candidate is rejected (the per-entry AND the machinery-throw case) and a distinct trace fires so the developer fixes the registration :where (:app-db/:machine-data/:machine-output), :reason (the throwing-validator message), :failing-id + :frame (the candidate-transition validator arm — the event id whose transition was rejected, and its frame; the machine-output arm carries neither), :path / :registered-path (registration root — structural locator, no user value), :schema (the malformed registration form), :rollback?. The failing app-db value is intentionally NOT carried — the validator never proved the slot's sensitivity, so omitting it is fail-closed
:rf.schema/violation :warning diagnostic A registered app-db path schema changed during hot-reload (file save re-evaluated reg-app-schema with a different schema) and the current app-db value at that path no longer validates against the new schema. Surfaced so dev panels highlight the stale slice; the live app continues running. Distinct from :rf.error/schema-validation-failure (which fires on dispatch-time validation at boundaries); this category fires at the hot-reload edge against pre-existing state. Per 010 §Schema migration on hot-reload. Default is log-and-continue. The :rf.spec/violation name is retired; see MIGRATION §M-54 :logged-and-skipped — the warning fires; app-db is not auto-cleared or rewound; the live app continues :path (the reg-app-schema registration path), :pre-reload-schema (the previously-registered schema form), :post-reload-schema (the newly-registered schema form), :mismatching-value (the current app-db value at :path that fails the new schema), :frame
:rf.error/drain-depth-exceeded :error always-on The run-to-completion drain hit its depth limit (:drain-depth) — a runaway / infinite dispatch cascade (per 002 §Run-to-completion §Rules rule 3). The drain halts the next (unstarted) event; the already-settled events stay durable (no whole-drain rollback under per-event epochs, :rollback? false) and the queue is cleared. Always-on: the halt is inherently DATA-dependent and PRODUCTION-only — under goog.DEBUG=false the dev trace is DCE'd, so before promotion a halted drain shipped NOTHING to any sink and simply went silent (a DoS surface per Security §Error catalogue). All three legs of §The promotion criterion hold (production-reachable; a corrupted-invariant contract breach the next operation cannot see; silence compounds with process lifetime), so the halt fans a STRUCTURAL-ONLY non-event union record out through the always-on axis (surface #4) via error-emit/dispatch-error-record! (the frame-teardown-report sibling; the halt has no handler in scope to rethrough, per 011 §rethrow / Spec-Schemas §:rf/epoch-record) so an off-box shipper sees the halt under goog.DEBUG=false. Cycle evidence: :tail-event-ids is the ring of the last K settled event-ids — the repeating suffix IS the runaway cycle (structural ids only, never event args). The rich human :reason prose + the full :last-event vector stay on the dev-only trace/emit-error! (DCE'd in production, per the §ungated direct-call dev-only diagnostic prose fold). Emitted by re-frame.router/handle-depth-exceeded! :no-recovery — always indicates a bug; the drain halts and the queue is cleared ALWAYS-ON record (structural-only): :depth, :queue-size, :last-event-id, :tail-event-ids (the cycle-evidence ring), :dropped-event-ids, :rollback? (false). DEV-TRACE (adds): :frame, :last-event (the full halting-event vector), :reason (the runaway-cycle hint)
:rf.error/no-such-handler :error always-on A registrar-shaped lookup missed. Covers three distinct failure modes, discriminated by the :kind tag (mandatory on every emit): (1) :kind :event — a dispatch / dispatch-sync arrived with no registered event handler (emitted by router.cljc); (2) :kind :frame — a Tool-Pair surface (restore-epoch!, or a pair-tool injection via replace-frame-state!) addressed a frame-id that is not in the frame registrar (emitted by epoch.cljc; see Tool-Pair §Surface behaviour against destroyed frames); (3) :kind :route:rf.route/handle-url-change (or a route-url caller) saw a URL that matched no registered :path pattern (emitted by routing.cljc; see 012 §Route-not-found and the default-projector mapping at 011 §Default projector). Consumers route on :kind for per-mode handling; tools that want a single "registrar miss" filter match the operation keyword alone. The :kind :event dispatch-miss and the :kind :route URL miss are both production-survivable through the always-on error-emit listener (surface #4) — the route miss as a NON-EVENT union record fanned through the :error-emit/dispatch-error-record hook (routing ships above core's require graph), carrying :kind / :frame / :time / :recovery / structured :reason and a :url whose query and #fragment carrier VALUES are redacted at the emit site per EP-0015, so a production SSR host answers 404 for an unroutable URL instead of a soft-404 200. Because the category reaches the SSR projector in production, 011 §Default projector gates its 404 arm on :kind :route: a :kind :event / :kind :frame miss is a server defect and projects the locked generic 500. Recovery is the framework's built-in :replaced-with-default :replaced-with-default — no-op; emit the trace + always-on listener record :kind (one of :event, :frame, :route — mandatory), plus mode-specific keys: :rf.event/v + :rf.trace/event-id + :frame (:kind :event); :frame (:kind :frame); :url + :frame (:kind :route)
:rf.error/no-frame-context :error always-on A frame-scoped op (subscribe / dispatch, the ambient 1-arity rf/ forms) carried no frame stamp and ran under no established scope — the strict embedded-app absent-target case (per 002 §Frame target resolution). Fails before any frame-registry lookup, so an absent context is never mis-reported as :rf.error/frame-destroyed for a synthesised default. The two firing cases: a plain (non-reg-view) Reagent fn that cannot read the surrounding frame-provider's frame (per 004D §The footgun is now :rf.error/no-frame-context), and a dispatch from a native async callback whose continuation fires after the cascade scope unwound. The error is itself frameless: it rides the always-on error axis (surface #4), so it survives goog.DEBUG=false, and it carries capture-site ancestry through the :rf.trace/dispatch-id / :rf.trace/parent-dispatch-id correlation graph, fully attributing a callback captured at handler X in frame Y whose continuation fired with no stamp. Replaces the retired :rf.warning/plain-fn-under-non-default-frame-once / :rf.warning/dispatch-from-async-callback-fell-through-to-default warnings (rows below). A security surface for lost / tampered frame context (per Security §Error catalogue). Emitted by re-frame.router/dispatch! (dispatch) / re-frame.subs/subscribe (subscribe) :supply-frame — the op fails fast and is NOT routed to a synthesised default; the fix is to carry the frame explicitly (capture it as a value at render time and thread it into the callback, or pass {:frame …}) :operation (:dispatch / :subscribe), :where (:re-frame.router/dispatch! / :re-frame.subs/subscribe), :event-id (the query-id / event-id the op carried), :recovery (:supply-frame), plus the capture-site ancestry correlation keys :rf.trace/dispatch-id / :rf.trace/parent-dispatch-id
:rf.error/bad-frame-provider-arg :error always-on A public frame-provider's :frame was non-nil but neither a keyword nor a live frame value (a string {:frame "app"}, a number, a collection, …). frame-provider accepts a frame TARGET — a frame-id keyword OR a live frame value (make-frame's return token), API-shrink #1 rf2-csbbwu — so anything else is a bad public provider argument, distinct from absence (:rf.error/no-frame-context — a nil :frame) and from a disturbed reader-side React-context read (:rf.error/frame-context-corrupted). Validated at every public provider entry point — Reagent re-frame.views.provider/frame-provider and the shared UIx-spine core re-frame.substrate.spine/build-frame-provider-element — BEFORE the value reaches React Context, so the bad value is never silently coerced to a registered keyword frame by the lower-level context reader's prop-stringified-keyword coercion. Rides the always-on error axis (surface #4) so it survives goog.DEBUG=false, then throws. Emitted by re-frame.frame/emit-bad-frame-provider-arg! (via require-frame-provider-target!) :supply-frame-target — the provider fails fast; supply a frame-id keyword (e.g. :todo) or a live frame value :received (the offending value), :where (the validating provider call site), :recovery (:supply-frame-target), :reason
:rf.error/frame-root-missing-id :error diagnostic The ENSURE component rf/frame-root (rf2-nyea0r split; create-if-absent / reuse-no-reseed / provide-id, no destroy-on-unmount) was mounted with no keyword :id. frame-root creates a frame via make-frame and registers it under :id in the one frame registry, so :id is REQUIRED — an idempotent re-mount under the same id is how the frame-root reuses the live frame without re-seeding across hot reload, and an anonymous frame-root could neither be reused idempotently nor addressed by descendants. It fails fast at mount rather than minting an un-addressable anonymous frame. (Distinct from :rf.error/frame-provider-frame-absent, the SCOPE frame-provider {:frame …} absent-frame boundary; from :rf.error/frame-root-given-frame, the :frame-on-a-root did-you-mean; and from :rf.error/bad-frame-provider-arg, the non-keyword-:frame boundary on the scope shape.) Mount-time validation, thrown ex-info, not a trace; diagnostic-channel. Emitted by re-frame.views.frame-boundary/require-frame-root-id!. Per 002 §frame-root and API §Registration :supply-frame-idframe-root fails fast; supply a keyword :id (its required key), or switch to rf/frame-provider {:frame …} to scope an already-created frame :where (the validating frame-root call site), :recovery (:supply-frame-id), :reason, :received (the offending :id)
:rf.error/frame-root-given-frame :error diagnostic The ENSURE component rf/frame-root (rf2-nyea0r split) was given a :frame — the SCOPE key. frame-root CREATES / REUSES a frame under :id; it does not scope an existing one, so a :frame prop is a did-you-mean configuration error naming frame-provider. Covers the both-keys case ({:id … :frame …} on a frame-root is still a :frame-on-a-root error). Mount-time validation, thrown ex-info, not a trace; diagnostic-channel. Emitted by re-frame.views.frame-boundary/reject-frame-root-frame!. Per 002 §frame-root and API §Registration :use-frame-provider-to-scope — to SCOPE an already-live frame, use rf/frame-provider {:frame …}; to CREATE-if-absent, pass frame-root {:id …} :received (the offending :frame), :where (the validating frame-root call site), :recovery (:use-frame-provider-to-scope), :reason
:rf.error/frame-root-reconfigured :error diagnostic A MOUNTED rf/frame-root's :id / opts changed after it committed (rf2-nyea0r amendment 4). A committed frame-root scopes exactly ONE frame for its lifetime; re-pointing it at a different frame id — or a different make-frame configuration — is a configuration error, not a reconfiguration the boundary supports (committed reconfiguration support is unearned machinery, and the pre-split silent useRef ignore hid a real mistake). Render-phase validation (the committed opts baseline is compared against the render's opts), thrown ex-info, not a trace; diagnostic-channel. Emitted by re-frame.views.frame-boundary/require-unchanged-root-opts!. Per 002 §frame-root and API §Registration :remount-with-a-new-key — to scope a DIFFERENT frame, give the frame-root a React key that changes so it remounts; to reconfigure the SAME frame (new :images / :initial-events), call rf/make-frame with the same :id directly :committed (the committed opts), :received (the changed opts), :where (the frame-root render site), :recovery (:remount-with-a-new-key), :reason
:rf.error/frame-provider-frame-absent :error diagnostic The SCOPE-only component rf/frame-provider (rf2-nyea0r split; {:frame existing-id} — provides an ALREADY-CREATED frame id through React context; creates / refreshes / destroys nothing) named a frame that is NOT live in the registry (never created, or destroyed). Scoping a subtree to an absent frame would silently establish a context every descendant subscribe / dispatch then mis-resolves, so it is a configuration error: the provider fails loud at mount rather than scoping descendants to a phantom frame. This is the guardrail Story + Xray rely on (mounting their tool/app frame's panel subtree against an existing frame). (Distinct from :rf.error/frame-root-missing-id, the ENSURE frame-root {:id …} missing-id boundary; from :rf.error/bad-frame-provider-arg, the non-keyword-:frame boundary; and from :rf.error/no-frame-context, the nil-:frame / no-scope boundary.) Mount-time validation, thrown ex-info, not a trace; diagnostic-channel. Emitted by re-frame.views.frame-boundary/require-live-frame-for-scope! (adapter substrates) and re-frame.ui.frames/require-scope-frame! (the compiled re-frame.ui substrate's frame-provider scope element, rf2-vxgfnd.9). Per 002 §frame-provider and API §Registration :ensure-or-create-the-frame — the provider fails fast; ensure the frame is created (e.g. rf/make-frame) before this provider mounts, or use the ENSURE component rf/frame-root {:id …} to create-if-absent :frame (the absent frame id), :where (the validating provider call site), :recovery (:ensure-or-create-the-frame), :reason
:rf.error/frame-provider-given-id :error diagnostic The SCOPE-only component rf/frame-provider (rf2-nyea0r split) was given an :id — the ENSURE key. frame-provider provides an ALREADY-CREATED frame through React context and creates NOTHING, so an :id prop is a did-you-mean configuration error naming frame-root. Covers the both-keys case ({:frame … :id …} on a provider is still an :id-on-a-provider error). Mount-time validation, thrown ex-info, not a trace; diagnostic-channel. Emitted by re-frame.views.frame-boundary/reject-frame-provider-id!. Per 002 §frame-provider and API §Registration :use-frame-root-to-ensure — to CREATE the frame if absent (and reuse it if present, without re-seeding), use rf/frame-root {:id …}; to scope an already-live frame, pass frame-provider {:frame …} :received (the offending :id), :where (the validating provider call site), :recovery (:use-frame-root-to-ensure), :reason
:rf.error/dispatch-sync-in-handler :error diagnostic dispatch-sync was called from inside an event handler's interceptor pipeline (use :fx [[:dispatch event]] instead — see 002 §dispatch-sync) :no-recovery — the call is rejected. Use :fx [[:dispatch event]] in the effect map :frame, :rf.event/v (the rejected inner event vector), :reason
:rf.error/effect-map-shape :error diagnostic A reg-event handler returned a malformed effect-map shape. Three cases: (a) bad top-level key — a key outside the closed set #{:db :rf.db/runtime :fx} (per MIGRATION §M-8 and Spec-Schemas §:rf/effect-map; :rf.db/runtime is the reserved framework-authority state effect, not a shape error); one trace per offending key, the key is dropped. (b) bad :fx value — a non-nil, non-sequential :fx value (e.g. {:fx :oops} or {:fx {…}}, the forgot-the-outer-vector typo); per Spec-Schemas §:rf/effect-map the :fx value must be a vector of [fx-id args] pairs. One trace with :offending-key :fx, the :fx slot is dropped. (c) bad :fx entry — an individual entry inside an otherwise-well-shaped :fx vector that is non-nil, non-empty, and NOT a [fx-id args] vector (e.g. {:fx [[:good a] :oops]}, the forgot-the-inner-vector typo); each entry must be a [fx-id args] tuple. One trace with :offending-key :fx, that entry is dropped while sibling entries still run. A nil / empty entry is the legal conditional-fx no-op and is NOT traced. In all cases legal closed-set keys (:db / :rf.db/runtime / :fx) still apply, so a malformed value never reaches the fx interpreter to throw a raw host exception after the :db commit :logged-and-skipped — the offending top-level key (case a), the malformed :fx value (case b), or the malformed :fx entry (case c) is dropped; the remaining legal closed-set keys (:db / :rf.db/runtime / :fx, and sibling :fx entries) still apply :failing-id (event-id), :rf.trace/event-id, :rf.event/v (vector), :offending-key (the bad key, or :fx for a bad :fx value/entry), :value (the offending value/entry), :reason
:rf.error/classification-effect-shape :error always-on A reg-event handler returned a malformed commit-plane data-classification effect. The four classification effects (:sensitive / :large / :clear-sensitive / :clear-large, per 015 §Data Classification and EP-0025) each take a vector of paths ([[path] …]) and are applied WITH the :db write at the commit point (a frame-state transform into the per-frame elision registry), NOT routed through do-fx. Two defect cases: (a) bad payload — the effect's value is not a vector (e.g. {:sensitive :not-a-vector}); (b) bad path entry — an entry is not a path vector, or carries a non-EDN-identity segment (the latter surfaced verbatim as :rf.error/bad-path from re-frame.path/normalize-concrete, the fail-closed :rf/path boundary). A malformed classification effect is fail-loud — it is checked at the router's FINAL-effects boundary (in re-frame.router/commit-and-flow!, immediately before the commit, so an :after-interceptor-injected payload is caught too). The check is a pure, non-throwing validator (re-frame.elision/classification-effect-defect, which returns the first defect map or nil); the router then emits the error in-band (NOT a throw — a throw here would escape the drain) via emit-classification-effect-shape! and aborts the event with NO :db partition commit and no classification install (no partial commit), mirroring the in-band :rf.error/legacy-runtime-root rejection at the same boundary. Fans through error-emit/emit-error-both!, riding the always-on error-emit axis (surface #4) alongside the DCE'd dev trace, so the corpus-wide listener observes the rejection in production under goog.DEBUG=false — the always-on record carries :offending-key as its lone discriminator, while the rejected :value and the interpolating :reason ride the dev trace only. Value-independent: the SHAPE of the declaration is validated, never the runtime value at the path :fix-effect — supply a vector of valid :rf/path vectors (e.g. {:sensitive [[:user :token]]}); the event aborts pre-commit until corrected :rf.trace/event-id (event-id), :rf.event/v (vector), :offending-key (the bad classification key), :value (the offending payload / path entry), :reason
:rf.error/effect-handler-bad-return :error diagnostic A reg-event handler returned a value that is neither a map nor nil (e.g. a vector, number, string, keyword — typically a typo or thinko). Without a map the runtime cannot extract :db / :fx and cannot guess the handler's intent, so the dispatch is treated as a no-op. nil remains the documented legal no-op and does not trigger this trace. Emitted by events.cljc's fx-handler->interceptor :no-recovery — the offending return is dropped; the dispatch is treated as a no-op :event-id (first of the event vector, when vector-shaped), :event (the event vector — the bare ERROR-tag spelling), :returned (the offending value), :returned-type (the runtime type), :reason
:rf.error/override-fallthrough :error always-on An override was specified but no matching id existed :replaced-with-default — use the registered fx as if no override existed :failing-id (the fx-id the override failed to redirect), :overrides-map, :looked-up-id, :frame, :reason
:rf.error/reserved-fx-override :error always-on A :fx-overrides entry (fn-value or keyword-redirect) overrode a non-overridable-source reserved fx-id — a state-installing lifecycle fx (:rf.machine/spawn, :rf.machine/destroy, :rf.fx/reg-flow, :rf.fx/clear-flow), the nav-token threader (:rf.route/with-nav-token), or the private :spawn-all join-completion transport (:rf.machine/join-dispatch). The first five may not be overridden because their bodies install/clear durable frame runtime-db state (or thread a correctness-critical nav-token) that later framework behaviour depends on far from the override site; :rf.machine/join-dispatch is protected on a distinct rationale — it writes no runtime state and threads no nav-token, but an override would capture or suppress the framework transport itself. The :reason payload is id-specific and states the rationale for the id actually rejected (per Conventions §Reserved fx-id override tiering and 002 §Reserved fx-ids are tiered against override). The OVERRIDABLE-tier reserved fxs (:dispatch, :dispatch-later, :rf.machine/dispatch-to-system, :rf.nav/*) do NOT trigger this — their overrides are honoured. This is the source policy only: it does NOT fire for a redirect whose target is one of these ids — a redirect naming :rf.machine/join-dispatch is refused with :rf.error/override-fallthrough, and a redirect naming :rf.machine/spawn / :rf.machine/destroy is permitted and runs the real handler (rf2-1w4af). Emitted at TWO sites discriminated by :where: :handle-one-fx (the dev per-call reject in re-frame.fx/handle-one-fx — emits once per offending fx as the walk reaches it) and :production-strip (the production prod-strip in re-frame.fx/strip-rejected-overrides, called by the router on the effective per-frame ⋈ per-call override map before the fx walk — emits once per stripped key up front). Production-reachable through the always-on error-emit listener (surface #4). The cascade-inherit set is also stripped of these ids so a per-call override never propagates into a [:dispatch …] child cascade :reserved-body-ran — the override is ignored; the real reserved/registered body runs (production prod-strip drops the key before the walk) :rf.fx/id (the rejected reserved fx-id), :failing-id (same id), :override (the offending override value), :where (:handle-one-fx / :production-strip), :frame, :reason
:rf.fx/handled :rf.fx diagnostic An fx was successfully dispatched (the runtime reached the fx and either ran the registered handler without exception or completed the reserved-fx-id action). Emitted by re-frame.fx/handle-one-fx on the success path so the :rf/epoch-record :effects projection captures one entry per dispatched fx (per Spec-Schemas §:rf/epoch-record). A keyword-redirected fx (an :fx-overrides id-redirect, per 002 §:fx-overrides) stamps the original fx-id as :rf.fx/from on this trace and on the sibling arg-bearing :rf.error/fx-handler-exception / :rf.fx/skipped-on-platform emits (:rf.fx/id carries the redirect target; the tag vocabulary :rf.fx/override-applied already uses), so the classification chokepoint composes both registrations' declarations (per 015 §Registration-owned transient classification) and tools see the redirect fact on the arg-bearing trace itself, not only on the separate :rf.fx/override-applied event n/a — success-path trace, not an error/warning :rf.fx/id, :rf.fx/args, :frame, :rf.fx/from (keyword-redirect only — the ORIGINAL fx-id)
:rf.fx/skipped-on-platform :warning diagnostic An fx was skipped because its :platforms excluded the active platform (per 011) :skipped — documented; not really an error :rf.fx/id, :rf.fx/args, :rf.fx/platform, :rf.fx/registered-platforms, :frame, :rf.fx/from (keyword-redirect only — the ORIGINAL fx-id; see the :rf.fx/handled row)
:rf.cofx/skipped-on-platform :warning diagnostic A declared ambient cofx's value-returning supplier was skipped because its :platforms excluded the active platform (per 011). Mirrors :rf.fx/skipped-on-platform; the supplier is NOT invoked and no value is delivered into :coeffects. Emitted from ambient-supplier execution in re-frame.cofx (the run-ambient-supplier step reached via a handler's :rf.cofx/requires declaration) after registry lookup succeeds but the platform predicate rejects :skipped — that coeffect's delivery is skipped; the event handler still runs :rf.cofx/id, :frame, :rf.cofx/platform, :rf.cofx/registered-platforms
:rf.ssr/hydration-mismatch :warning diagnostic Server-vs-client render divergence, across two tiers. Hiccup tier (ssr.cljc): the first client render diverges from the server-supplied render-tree (hash mismatch), OR the client-computed head model differs from the server-supplied head; the :failing-id discriminator routes those two (:rf/hydrate for the body, :rf.ssr/head-mismatch for the head), carrying :server-hash / :client-hash. Compiled tier (re-frame.ui, rf2-6z1i2): a compiled root has no hashable render-tree, so it verifies by React-native ADOPTION — a React-recoverable error in the hydration adoption window (before the root's phase flip) surfaces through the root's onRecoverableError, discriminated by :where re-frame.ui/hydrate-root and carrying :root-id / :error (NO hash). This surfaces only the divergences React itself recovers from (text-content and structural — missing / extra / wrong-type element), NOT exhaustive server-vs-client divergence: an attribute-only mismatch (a stale class / style / ARIA value) takes React's development-only warning path, is not guaranteed to be patched, and produces no trace on this tier (an intrinsic boundary of React-native adoption — the compiled tier carries no structural hash). Per 011 §Hydration-mismatch detection (the two-tier split and its attribute-only boundary) and 011 §Mismatch detection — head :warned-and-replaced — hiccup body: re-render client-side, server's HTML replaced; head: client renders its head, server's replaced; compiled: React's native adoption is literally warn-and-replace (it patches the divergent DOM) hiccup: :server-hash, :client-hash, :failing-id, :first-diff-path (body, optional — host-supplied through verify-hydration! and carried verbatim; the bundled runtime supplies none, so the key is simply ABSENT when no host diff ran, which never means "divergence at the root", per 011 §The first-diff-path tag), :head-id (head); compiled: :root-id, :error, :where
:rf.ssr/phase-flip :info diagnostic A hydrating root completed its client-only phase flip — the root advanced from :server to :client phase, so every client-only site in the root swapped its fallback for its client subtree in one root-scoped update. Emitted by BOTH view substrates on the same channel with the same tag: re-frame.ui.runtime for a compiled ui/client-only site, and re-frame.freehand.phase for an interpreted v/client-only one. Fires once per hydrating root, at the flip commit that follows the root's hydration commit. Because the flip is a POST-commit passive effect it runs strictly after React's hydration adoption of the :server-phase fallback tree — so the adoption-window mismatch check (above) always precedes the flip. Non-hydrating roots (mount / render! / ui.test/render / v/mount) are born :client and never flip; a root that failed to boot never flips (its :rf.error/root-boot-failed already fired). NOT an event and mints no epoch — render-layer root bookkeeping, not a dequeued event. Per 011 §Phase flip and 011 §The phase flip on the Freehand paved path n/a — informational lifecycle trace :root-id (the root that flipped)
:rf.ssr/version-mismatch :warning diagnostic The hydration payload's :rf/version differs from the runtime's. Emitted by the :rf.ssr/check-version fx dispatched from the reference :rf/hydrate handler. The handler still applies — degraded-but-running is the locked posture. Per 011 §The :rf/hydrate event and :warned-and-applied — the trace fires; hydration proceeds with the server-supplied app-db (the runtime does not abort hydration on version drift) :expected (server-supplied value), :actual (client-side runtime value)
:rf.ssr/schema-digest-mismatch :warning diagnostic The hydration payload's :rf/schema-digest differs from the digest of the client's currently-registered app-schema set. Emitted by the :rf.ssr/check-schema-digest fx dispatched from the reference :rf/hydrate handler. Useful for catching deploy drift where server and client bundles were built against different schema sets. Per 011 §The :rf/hydrate event and :warned-and-applied — the trace fires; hydration proceeds with the server-supplied app-db :expected (server-supplied digest), :actual (client-computed digest)
:rf.ssr/compatibility-check-skipped :warning diagnostic The :rf.ssr/check-schema-digest fx fired but its :schemas/app-schemas-digest actual-value hook is not registered (the schemas artefact is not on the classpath), so the comparison cannot be made. The :rf.ssr/check-version fx does NOT emit this — it resolves its client-side "actual" from the SSR artefact's compiled-in pattern-protocol constant, which always resolves (rf2-qfb1i). The fx never throws — degraded-but-running is the locked posture. Per 011 §The :rf/hydrate event and :skipped — the comparison is no-opped; hydration proceeds :check (the calling fx-id, :rf.ssr/check-schema-digest), :reason (why no actual value could be resolved)
:rf.ssr/invalid-version :warning diagnostic The host's explicit :version build opt was present but is NOT a coercible integer pattern-protocol version (a semver-style string "1.0.0", a float, a keyword). Per Spec-Schemas §:rf/hydration-payload :rf/version is canonically an :int (NOT a semver string); a whole-number string ("7") is tolerantly coerced, anything else is rejected so payload assembly does not ship a schema-violating value. Emitted by re-frame.ssr.payload-policy/resolve-version during payload assembly. Per 011 §The :rf/hydrate event :rejected-and-fell-back — the non-integer value is rejected; resolution falls through to the SSR artefact's compiled-in pattern-protocol constant (v1 = 1), so the payload always carries an integer :rf/version :value (the rejected source value), :reason
:rf.error/hydration-frame-id-mismatch :error always-on The hydration payload's :rf/frame-id (the frame the server stamped at render time) is present-and-different from the frame being hydrated into — the runtime will not silently install a server slice into a different frame than the one it was rendered for. TWO emit sites share this id: (1) the :rf/hydrate HANDLER guards the direct-dispatch-sync split path: a present-and-different :rf/frame-id against the dispatch target (:rf.frame/id) fails CLOSED — app-db AND runtime-db left unchanged, no compatibility-check fxs — emitting a dev trace AND an always-on error-emit record (off-box-shippable under goog.DEBUG=false, the :rf.error/malformed-hydration-payload sibling: corrupt/wrong-frame hydration INPUT is a fail-closed boundary event, not a dev teaching diagnostic). Emitted by re-frame.ssr.hydrate/hydrate-event-handler (ssr/hydrate.cljc). (2) the boot helper hydrate! validates the payload's :rf/frame-id against the explicit :frame target and THROWS pre-dispatch — surfaced as a dev trace (interop/debug-enabled?-gated) AND a thrown ex-info that aborts the boot. The boot THROW is the diagnostic-channel sibling of the handler's always-on record (a thrown abort cannot also ride the always-on listener); the row is graded always-on by its production-reachable handler path. Emitted by re-frame.ssr.boot/validate-payload-frame-id! (ssr/boot.cljc). Per 011 §The :rf/hydrate event and 011 §Client flow :no-recovery (handler: fails closed, frame-state unchanged) / :supply-matching-frame (boot: aborts) — pass the frame the server stamped, or correct the server's render frame :where (rf.ssr/hydrate or rf.ssr/hydrate!), :frame, :failing-id (:rf/hydrate), :target-frame, :payload-frame-id, :reason
:rf.error/suspense-boundary-duplicate-id :error diagnostic Two streaming-SSR suspense boundaries registered the same :id — a programmer error (the client never finds a matching resolved chunk for the shadowed boundary). Fail-soft: the LAST registration wins (the wire shape matches a second-registration-overwrites-first). Emitted by re-frame.ssr.streaming/dedupe-continuations (ssr/streaming.cljc). Per 011 §Boundary nesting and recursion :last-write-wins — the duplicate is dropped; the last registration for the id is kept and the stream proceeds :id, :count (registrations sharing the id), :recovery
:rf.ssr/suspense-boundary-failed :error diagnostic A streaming-SSR delta <script> body did not yield a usable delta-map — EITHER an unparseable-EDN reader exception OR a parseable-but-non-map value (a vector, number, string, …). Fail-CLOSED: the malformed delta is skipped rather than applied. Emitted by re-frame.ssr.streaming.client/malformed-delta! (ssr/streaming/client.cljs) + the server-side ssr/streaming.cljc boundary path. Per 011 §Streaming SSR :skipped-delta — the malformed delta <script> is skipped; the boundary's fallback placeholder remains and the stream continues :id, :frame, :where (rf.ssr/streaming-client), :reason, plus a branch-specific :exception (reader throw) or :malformed-value-type (non-map parse)
:rf.error/ssr-ring-on-error-failed :error diagnostic The caller-supplied (already-resolved) :on-error transport-failure handler ITSELF threw while building the error response; the Ring host adapter falls back to the locked default-on-error so a buggy :on-error cannot bypass the error boundary. The diagnostic-channel sibling of the always-on :rf.error/ssr-ring-error-view-failed (a thrown :on-error rides the dev trace; the off-box-shippable failure is the error-view variant). Emitted by re-frame.ssr.ring.lifecycle/guarded-on-error (ssr-ring/lifecycle.clj). Per 011 §The Ring host adapter :fell-back-to-default-on-error — the throwing :on-error is discarded; default-on-error builds the response :exception (the throwable's message), :ex-class, :recovery
:rf.ssr/destroy-frame-failed :warning diagnostic A best-effort SSR frame teardown (destroy-frame-quietly!) threw while tearing the request frame down; the throw is swallowed (it must not mask the real handler error, which has already been materialised) and surfaced on the trace bus rather than escalating to a user-visible 500. Emitted by re-frame.ssr.ring.lifecycle/destroy-frame-quietly! (ssr-ring/lifecycle.clj). Per 011 §The Ring host adapter :warned-and-skipped — teardown continues best-effort; the failed destroy's cleanup may be incomplete :frame, :reason, :ex-class
:rf.ssr/ssr-non-integer-status :warning diagnostic The Ring response materialiser saw a non-integer :status (almost certainly a caller bug). It will not guess a coercion ("404" is not assumed to mean 404), so it fails closed to a valid 500 Ring response and surfaces the defect rather than ship a malformed map. This is the DEV half (axis 2) of that one report: it keeps the offending VALUE, which the always-on twin :rf.error/ssr-ring-response-status-invalid deliberately does not carry, and both are emitted from the single site re-frame.ssr.ring.pipeline/report-non-integer-status! so the two axes cannot disagree. Reached from re-frame.ssr.ring.pipeline/fail-closed-status (ssr-ring/pipeline.clj), which must be called ONCE per materialisation — its defect arm has a side effect. Per 011 §HTTP response contract :failed-closed-to-500 — the response status is forced to 500 (a valid, fail-closed Ring response) :where (:ssr-ring/ssr-response->ring-response), :status, :status-type, :reason
:rf.ssr/ssr-non-string-header-value :warning diagnostic A Ring response header carried a non-string value (Ring header values must be strings or a vector of strings) — almost certainly a caller bug. The materialiser coerces it to its string form so the wire shape is valid, and surfaces the defect on the dev trace bus. Emitted by re-frame.ssr.ring.headers/merge-pair-into-header-map (ssr-ring/headers.clj). Per 011 §HTTP response contract :warned-and-coerced — the value is coerced to its string form; the response is well-formed regardless of the warning :where (:ssr-ring/merge-pair-into-header-map), :header, :value-type, :reason
:rf.ssr/ssr-redirect-no-target :warning diagnostic A :rf.server/redirect set :redirect with no :location — a 3xx with no Location header is a malformed wire response (the browser has nowhere to go). The runtime accepts a target-less redirect at the fx boundary (the location is caller-trusted and optional), so the adapter is the last line: it emits the status it has (no target to invent) and surfaces the defect on the trace bus. Emitted by re-frame.ssr.ring.pipeline/ssr-response->ring-response (ssr-ring/pipeline.clj). Per 011 §Redirect precedence :warned-and-emitted-statusonly — the 3xx status is emitted with no Location header; the trace is the signal :where (:ssr-ring/ssr-response->ring-response), :status, :reason
:rf.ssr.head/cleanup-failed :warning diagnostic The optional :ssr/head-on-frame-destroyed late-bind head-cleanup hook threw during SSR frame teardown; the throw is caught and surfaced on the trace bus rather than silently swallowed (the trace-on-catch symmetry shipped for destroy-frame-quietly!, audit CQ-2). Emitted by re-frame.ssr.request/clear-request-state! (ssr/request.cljc). Per 011 §SSR head management :warned-and-skipped — teardown continues best-effort; the head-cleanup may be incomplete :frame, :hook (:ssr/head-on-frame-destroyed), :reason, :ex-class
:rf.resource/hydrate-clock-skew :warning diagnostic A :rf/hydrate-installed resource entry's absolute :stale-at is ahead of the live client clock — server clock skew makes freshness ambiguous until the next live-owner ensure resolves it. One warning per skewed entry. Part of the resources SSR/restore-reconcile trace family (also listed in §Where trace emission lives prose). Emitted by re-frame.resources.ssr (resources/ssr.cljc, hydrate path). Per 016 §SSR and hydration :no-recovery — the entry is installed as-is; the next live-owner ensure resolves the freshness ambiguity (a refetch when stale) :rf.frame/id, :resource/key, :skew-ms, :reason
:rf.resource/restore-clock-skew :warning diagnostic A restore-epoch!-installed resource entry's absolute :stale-at is ahead of the live clock — clock skew makes freshness ambiguous until the next live-owner ensure resolves it. The restore-path twin of :rf.resource/hydrate-clock-skew; emitted as a :level :warning deferred-trace record (also listed in §Where trace emission lives prose). Emitted by re-frame.resources.ssr (resources/ssr.cljc, restore-reconcile path). Per 016 §Restore and replay :no-recovery — the entry is installed as-is; the next live-owner ensure resolves the freshness ambiguity :rf.frame/id, :resource/key, :skew-ms, :reason
:rf.error/malformed-hydration-payload :error always-on The deserialised :rf/hydrate payload (an UNTRUSTED transport input — the server's pr-str'd EDN round-tripped through cljs.reader/read-string) is structurally malformed: the payload is not a map, EITHER its app-db OR its runtime-db partition slice is present-but-not-a-map, OR the __rf_payload script did not parse as EDN. Because :replace-frame-state is the locked merge policy (Spec 011 §The :rf/hydrate event), installing a non-map slice would silently coerce corrupt/hostile input into a partition (the ENTIRE client app-db, or the runtime-db partition) — the fail-OPEN class the schemas / routing boundary sweeps closed. Both partitions validate fail-CLOSED before installation: the malformed payload is REJECTED and the existing client frame-state is left unchanged. Always-on: corrupt hydration INPUT is a fail-closed boundary event (not a dev teaching diagnostic), so it rides the always-on error-emit axis (surface #4) ALONGSIDE the dev trace — an off-box shipper on a goog.DEBUG=false client build must still see the rejected payload. Two emit sites: the hydrate-event-handler shape-guard fires with the resolved :frame; the read-server-payload PRE-FRAME parse guard fires a FRAMELESS always-on record (:frame nil) — read-server-payload runs before hydrate! resolves the target frame, so there is no frame to carry, exactly the :rf.error/no-frame-context frameless always-on precedent. Emitted by re-frame.ssr.hydrate/hydrate-event-handler (shape guard) and re-frame.ssr.boot/read-server-payload (frameless parse guard). Per 011 §The :rf/hydrate event :no-recovery — the malformed payload is rejected; the client frame-state is left unchanged and the host renders client-only (degraded-but-running). No compatibility-check fxs fire (no trustworthy server slice to compare against) :where (rf.ssr/hydrate or rf.ssr/read-server-payload), :frame (the resolved frame, or absent/nil on the frameless parse path), :failing-id (:rf/hydrate), :reason
~~:rf.warning/plain-fn-under-non-default-frame-once~~ n/a (retired) RETIRED. A plain (non-reg-view) Reagent fn cannot read the surrounding frame-provider's frame; under the carried-frame invariant it does not fall through to :rf/default (there is none) — its ambient subscribe/dispatch raise the structured :rf.error/no-frame-context error instead (per 004D §Plain Reagent fns). The warn-once vocabulary is superseded by that loud error.
~~:rf.warning/dispatch-from-async-callback-fell-through-to-default~~ n/a (retired) RETIRED. There is no fall-through-to-:rf/default to warn about: an async-callback dispatch with no carried frame stamp (scope unwound) raises the always-on, structured :rf.error/no-frame-context error, which carries capture-site ancestry through the :rf.trace/dispatch-id / :rf.trace/parent-dispatch-id correlation graph (per 002 §Frame target resolution). The loud error replaces the warning.
:rf.warning/cross-frame-dispatch-sync-during-drain :warning diagnostic A dispatch-sync! was issued against a target frame while a different frame is mid-drain (same-frame reentry is already rejected as :rf.error/dispatch-sync-in-handler). The cross-frame case is not rejected — frames are independent state machines per 002 §Run-to-completion §Rules rule 1 — but the drains interleave (the target frame drains to settled while the caller's frame is still in flight, then the caller continues), which is rarely the caller's intent. Surfaced for observability tools; the dispatch proceeds. Per 002 §Cross-frame dispatch-sync and :no-recovery — the dispatch proceeds; the warning is purely diagnostic. Frames are independent state machines so the cross-frame drain is not a contract violation, but the interleaved ordering is rarely intentional :caller-frame (the frame read from *current-frame*, or :rf/none when unbound), :target-frame (the dispatch-sync!'s target), :other-frame (an arbitrary mid-drain sibling — typically the caller's frame), :event (the dispatched event vector), :reason
:rf.warning/no-clock-configured :warning diagnostic A timing-sensitive substrate feature (e.g. state-machine :after per 005 §Delayed :after transitions) was exercised on a host whose re-frame.interop clock primitives (now-ms / schedule-after! / cancel-scheduled!) weren't wired up. The runtime falls back to the host-native clock if available; this advisory surfaces so tests / agents can spot the missing wiring :warned-and-replaced — fall back to the host-native clock :feature (e.g. :rf.machine/after), :fallback (the host-native clock used)
:rf.warning/unknown-dispatch-opt :warning diagnostic A dispatch / dispatch-sync opts map carried one or more keys outside the recognised known-dispatch-opts set — the runtime reads only the known keys in build-envelope, so an unrecognised key (almost always a typo — :fram for :frame) is silently swallowed and changes nothing. Surfaced loudly per the no-silent-swallow posture; one warning per offending dispatch call. Dev-only (interop/debug-enabled?-gated, DCE'd in production). Emitted by re-frame.router.diagnostics/emit-unknown-dispatch-opts-warning! (core/router/diagnostics.cljc). Per 002 §dispatch :no-recovery — the dispatch proceeds unchanged (observational, never refusal); fix the misspelt opt or move a custom payload into the event vector :event, :event-id, :unknown-keys, :known-keys, :detected-at, :reason
:rf.warning/non-serialisable-event-payload :warning diagnostic A dispatched event's payload carries a host handle — a fn, Promise, AbortController, DOM node, Date, or RegExp (the SAME closed set re-frame.reply/host-handle? polices for the reply-map / reply-target data-only invariant, per Managed-Effects §The reply map) — walked via a budget-bounded traversal (re-frame.reply/walk-find-host-handle-bounded; a budget-exhausted walk is a false negative, not a false positive). Per Conventions §Event payloads SHOULD be serialisable data: a SHOULD, not the :rf.cofx structural-EDN MUST — permissive :any payloads and ad-hoc test payloads still rely on carrying a live value occasionally, so this warns rather than rejects. Dev-only (interop/debug-enabled?-gated wholesale, DCE'd in production). Emitted by re-frame.router.diagnostics/emit-non-serialisable-event-payload-warning! (core/router/diagnostics.cljc), called from re-frame.router/build-envelope. Per 002 §The :rf.cofx envelope field :no-recovery — the dispatch proceeds unchanged (observational, never refusal); replace the handle with its data projection (an id, a snapshot, a plain value) :event, :event-id, :path (the offending value's path inside the event vector), :reason
:rf.error/legacy-runtime-root :error always-on A stray legacy :rf/runtime root was found at the top of app-db — written by user code. Under the two-partition contract, framework durable state lives in the runtime-db partition (:rf.db/runtime, children :rf.runtime/*), NOT in an app-db root; an :rf/runtime root in app-db is illegal. Hard error — per Conventions §The legacy :rf/runtime root. There is no :rf.warning/runtime-state-dropped containment diagnostic: the partition makes a {:db fresh-map}-return clobber structurally impossible (an ordinary :db effect replaces only app-db; runtime-db is a separate partition the handler never holds — per Conventions and 002 §An ordinary :db return replaces only app-db). Detected at the router's FINAL-effects boundary and emitted in-band by re-frame.router/emit-legacy-runtime-root! (a throw there would escape the drain), aborting the event with no commit — the :rf.error/classification-effect-shape sibling rejection at the same boundary. Fans through error-emit/emit-error-both!, riding the always-on error-emit axis (surface #4) alongside the DCE'd dev trace, so the corpus-wide listener observes the rejection in production under goog.DEBUG=false :no-recovery — hard error; the fix is to migrate the :rf/runtime write to the runtime-db partition (framework code) or to remove it (user code) :frame, :rf.event/v (the offending event vector when applicable), :reason (names the migration target — runtime-db :rf.runtime/*)
~~:rf.warning/legacy-runtime-root~~ n/a (retired) RETIRED. There is no migration-warning counterpart to :rf.error/legacy-runtime-root. A stray :rf/runtime root is always the hard error; per Conventions §The legacy :rf/runtime root.
:rf.warning/app-handler-runtime-effect :warning diagnostic An ordinary (non-framework-authority) app handler returned a reserved :rf.db/runtime effect. A handler has framework-write authority when its registration meta carries :rf/framework-authority? true (or implies it via :rf/machine?) — the general minting mechanism per Conventions §Reserved registration metadata and 002 §Minting framework-write authority; framework subsystem handlers (routing, SSR, machines) carry it and so do NOT fire this diagnostic. :rf.db/runtime is reserved by convention for framework / runtime-extension code, NOT a security boundary (Mike ruling #4 — per 002 §Write authority is by convention): the runtime surfaces the misuse through this dev diagnostic rather than enforcing a capability or silently dropping the effect. Dev-only; interop/debug-enabled?-gated :warned — the effect is still applied (convention, not enforcement); the diagnostic names the runtime-db ownership rule and points at the public subscription/effect surfaces :frame, :rf.event/v, :reason
:rf.warning/db-nil-coerced :warning diagnostic An event handler returned {:db nil}. app-db is always a map, never nil — the nil is coerced to {} at the :db effect → :rf.db/app partition mapping (before commit-frame-transition!), so the partition layer never sees a nil app-db. This removes the v1 nil-footgun (a db handler returning nil silently wiping app-db to nil) structurally, at the commit boundary. A {:db nil} return is more often a bug (a handler accidentally computed nil) than a deliberate clear, and a silent coercion would quietly wipe app-db — so the coercion fires this diagnostic for accidental-wipe visibility. A deliberate clear writes {:db {}} directly (a distinct, non-nil empty map) and fires no diagnostic; only the literal {:db nil} form is flagged. Dev-only; interop/debug-enabled?-gated :warned — the nil is coerced to {} and committed; the diagnostic names the bug-vs-deliberate-clear distinction :frame, :rf.event/v, :reason
:rf.error/duplicate-url-binding :error diagnostic A second frame attempted :url-bound? true while another already owns the URL. Per 012 §Multi-frame routing :no-recovery — both bindings are stored (the diagnostic fires from a registrar registration-hook that runs after the slot is written, so the offending frame's :url-bound? true metadata is visible in the registry), but only the single deterministic owner resolved by url-owner-frame-id drives navigation: the existing URL owner is unchanged and the losing binding's :rf.nav/push-url / :rf.nav/replace-url fxs no-op. No frame metadata is mutated by the error. Per 012 §Multi-frame routing :existing-frame, :offending-frame
:rf.error/system-id-collision :error diagnostic A spawn whose :system-id was already bound in the per-frame [:rf.runtime/machines :system-ids] reverse index displaced the previous binding. Last-write-wins, matching reg-event re-registration semantics. Per 005 §Named addressing via :system-id and :warned-and-replaced — the previous binding is displaced; the new gensym wins :frame, :system-id, :existing-machine (the displaced gensym'd id), :rebound-to (the new gensym'd id), :reason
:rf.error/resource-missing-scope-policy :error diagnostic A reg-resource registration declared no valid :scope policy. :scope is REQUIRED and fail-closed: one of :rf.scope/global (an explicit, auditable global claim), a resolver, or :rf.scope/from-caller. There is no implicit [:rf.scope/global] default — "I forgot this read is user-scoped" is unrepresentable at registration rather than an Xray heuristic. Registration-time / dev+prod (a caller bug); surfaced as a thrown ex-info, not a trace. Per 016 §Scope resolution :fix-registration — the call throws; the offending resource is NOT registered. The fix: declare an explicit :scope policy :resource-id, :scope (the rejected value), :reason
:rf.error/resource-bad-spec :error diagnostic A reg-resource registration omitted a REQUIRED key — :params-schema (validates + canonicalizes the resource's params, its identity) or :request (the Spec 014 managed-HTTP args map for :transport :rf.http/managed, the only initial-scope transport) — or the spec was not a map. (The REQUIRED, fail-closed :scope policy is validated FIRST and separately, raising the dedicated :rf.error/resource-missing-scope-policy.) Registration-time / dev+prod (a caller bug); surfaced as a thrown ex-info, not a trace. Per 016 §Resource registration spec :fix-registration — the call throws; the offending resource is NOT registered. The fix: declare :params-schema + :request :resource-id, :value (when the spec was not a map), :reason
:rf.error/resource-scope-required-from-caller :error diagnostic A :rf.scope/from-caller resource event (:rf.resource/ensure / :rf.resource/refetch / :rf.resource/remove) was reached with no payload :scope and no route-resource resolver to supply it. Enforcement lands where the scope is actually known (the use site). There is NO silent global read — fail-closed per 016 §Scope resolution. Thrown ex-info at the use site :fix-registration — the call throws; supply :scope on the event payload (or declare a route-resource :scope resolver) :resource-id, :reason
:rf.error/resource-sub-unresolved-scope :error diagnostic A passive resource subscription ([:rf/resource …] and siblings) could not resolve a cache scope: no :scope on the subscription payload AND the resource's spec policy is not sub-resolvable (a (route, ctx) resolver or :rf.scope/from-caller that a pure sub cannot evaluate). The read-side counterpart of the write-side fail-closed gate — NEVER a silent [:rf.scope/global] read and NEVER a silent :idle (the permanent-skeleton bug family). Thrown ex-info from the sub. Per 016 §Subscription-side scope resolution :fix-registration — the subscription throws; pass :scope on the payload (the same scope the owning route/event ensured under), or re-declare the resource with a sub-resolvable scope policy :resource-id, :policy, :reason
:rf.error/resource-non-edn-params :error diagnostic A resource params (or scope) map carried a host / opaque value — a function, promise, date, DOM node, AbortController, or raw JS object — at the cache-key boundary. The scoped resource key MUST be serializable EDN (key-order-independent, recursively EDN), so host values are rejected loudly: put every value that affects remote identity in params as plain EDN. Thrown ex-info from the canonicalization boundary (ensure / refetch / remove / subscribe). Per 016 §Resource identity / §Canonicalization rule :fix-params — the call throws; the params / scope are not cacheable until the host value is removed (represent it as plain EDN) :resource-id, :kind (:params / :scope), :value, :reason
:rf.error/resource-invalid-params :error diagnostic A resource's params failed conformance against its REQUIRED :params-schema (the pluggable, late-bound Malli validator — no-op when no validator is registered, exactly as routing validates route params). nil vs missing is schema-defined, not accidental. Thrown ex-info from the canonicalization boundary; dev-tier when the schema validator is present. The :params + :error slots are projected against the resource's OWN :params-schema per-slot classification (the SAME co-equal owner surface SSR key egress uses, per 015 §Resource and mutation durable classification): a {:sensitive? true} params slot egresses as :rf/redacted, a {:large? true} slot as :rf.size/large-elided, and the Malli explainer :error (which carries the failing params verbatim) scrubs to :rf/redacted whole-payload when the schema declares any sensitive slot — so a conforming sensitive sibling never leaks through the thrown error data when validation fails on a different field. Per 016 §Resource identity :fix-registration — the call throws; the params are corrected to conform to :params-schema :resource-id, :params, :error (the Malli explainer payload), :reason
:rf.error/resource-not-registered :error diagnostic A resource operation (:rf.resource/ensure / :rf.resource/refetch / :rf.resource/remove, or a [:rf.resource/*] subscription / resource-state introspection) referenced a resource-id with no registered :resource-kind entry. Call rf/reg-resource before ensuring / subscribing. Thrown ex-info :fix-registration — the call throws; register the resource first :resource-id, :reason
:rf.error/resource-unknown-transport :error diagnostic A resource declared a :transport other than the only initial-scope built-in (:rf.http/managed, Spec 014). The resource lifecycle is transport-neutral so a later transport (the deferred GraphQL phase) can plug in, but the read transport must be one the artefact ships. Thrown ex-info from the transport lower seam. Per 016 §Transport :fix-registration — the call throws; use :transport :rf.http/managed (the only initial-scope transport) :transport, :reason
:rf.error/resource-reserved-request-key :error diagnostic A resource's :request (the Spec 014 managed-HTTP args map it returns for :transport :rf.http/managed) supplied one of the runtime-owned reply-addressing / request-correlation keys — :request-id, :on-success, or :on-failure. The resource runtime OWNS reply addressing: it supplies those from the scoped resource key and current generation so the internal reply verifies frame + work-id + generation before writing. An app-supplied reply target would bypass stale suppression (the correctness boundary) — a stale reply could overwrite newer data. Thrown ex-info from the managed-HTTP lower seam at dispatch (ensure / refetch). Per 016 §Transport :fix-registration — the call throws; the request is NOT lowered (no managed-HTTP request reaches the wire). The fix: remove :request-id / :on-success / :on-failure from the :request return — the runtime addresses the reply :keys (the rejected reserved key vector), :resource/key, :reason
:rf.error/resource-ssr-blocking-timeout :error diagnostic One or more BLOCKING SSR resources for the current nav-token did not settle (:loaded / :error) within the server-render deadline (Spec 016 §SSR and hydration — blocking timeout policy). Rather than hang the request indefinitely, the timeout settles each unsettled blocking entry as a structured first-load failure ({:status :error :error {:kind :rf.http/timeout :reason :ssr-blocking-timeout …}}) so the renderer sees a structured :error (never a hung :loading) and records the route blocking failure so it can choose error markup / a skeleton / an application fallback. Emitted by re-frame.resources.ssr/settle-blocking-timeout on the SSR frame; a server-side (JVM) trace, not a thrown ex-info — the request continues to render. Per 016 §SSR and hydration. Classified DIAGNOSTIC (not always-on): the failure IS recorded in observable state (the resource entry settles to a structured first-load failure, hydrates to the client, is subscribable and Xray-visible); an operational resource timeout is data-plane telemetry, not a framework-contract breach. Named home: the resources trace family + the observability-sink routing — its operator-observability route is the data-plane sink, not the always-on framework-error axis :settled-as-first-load-failure — each timed-out blocking entry settles to a first-load :error; the render proceeds against the settled state and the host hands the route-blocking-failure record to the renderer :where (re-frame.resources.ssr/settle-blocking-timeout), :frame, :timed-out (vector of the scoped resource keys that timed out), :limit-ms (the render deadline), :reason
:rf.error/resource-route-plan :error diagnostic The effective resource plan could not be FORMED. The failure set is the whole planning boundary, not just params/scope: a fail-closed :params / :scope / :when throw on a contributing declaration, a missing or cyclic :after edge, an unresolved or cyclic :parent (branch resolution is fail-loud, never silently truncated), or a cycle created by identity collapse — all enumerated at 016 §Effective parent-chain resource plans. Emitted as an error trace and, on the activation path, also recorded on the route slice's :error (visible to the :rf/route sub + Xray); NEVER swallowed as a silent cache miss. The plan fails WHOLLY — :resources planning is atomic at this boundary. A failed plan contributes an empty next-ownership set: no partially-planned owner is attached and no partial ensure is dispatched, while every owner held only by the previous plan is still released (012 §Failed activation, 016 §Plan diff and owner handoff). FIRST-error-wins names WHICH failure is reported when several declarations are unplannable — it does not mean surviving siblings ensure anyway; they do not. The route still commits its target and URL (a committed failed activation, addressable and error-renderable) and readiness projects :error, but nothing from the invalid plan runs and the target's :on-match events do not fire. On the warm-mode prefetch path (:plan-cause :prefetch) the same diagnostic fires with no :nav-token, touches no route state, and alters no readiness — a preload owns none. Per 016 §Route integration :fix-params for a params / scope failure (make the resolver produce serializable EDN conforming to :params-schema, or gate the declaration with :when); :fix-when for a throwing :when, :fix-scope for an unresolvable :scope, :fix-after for a missing / cyclic :after or a collapse-created cycle, and :fix-parent for an unresolved / cyclic :parent :route-id (the LEAF activation target), :resource-id (only when one declaration owns the failure), :contributor ({:route-id :local-id} — the CONTRIBUTING route + local declaration, so an ancestor's failure is not reported as the leaf's; absent for a failure belonging to no single declaration), :nav-token (activation attempts only), :plan-cause (:prefetch on a warm-mode preload failure; absent on an activation failure — the door cause is already on the preceding :rf.route/planned), :cause (the underlying canonicalization / validation ex-data), :reason
:rf.error/resource-route-blocking :error diagnostic A BLOCKING route resource FAILED its first load. A blocking route resource keeps the route transition :loading (it is the route's SSR wait point); when one settles as a first-load failure the runtime flips the route transition to :error and populates [:rf.runtime/routing :current :error] with this structured error, so a failed required server-state read is observable in route state rather than a permanent skeleton. The error envelope carries the resource's own :error first-load failure. Per 016 §Route integration. Classified DIAGNOSTIC (not always-on): like :rf.error/resource-ssr-blocking-timeout, the failure is recorded in observable route state ([:rf.runtime/routing :current :error], the :rf/route sub + Xray); it is data-plane telemetry, not a framework-contract breach. Named home: the resources trace family + the observability-sink routing :no-recovery — the route is in :error; the app reads :rf.route/error and renders an error view, retries via :rf.resource/refetch, or re-navigates :resource-id, :nav-token, :error (the resource's first-load failure envelope), :reason
:rf.error/infinite-missing-page-accessor :error diagnostic An :infinite resource accumulated a non-vector / enveloped page (e.g. {:items […] :page-info …}) but declares no :page->items accessor — so the merged :rf.resource/items headline read cannot flatten it. The flatten rule is loud, not magic (R3): an already-vector page flattens by identity; a non-vector page REQUIRES a :page->items (a keyword key or a (fn [page] → items)); the framework NEVER guesses :items / :data. Runtime-detected at the merge site — the wave-2 registry validation cannot inspect a concrete page shape at registration time, so the check lands where the merge first sees a non-vector page (re-frame.resources.state/merge-pages->items, read from re-frame.resources.subs's :rf.resource/items / :rf.resource/feed projection). Thrown ex-info from the merge site. Per 016 §Subscription contract :fix-registration — the merge throws; declare :page->items on the reg-resource (a keyword key or a (fn [page] → items)) :resource-id, :page-shape (:map / :seq / the concrete type), :reason
:rf.warning/resource-clear-scope-unresolved :warning diagnostic A :rf.resource/clear-scope referenced a named scope resolver via {:from-db …}, but it resolved NIL against the current db — FAIL-CLOSED. The resolver's declared :inputs are not present (e.g. no logged-in user); a derived scope that cannot resolve is the unresolved condition, never permission to clear global or a silent no-op. Clears NOTHING and emits the loud dev diagnostic. Emitted by re-frame.resources.events (resources/events.cljc). Per 016 §clear-scope is causal :fix-scope — nothing is cleared; supply a resolvable scope (the :hint names the fix) :rf.frame/id, :scope, :from-db, :cause, :hint
:rf.warning/resource-load-more-owner-ignored :warning diagnostic A :rf.resource/load-more was given a non-nil :owner. A load-more is OWNERLESS by contract: the feed's liveness is the ROUTE owner's (the route that ensured page 0), and a load-more is a user-caused page extension during that route's lifetime, NOT a new owner. A supplied :owner is a recognised-but-unhonourable input (typically a consumer copying the ensure / refetch payload shape) that, attached, would add a SECOND durable owner to the feed (:active-owners + :owner-index) and silently extend its liveness / GC lifetime until an explicit :rf.resource/release-owner — the owner LEAK. Continuation is safe (the page-append path is proven correct), so this is a WARNING, not an error: the owner is NORMALIZED to nil (it reaches NEITHER :active-owners, the :owner-index, NOR the work record; :cause is untouched, attribution preserved) and the page still fetches + appends. Emitted ONCE per load-more, on EVERY branch (issue / skip / dedupe / no-feed), by re-frame.resources.events (resources/events.cljc). Per 016 §Causal event — load-more and Conventions §No silent swallow :remove-owner — the owner is dropped; the page still appends. The :hint names the fix (remove :owner from the load-more payload) :rf.frame/id, :resource, :resource/key, :owner (the ignored owner), :cause, :hint
:rf.error/mutation-bad-spec :error diagnostic A reg-mutation registration omitted a REQUIRED key — :request (the Spec 014 managed-HTTP args map the write lowers into) or :params-schema (validates + canonicalizes the write's params) — or the spec was not a map. Registration-time / dev+prod (a caller bug); surfaced as a thrown ex-info, not a trace. Per 016 §Deferred slices / EP-0003 §Mutations :fix-registration — the call throws; the offending mutation is NOT registered. The fix: declare :request + :params-schema :mutation-id, :value (when the spec was not a map), :reason
:rf.error/mutation-optimistic-before-request :error diagnostic A reg-mutation registration declared an optimistic plan (:optimistic / :optimistic-tags) together with :invalidate-timing :before-request — these are INCOMPATIBLE (EP-0019 Rider 3). A :before-request invalidation STALES the touched entries before the request, and an optimistic apply immediately RE-POPULATES the same entries (stale-then-optimistic-fresh) — contradictory. Rejected LOUDLY at the authoring boundary (a registration error, not a silent precedence rule); optimistic writes use the default :after-success timing. Registration-time / dev+prod (a caller bug); surfaced as a thrown ex-info, not a trace. Per 016 §Optimistic mutations :fix-registration — the call throws; the offending mutation is NOT registered. The fix: drop :invalidate-timing :before-request (or drop the optimistic plan) :mutation-id, :invalidate-timing, :has-optimistic?, :has-optimistic-tags?, :reason
:rf.error/mutation-not-registered :error diagnostic A mutation operation (:rf.mutation/execute) referenced a mutation-id with no registered :mutation-kind entry. Call rf/reg-mutation before :rf.mutation/execute. Thrown ex-info from the execute handler (caught by the cascade's interceptor-error trap — no managed-HTTP write is lowered). Per EP-0003 §Mutations :fix-registration — register the mutation first :mutation-id, :reason
:rf.error/mutation-invalid-params :error diagnostic A mutation's params failed conformance against its REQUIRED :params-schema (the same pluggable, late-bound Malli validator resources use — no-op when no validator is registered), OR carried a host / opaque value at the cache-key boundary (the params are stored on the durable instance row + closed over by :invalidates / :patches, so the same serializable-EDN discipline as resource params applies — a non-EDN value raises :rf.error/resource-non-edn-params from the shared canonicalizer). Thrown ex-info from the execute handler's canonicalization boundary. The :params + :error slots are projected against the mutation's :params-schema per-slot classification identically to :rf.error/resource-invalid-params (sensitive slots → :rf/redacted, large slots → :rf.size/large-elided, the explainer :error scrubs whole-payload when any slot is sensitive — the shared resources-family classification seam, per 015 §Resource and mutation durable classification). Per EP-0003 §Mutations :fix-registration — the call throws; correct the params to conform to :params-schema (and represent host values as plain EDN) :mutation-id, :params, :error (the Malli explainer payload), :reason
:rf.error/reply-invalid-target :error diagnostic A reply target handed to re-frame.reply/normalize-target (the shared uniform-reply-envelope substrate every managed async family — HTTP, resources, machines, route loaders — lowers its completion onto) is malformed: not an event-vector prefix [:event-id arg ...] (a bare keyword, an empty vector, or a non-keyword-headed vector), a descriptor whose :event is not an event-vector prefix, or a value that is neither a vector nor a map. The substrate FAILS CLOSED rather than let a bogus {} / {:event nil} / {:event :x} travel on to complete and become a garbage dispatch shape. Thrown ex-info from re-frame.reply/normalize-target (inherited by every downstream consumer — complete, map-completed-event, durable-target, target->short-form). The reply-specific category rides :rf.error/kind; the canonical :rf.error/id is the closed-table projection. Per Managed-Effects §The reply target :no-recovery — the call throws; supply an event-vector prefix [:event-id arg ...] or a well-formed descriptor map :rf.error/kind (:rf.reply/invalid-target), :where (:rf/reply-to), :target (the malformed target), :event (the offending :event on the descriptor path), :reason
:rf.error/reply-non-map-reply :error diagnostic A reply handed to re-frame.reply/validate-reply is NOT a map — the uniform reply envelope MUST be a map carrying :status (the closed taxonomy) plus :value / :error slots (Managed-Effects §The reply map), so a scalar reply is rejected rather than validated field-by-field against a non-map. Thrown ex-info from re-frame.reply/validate-reply (the pure validator throws ONLY on a non-map argument; a merely-malformed map is returned as data problems, not thrown). Per Managed-Effects §The reply map :no-recovery — the call throws; build the reply as a map ({:status … :value/:error …}), not a scalar :rf.error/kind (:rf.reply/non-map-reply), :where (:rf/reply-to), :reply (the non-map value), :reason
:rf.error/reply-unknown-delivery :error diagnostic re-frame.reply/complete saw a reply target whose :delivery is neither :append (the only public reply mode — the reply map appended as the final argument of the target event) nor a known internal compatibility-adapter mode. An unknown delivery mode is a contract error, not a silent fall-through to a no-op completion. Thrown ex-info from re-frame.reply/complete. Per Managed-Effects §The reply target :no-recovery — the call throws; set the descriptor's :delivery to :append :rf.error/kind (:rf.reply/unknown-delivery), :where (:rf/reply-to), :delivery (the unknown mode), :target (the normalized descriptor), :reason
:rf.error/reply-non-data-target :error diagnostic re-frame.reply/durable-target — invoked before a reply target could become DURABLE (a stored continuation, a ledger row, a replay log) — found a host handle (a fn / Promise / AbortController / DOM node / Date / …, the closed set host-handle? polices) in a PUBLIC field (:event / :suppress) AFTER stripping the framework-private ephemeral slot (::post). A durable reply target MUST be data-only; a smuggled non-serializable handle is an app/family bug. Thrown ex-info from re-frame.reply/durable-target. Per Managed-Effects §The reply target :no-recovery — the call throws; replace the host handle at :path with its data projection (an id, a snapshot, a plain value) :rf.error/kind (:rf.reply/non-data-target), :where (:rf/reply-to), :path (the path to the offending slot), :target (the stripped descriptor), :reason
:rf.warning/multiple-status-set :warning diagnostic Two or more :rf.server/set-status calls in the same request drain. Last-write-wins; advisory for finding the conflicting handlers. Per 011 §Multiple-status policy :warned-and-replaced — last-write wins; advisory only :writes (vector of {:status :handler-id :event} per write), :final-status, :frame
:rf.warning/multiple-redirects :warning diagnostic Two or more :rf.server/redirect calls in the same request drain. Last-write-wins. Per 011 §Redirect precedence :warned-and-replaced — last-write wins; advisory only :writes (vector), :final-redirect, :frame
:rf.error/on-destroy-handler-exception :error always-on The user-supplied :on-destroy event handler (or any handler in its dispatch drain) threw while destroy-frame! was firing it. Teardown does NOT abort — the throw is caught and every downstream step (machine cascade, sub-cache disposal, cleanup hooks, :rf.frame/destroyed, registry dissoc) still runs (per 002 §:on-destroy handler throw semantics, decision b). This is the discriminable teardown signal: the router ALSO surfaces the same throw as a generic :rf.error/handler-exception (the production source of record for the handler throw itself), but the discriminator — it happened during destroy — rides the always-on axis so an operator on a goog.DEBUG=false host can tell an :on-destroy failure (a teardown / resource-leakage class — a throwing :on-destroy can leak resources mid-teardown) from a generic handler throw. It is ALSO the ONLY always-on coverage for the defence-in-depth re-throw branch (a fault inside dispatch-sync! itself, which never produced a router :rf.error/handler-exception). frame.cljc cannot static-require re-frame.error-emit (load order), so the emission rides the :error-emit/dispatch-on-error late-bind hook (the same hook the no-frame-context emit uses). Per 002 §Frame lifecycle and EP-0008 :ignored — teardown continues best-effort (the discriminable signal is the production-survivable breadcrumb; recovery is not what promotion changes — the channel is) :frame (the frame being destroyed), :event (the elided :on-destroy event vector), :event-id (the event-vector head), :exception, :exception-message (when the throw came through the router-converted trace), :where (:fire-on-destroy-event!)
:rf.error/frame-teardown-failed :error always-on One bounded always-on report per frame destroy that had at least one failed teardown step — a late-bound cleanup hook, or a guarded direct step (per §Observability channels and the promotion criterion). destroy-frame! runs a best-effort recipe of optional late-bound cleanup hooks plus a few guarded direct teardown steps (notably the :frame/notify-machine-destruction! machine cascade); rather than fan out one always-on emission per failed step (an SSR per-request-destroy × M req/s flood of the production error shipper), the runtime accumulates the per-step failures through the shared record-teardown-failure! emit boundary and emits one :rf.error/frame-teardown-failed record carrying a :hook-failures vector. This is the production-survivable fact: skipped teardown is a resource-leakage class (stale schemas, flow rows, orphaned timers, cross-request contamination the next operation cannot see locally) that compounds with process lifetime — all three legs of the promotion criterion hold. The destroy IS the fact; the failed steps are detail rows, and one record preserves the which-steps-failed-together correlation external shippers will not reliably re-group. Emit-safety (finally-shaped flush): the :hook-failures entries are accumulated during the teardown walk and flushed through a finally-shaped emission boundary, so that if teardown itself aborts after (say) hook 3 of 7 the entries collected so far still ship — the single-report shape does not sacrifice incremental delivery against a mid-teardown collapse. Emitted at most once per destroy. The dev-only per-hook diagnostic (:rf.warning/teardown-hook-exception, next row) is the diagnostic-channel companion, unchanged. Per 002 §Frame lifecycle and EP-0008 :ignored — teardown continues best-effort; the report is the production-survivable breadcrumb (recovery is not what promotion changes — the channel is) :frame (the frame being destroyed), :hook-failures (a vector of {:hook <step-key> :exception <ex> :where <catch-boundary>} — one entry per failed teardown step; :hook names the late-bound cleanup-hook key that threw OR the guarded direct-step key (e.g. the :frame/notify-machine-destruction! machine cascade), and :where is the boundary that caught it: :safe-call-hook! for a late-bound hook, :safe-teardown-step! for a guarded direct step), :reason
:rf.warning/teardown-hook-exception :warning diagnostic A best-effort teardown step — an optional late-bound cleanup hook (:elision/clear-warning-cache!, :ssr/on-frame-destroyed, :machines/on-frame-destroyed!, :schemas/on-frame-destroyed!, :flows/teardown-on-frame-destroy!, :routing/on-frame-destroyed!, :resources/on-frame-destroyed!, :epoch/snapshot-frame-destroyed, :epoch/on-frame-destroyed, :trace.tooling/release-frame-ring!) OR a guarded direct teardown step (notably the :frame/notify-machine-destruction! machine cascade, run under safe-teardown-step!) — threw while destroy-frame! was tearing the frame down. Teardown continues best-effort (the throw is swallowed so one bad hook can't block the rest of the recipe); this per-hook warning is the dev diagnostic breadcrumb at its causal position so a leaked cleanup (stale schemas, flow rows, side-channel atoms, trace rings) is traceable in long-lived SSR / test / tooling processes. It rides the diagnostic channel (dev-only, DCE'd in production); the production-survivable always-on fact is the single :rf.error/frame-teardown-failed report (previous row), which carries the same per-step detail under :hook-failures. (Per-step visibility is on the diagnostic axis; only the always-on emission collapses to one report.) Emitted through the shared record-teardown-failure! boundary in frame.cljc — the single trace/emit-error! call every best-effort teardown catch site funnels through: safe-call-hook! for the late-bound cleanup hooks (recording :where :safe-call-hook!), safe-teardown-step! for the guarded direct steps such as :frame/notify-machine-destruction! (recording :where :safe-teardown-step!), and the two epoch hooks (:epoch/snapshot-frame-destroyed PRE-dissoc, :epoch/on-frame-destroyed POST-dissoc — the terminal snapshot/publish split, rf2-vxgfnd.151), which catch directly at their own causal positions and route through the same boundary recording :where :safe-call-hook! for uniformity. dev-only (interop/debug-enabled?-gated) :ignored — teardown continues; the failed step's cleanup may be incomplete :hook (the failing step key — a late-bound cleanup-hook key, or a guarded direct-step key such as :frame/notify-machine-destruction!), :frame (the frame being destroyed), :exception, :where (the catch boundary that recorded it: :safe-call-hook! for a late-bound hook, :safe-teardown-step! for a guarded direct step)
:rf.warning/sub-input-dispose-exception :warning diagnostic A layer-2+ subscription's recursive disposal released its :<- input refs by calling unsubscribe once per input (symmetric with the per-input subscribe bumps taken at build time), and ONE input's unsubscribe threw — most plausibly from a buggy custom-substrate adapter -dispose. The per-input release walk is best-effort: the throw is swallowed so the REMAINING inputs still release (a skipped sibling release would leak its ref-count, which compounds with process lifetime), and this per-input warning is the dev breadcrumb at its causal position so the otherwise-invisible leak is traceable in long-lived SSR / test / tooling processes. Mirrors frame.cljc's safe-call-hook! / epoch's restore-quiesce posture — best-effort teardown, per-item diagnostic. The reference substrate adapters do not throw here, so the production firing case is currently theoretical; the row closes the observability gap, not a recovery gap. Emitted from subs.cljc's release-input-ref! (both the cached reaction's :on-dispose callback and the symmetric :not-cached-release escaped-caching path); dev-only (interop/debug-enabled?-gated via trace/emit-error!, DCE'd in production) :ignored — the input release is best-effort; the failing input's ref-count may leak, the remaining inputs still release :frame (the disposing frame), :rf.sub/query-v (the input query-vector whose release threw), :exception, :where (:on-dispose / :not-cached-release)
:rf.warning/sub-arg-cache-fragmentation :warning diagnostic A subscription was re-subscribed (the SAME sub-id, across renders) with a query-vector arg that is value-EQUAL (=) to the arg of the PREVIOUS subscribe of that sub-id but NOT identical? to it — a non-primitive arg (map / set / vector / record / fn) freshly REBUILT each render (e.g. a {…} literal or a collection assembled in the render body). The per-frame sub-cache keys by query-vector identity (subs.cljc's cache-key = =), so a fresh-but-equal arg mints a DISTINCT cache key each render: the cache grows unbounded and never reuses (zero hit-rate), silently — the sub still computes the right value, just never from cache. The subscription-cache analogue of the React-hook use-subscribe deps-array identity defence (spine.cljs). A one-shot dev tripwire, fired ONCE per sub-id, FALSE-POSITIVE-AVERSE by construction: it never fires on the FIRST subscribe of a sub-id (no prior arg), on an identical? (value-stable / hoisted) arg, on a genuinely-varying not= arg (legitimately-distinct parameters SHOULD fragment — correct keying), or on a primitive arg; only the value-equal-yet-fresh-identity non-primitive is the unambiguous, actionable signal. Emitted from subs.cljc's maybe-warn-fragmenting-arg! (fires on every subscribe, hit AND miss); dev-only (interop/debug-enabled?-gated via trace/emit-error!, DCE'd in production). Per 006 §Subscription cache — contract and operational semantics :ignored — the subscribe succeeds and the sub computes correctly; the warning is purely the diagnostic nudge to hoist the arg to a value-stable reference :rf.sub/id (the fragmenting sub-id), :rf.sub/query-v (the offending query-vector), :hint (the fix sentence)
:rf.warning/restore-quiesce-hook-exception :warning diagnostic An optional late-bound restore-time host-transient quiesce hook (:machines/on-frame-restored!, :http/abort-in-flight-for-frame!) threw while perform-restore! was quiescing the orphaned async host work of an epoch restore. Epoch restore installs the captured durable frame-state WHOLESALE, then cancels/clears the async host work the unwound epochs spawned (machine :after host-clock timers, non-resource managed-HTTP in-flight handles) so a late pre-restore completion cannot deliver to its original :rf/reply-to target (per Managed-Effects §SSR, preload, hydration, and restore). The quiesce runs only AFTER a successful install and is best-effort — a throwing subsystem hook is swallowed so it cannot strand the others, mirroring destroy-frame!'s safe-call-hook! posture; this per-hook warning is the dev breadcrumb that a restore-time cleanup leaked. Emitted from epoch/tool_pair.cljc's quiesce-orphaned-async-host-work!; dev-only (interop/debug-enabled?-gated via trace/emit-error!) :ignored — the restore stands; the failed hook's host-transient cleanup may be incomplete :hook (the late-bind hook key that threw), :frame (the restored frame), :exception
:rf.warning/trace-buffer-unrecognised-opts :warning diagnostic (rf/configure! {:trace-buffer ...}) was handed an opts map without a usable :events-retained — the retired {:depth N} shape, a negative value, or a non-numeric value. :events-retained N (non-negative integer) is the SOLE recognised opt (per §Retention contract — the single knob :rf.trace/events-retained). Emitted from trace/tooling.cljc's configure-trace-buffer!; dev-only (interop/debug-enabled?-gated, DCE'd in production). The loud-not-silent guard against believing retention was tuned when the call did nothing :ignored — the call is a no-op; the process-default events-retained is unchanged. The :reason names the fix ({:events-retained N}) :opts (the rejected opts map), :reason
:rf.error/unknown-registry-kind :error diagnostic re-frame.registrar/register! — the single chokepoint every reg-* macro funnels through — was called with a kind outside the closed v1 registrar-kind set (re-frame.registrar/kinds). The check runs FIRST, before the slot is written, any hook fires, or :rf.registry/handler-registered / :rf.registry/handler-replaced emits. The registrar kind set is closed-and-Spec-owned (adding a kind is a Spec change), so an unrecognised kind reaching register! signals a mis-wired internal caller (a framework reg-* surface, or a framework-internal register! call) rather than ordinary app code, which never calls register! directly. Surfaced as a thrown ex-info from re-frame.registrar/register!, not a trace. Per 001 §Registry model :fix-registration — the call throws; the registry is left untouched (the guard precedes the swap!). Fix the calling reg-* surface to pass one of the registered registry kinds :kind (the offending kind value), :id (the offending id), :reason
:rf.warning/missing-doc :warning diagnostic A reg-* registration's metadata-map carried no :doc (or :doc nil, or :doc ""). The registration completes; the warning is the dev-time nudge toward documented handlers. Emitted at most once per (kind, id) pair within a runtime process (suppression cache resets on frame destroy, matching the other one-shot warnings). Production builds elide the check entirely via goog.DEBUG. Per 001 §:doc is dev-warned when absent and :ignored — the registration completes normally; the warning is purely diagnostic :kind (one of the canonical registry kinds), :id (the registered id), :source-coords (the captured :rf/source-coord-meta sub-map, when available), :reason
:rf.warning/registration-collision :warning diagnostic A reg-* re-registration assigned an existing id to a different fn (different source-coord pair, in CLJS reference) rather than a re-eval of the same source file. Last-write-wins by default; the warning surfaces the change so dev tools can flag accidental shadowing. Recommended on in dev. Per 001 §Re-registration of a different function — collision warning :warned-and-replaced — the new fn replaces the existing slot (last-write-wins); the warning fires :kind, :id, :previous-coord, :new-coord
:rf.error/at-boundary-missing-schema :error diagnostic A reg-event-* call attached the :rf.schema/at-boundary interceptor (per 010 §Production builds) but the registration's metadata-map carried no :schema. The boundary interceptor is structurally meaningless without a schema to validate against, so the registrar hard-rejects the call at registration time rather than waiting for the first dispatch to surface the misconfiguration. Surfaced as a thrown ex-info from reg-event-*, not a trace. Per 010 §Production builds :no-recovery — the call throws an ex-info; the offending handler is NOT registered. The two fixes: (1) attach a :schema to the metadata-map (recommended), or (2) remove the boundary interceptor from metadata :interceptors :reg-fn (the calling reg-fn's name as a string), :id (the offending event-id), :reason, :recovery
:rf.error/reg-event-bad-interceptors :error diagnostic A reg-event-* metadata-map carried a malformed :interceptors value — a non-vector, or a vector carrying a non-interceptor entry (a value that is not a map carrying :id / :before / :after). The malformed chain cannot be honoured and is not silently dropped or coerced (consistent with the existing reg-event arg policing — :rf.error/reg-event-bare-interceptor / :rf.error/reg-event-bad-middle-slot). Surfaced as a thrown ex-info from reg-event-*, not a trace. Per 001 §Allowed forms of the middle slot :fix-registration — the call throws; the offending handler is NOT registered. The fix: make :interceptors a vector of interceptor references (a bare keyword id or an [id arg] 2-vector) — register the interceptor with rf/reg-interceptor (the public authoring form) and reference it by id (chains are reference-only, an inline value raises :rf.error/inline-interceptor-removed) :reg-fn (the calling reg-fn's name as a string), :id (the offending event-id), :got (the malformed value), :expected, :reason, :recovery
:rf.error/reg-event-bad-middle-slot :error diagnostic A reg-event-* call used a middle slot that is neither a metadata map nor a handler fn. This includes the retired positional interceptor vector form (reg-event-* id [i1 i2] handler). Surfaced as a thrown ex-info from reg-event-*, not a trace. Per 001 §Allowed forms of the middle slot :fix-registration — the call throws; the offending handler is NOT registered. Put interceptor chains in metadata {:interceptors [...]} :reg-fn, :id, :got, :expected, :reason, :recovery
:rf.error/reg-event-bad-arity :error diagnostic A reg-event-* call used an unsupported tail shape. This includes the retired metadata-plus-positional-vector form (reg-event-* id metadata [i1 i2] handler). Surfaced as a thrown ex-info from reg-event-*, not a trace. Per 001 §Allowed forms of the middle slot :fix-registration — the call throws; the offending handler is NOT registered. Use (id handler) or (id metadata handler) and put interceptor chains in metadata :interceptors :reg-fn, :id, :tail, :expected, :reason, :recovery
:rf.error/reg-event-bare-interceptor :error diagnostic A reg-event-* call supplied a bare interceptor map where metadata was expected, e.g. (reg-event-db id mw/some-interceptor handler). Because interceptors are maps, a bare interceptor map would read as metadata and silently drop the chain; the registrar rejects it loudly. Surfaced as a thrown ex-info from reg-event-*, not a trace. Per 001 §Allowed forms of the middle slot :fix-registration — the call throws; the offending handler is NOT registered. Write (reg-event-db id {:interceptors [mw/some-interceptor]} handler) :reg-fn, :id, :got, :expected, :reason, :recovery
:rf.error/reserved-event-id :error diagnostic A public reg-event named a reserved framework-standard event id — an :rf/* single-root id the framework owns and registers itself (into both the regular registrar and the image standard registry). Today the reserved set is #{:rf/set-db}. Re-registering one in app code is a reserved-id collision that fails loud rather than silently shadowing framework behaviour — per Conventions §Reserved namespaces ("A user may not (reg-event :rf/hydrate ...)"). The framework's OWN seeding goes through the private registrar/register! path, so this guard fires only on the public reg-event entry. Surfaced as a thrown ex-info from reg-event, not a trace. Registration-time :fix-registration — the call throws; the offending handler is NOT registered. Choose an application-namespaced id (e.g. :my-app/set-db) :reg-fn, :id (the reserved id), :reason, :recovery
:rf.error/set-db-bad-value :error diagnostic The framework-standard [:rf/set-db x] event was dispatched with a missing, nil, non-map, or EXTRA-trailing-args argument. :rf/set-db REPLACES the whole app-db partition with the supplied map ({:db new-db}), so it validates exactly one map argument — [:rf/set-db {…}] (no second-argument meaning); a trailing arg ([:rf/set-db {} :junk]) is a mis-call and fails loud rather than being silently ignored. Set app-db empty with [:rf/set-db {}]. The bad-argument diagnostic is raised through error/throw-error! from inside the :rf/set-db handler body, so it THROWS. At RUNTIME the interceptor chain catches that throw and surfaces it in-band as :rf.error/handler-exception (dispatch-sync does not re-raise). When [:rf/set-db x] is an :initial-events setup step it is a setup-step failure under strict construction (per EP-0027 §Failure): the runner detects the captured in-band handler-exception, tears the partial frame down, and re-raises as :rf.error/initial-events-step-failed. Raised by the :rf/set-db handler in events.cljc. Dispatch-boundary :no-recovery — the dispatch throws; app-db is NOT changed. The fix: pass a map (use [:rf/set-db {}] to empty app-db) :event (the offending argument), :rf.event/v (the event vector), :reason, :recovery
:rf.error/reg-event-db-removed :error always-on reg-event-db was called — removed (no alias). A hard error naming reg-event as the replacement and showing the two-line conversion (destructure :db from the coeffects map; wrap the return in {:db …}); fires in production too (a correctness contract). Per 001 §The retired event-registration names :no-recovery — the call is rejected; the handler is NOT registered. The fix: (reg-event id (fn [{:keys [db]} ev] {:db BODY})) :id (the offending event-id, when available), :reason (names reg-event and the conversion), :recovery
:rf.error/reg-event-fx-removed :error always-on reg-event-fx was called — removed (no alias). reg-event is the identical shape under the bare name (coeffects in, effects out). A hard error naming reg-event; fires in production too. Per 001 §The retired event-registration names :no-recovery — the call is rejected; the handler is NOT registered. The fix: rename reg-event-fx to reg-event :id (when available), :reason (names reg-event), :recovery
:rf.error/reg-event-ctx-removed :error always-on public reg-event-ctx was called — demoted to a framework-internal primitive (off the public surface; the context -> context mechanism is retained internally). A hard error naming reg-interceptor as the public replacement for application full-context work; fires in production too. Per 001 §The retired event-registration names :no-recovery — the call is rejected; the handler is NOT registered. The fix: express full-context work as a registered interceptor (rf/reg-interceptor with :before / :after) referenced by id from a reg-event chain :id (when available), :reason (names reg-interceptor), :recovery
:rf.error/invalid-interceptor :error diagnostic A reg-interceptor call received a malformed descriptor — neither {:before f} / {:after f} / {:before f :after g} (static) nor {:factory f} (parameterized), nor a migration-boundary interceptor value carrying :before / :after (per 002 §Error model). Also fires when a migration-boundary interceptor VALUE carries an :id disagreeing with the positional registration id. Registration-time / dev-only validation (it fires on the reg-interceptor path, which production never re-runs), so it stays dev-trace-only. Surfaced as a thrown ex-info from reg-interceptor*, not a trace. Emitted by interceptor_registry.cljc. Per 001 §Interceptors :fix-registration — the call throws; the interceptor is NOT registered. The fix: use one of the four descriptor shapes (or match the value's :id) :id (the offending interceptor id), :got (the malformed descriptor), :expected, :reason, :recovery
:rf.error/unregistered-interceptor :error diagnostic An event/frame :interceptors chain referenced an interceptor id with no reg-interceptor registration (per 002 §Validation and resolution timing). A live reg-event / make-frame fails at registration so typos die before dispatch; a dispatch-time occurrence is the defensive guard against corrupt state / a hot-reload race. Surfaced as a thrown ex-info, not a trace; dev-trace-only (the registration / chain-assembly path). Emitted by interceptor_registry.cljc's resolve-ref. Per 002 §Validation and resolution timing :fix-registration — register the interceptor with reg-interceptor before referencing it by id :ref (the offending reference), :id (the unregistered id), :reason, :recovery
:rf.error/invalid-interceptor-ref :error diagnostic An event/frame :interceptors chain entry is neither a keyword id nor an [id arg] 2-vector reference (per 002 §Interceptor references). Since the reference-only flip an inline interceptor value gets the dedicated :rf.error/inline-interceptor-removed instead; this category is now only the structurally-malformed (non-ref, non-value) entry. Surfaced as a thrown ex-info from chain assembly, not a trace; dev-trace-only. Emitted by interceptor_registry.cljc's resolve-ref / resolve-chain. Per 002 §Interceptor references :fix-registration — make the entry a keyword id or an [id arg] ref :ref (the offending entry), :expected, :reason, :recovery
:rf.error/inline-interceptor-removed :error diagnostic A public event/frame :interceptors chain carried an INLINE interceptor value — a map / value / Var (an ->interceptor result, a (path …) / (redact-interceptor …) value, a value-Var, a locally-bound interceptor symbol) — in a chain position. Interceptor chains are reference-only: register the interceptor with reg-interceptor and reference it by id. Fails at reg-event registration (validate-meta-interceptors!) for an early death, and re-guarded at dispatch-time chain assembly (resolve-chain) for a frame-level chain / corrupt state. The framework's own appended handler-wrapper (:rf/default? true) is exempt — it is framework machinery, not an authored chain entry. Surfaced as a thrown ex-info, not a trace; dev-trace-only (the registration / chain-assembly path). Emitted by events.cljc (registration) and interceptor_registry.cljc's resolve-chain (dispatch). Per 002 §Event and frame chain grammar :fix-registration — register the interceptor with reg-interceptor and reference it by a bare keyword :my/ic or an [id arg] 2-vector :entry / :offending (the inline value), :id (event id, registration path), :expected, :reason, :recovery
:rf.error/interceptor-factory-arity :error diagnostic A parameterized [id arg] reference targets an id that is NOT a :factory interceptor, OR a bare-keyword ref names a :factory (which requires an [id arg] form), OR a :factory cannot build for the supplied arg (it threw, or returned a non-descriptor / non-interceptor) (per 002 §Error model). Surfaced as a thrown ex-info from chain assembly, not a trace; dev-trace-only. Emitted by interceptor_registry.cljc's resolve-ref / resolve-factory. Per 002 §Interceptor references :fix-registration — reference a :factory interceptor as [id arg] (and a static one as a bare keyword); ensure the factory builds for the arg :ref (the offending reference), :id, :reason, :recovery
:rf.error/interceptor-override-invalid :error diagnostic An :interceptor-overrides map carried a malformed key (not an interceptor reference — neither a keyword id nor an [id arg] 2-vector) or a malformed replacement (neither another interceptor reference nor nil-to-remove). An inline interceptor value as a replacement is rejected here (per 002 §:interceptor-overrides). Surfaced as a thrown ex-info from dispatch-chain assembly when the merged frame/per-call override map is applied, not a trace; dev-trace-only (the chain-assembly path). Emitted by router.cljc's apply-icpt-overrides. Per 002 §:interceptor-overrides :fix-overrides — make each override key an interceptor reference and each replacement a reference or nil :key (the offending override key), :replacement (its value), :reason, :recovery
:rf.error/path-interceptor-bad-path :error diagnostic The standard [:rf.interceptor/path <path-vector>] reference carried a non-vector or otherwise malformed path argument (per 002 §Standard :rf.interceptor/path). Surfaced as a thrown ex-info from the standard path :factory (which runs at chain assembly — and at registration-time ref validation), not a trace; dev-trace-only. The error propagates verbatim through resolve-factory rather than being masked as :rf.error/interceptor-factory-arity. Emitted by std_interceptors.cljc's path-factory. Per 002 §Standard :rf.interceptor/path :fix-path — make the path argument an EDN vector naming a concrete app-db path, e.g. [:rf.interceptor/path [:cart :items]] :got (the malformed path argument), :expected, :reason, :recovery
:rf.error/unknown-listener-stream :error diagnostic A register-listener! / unregister-listener! call named a stream outside the closed vocabulary :trace / :events / :errors / :epoch (no bare-:trace default, no compatibility aliases — per §The listener API). Surfaced as a thrown ex-info from the dev-only listener-API tooling surface, not a trace; dev-trace-only (the listener-registration path, which production never reaches). Emitted by core.cljc's unknown-listener-stream!. Per §The listener API :fix-registration — the call throws; the listener is NOT registered. The fix: pass one of the four pure observation streams :where (the user-facing verb symbol — 'rf/register-listener! / 'rf/unregister-listener!), :stream (the offending stream), :valid (the closed vocabulary), :reason, :recovery
:rf.error/invalid-image :error diagnostic An rf/image constructor call carried a malformed spec (EP-0023 §Image / §Image Fragments) — a non-map spec, an unknown top-level image key, an :include-ns pattern that is not a glob string, or a malformed inline :registrations entry / unknown section key. Construction-time validation — it fires only on an rf/image call (an inert image-value construction), which production never re-runs, so it stays dev-trace-only. Surfaced as a thrown ex-info from rf/image, not a trace. Emitted by image.cljc's image / inline-entry->descriptor / registrations->inline-descriptors. Per EP-0023 §Image :pass-a-spec-map / :use-namespace-glob-strings / :use-a-call-shaped-tuple / :correct-the-section-key / :remove-or-correct-the-key — the call throws; fix the offending image spec slot :image (the image id, when known), plus the offending-slot key (:spec / :unknown-key / :bad-pattern / :section / :entry / :unknown-section), :recovery
:rf.error/image-zero-match :error diagnostic An image assembly :include-ns glob pattern matched ZERO loaded registration descriptors (EP-0023 §Namespace-Selected Images — "Zero matches are fail-loud by default"). Every pattern MUST select at least one descriptor; a zero match is a typo, a forgotten require, a DCE-removed namespace, or a stale namespace name producing a silently incomplete image — so selection refuses rather than seal a partial generation. Assembly-time validation on the runtime/SSR path (rf/make-frame), surfaced as a thrown ex-info, not a trace; the selection path is dev/assembly logic production never re-runs after boot, so it stays diagnostic-channel. Emitted by image.cljc's select-by-include-ns. Per EP-0023 §Namespace-Selected Images :fix-the-pattern-or-require-the-namespace — the call throws; correct the glob, require the missing namespace, or publish the optional feature as a separate image :image (the image id), :pattern (the zero-match glob), :loaded-ns (the loaded provenance namespaces considered), :recovery
:rf.error/image-duplicate-id :error diagnostic Image assembly selected ≥2 DISTINCT registrations (different impls / source coordinates) for the same (kind, id) within one image’s selection — including the default generation, the implicit selection over the whole source store (EP-0026 §Layered Resolution / §Default Image). The central "order never silently decides a winner" guarantee: a genuine collision is an ERROR, not a last-write. A deliberate override is expressed ACROSS images — define the winner in a LATER image and compose; the later image wins and the shadow is reported on :rf.gen/shadows. Assembly-time validation (rf/make-frame), thrown ex-info, not a trace; the assembly path is not re-run per event in production, so it stays diagnostic-channel. Emitted by image_assembly.cljc’s resolve-collision. Per 002 §Image resolution and composition :narrow-the-selection-or-rename-the-id — the call throws; narrow the :select-ns selection so only one is selected, rename the duplicate id, or move the intended override into a later image :image, :kind, :id, :colliding-coordinates (the source coordinates of every colliding descriptor), :recovery
:rf.error/image-within-image-collision :error diagnostic Within ONE image, an INLINE :registrations entry collided with a :select-ns-selected registration for the same (kind, id), or two inline entries defined one (kind, id). An image must resolve cleanly to ONE descriptor per (kind, id) — there is no within-image winner rule; an image may carry both :select-ns and :registrations, but they MUST be disjoint. A deliberate override is expressed as a LATER image (image-order layering, EP-0026 §Layered Resolution). Assembly-time validation (rf/make-frame), thrown ex-info, not a trace; diagnostic-channel. Emitted by image_assembly.cljc’s resolve-collision. Per 002 §Image resolution and composition :move-the-override-to-a-later-image-or-deduplicate — the call throws; define the override in a later image of the :images vector, or remove the duplicate inline entry :image, :kind, :id, :colliding-coordinates, :recovery
:rf.error/image-unsupported-kind :error diagnostic An image-assembly descriptor carried a registration :kind outside the closed re-frame.registrar/kinds set (Spec 001 registry taxonomy) — a malformed inline image section or a corrupt source-store entry (EP-0023 §Image Validation — "unsupported registration kind in image path"). Fails before sealing rather than producing a generation the runtime cannot resolve. Assembly-time validation (rf/make-frame), thrown ex-info, not a trace; diagnostic-channel (the assembly path is not re-run per event in production). Emitted by image_assembly.cljc's check-supported-kinds!. Per EP-0023 §Image Validation :correct-the-descriptor-kind — the call throws; use a valid reg-* section key / kind :image, :kind (the unsupported kind), :id, :coordinate (the descriptor's source coordinate), :recovery
:rf.error/image-missing-reference :error diagnostic A selected descriptor named a reference the sealed image generation does not provide (EP-0023 §Image Validation). TWO reference legs share this one fail-loud point: (1) an event/frame descriptor's :interceptors chain naming an APPLICATION interceptor id (not a reserved :rf.interceptor/* standard ref) absent from the generation — "event references missing interceptor"; (2) a :resource descriptor's {:from-db <scope-resolver-id>} derived-scope reference (Spec 016 §Resolver references) naming a :resource-scope resolver that is NOT selected into the generation — "resource references missing scope resolver" (a concrete :scope like :rf.scope/global or a [:rf.scope/session …] tuple names no resolver to validate). Assembly-time validation (rf/make-frame), thrown ex-info, not a trace; diagnostic-channel. Emitted by image_assembly.cljc's check-references!. Per EP-0023 §Image Validation :select-the-missing-registration-or-fix-the-reference — the call throws; select the namespace that registers the referenced interceptor / reg-resource-scope's the scope resolver, or correct the reference :image, :kind, :id, :rf.provenance/ns (the referencing descriptor's provenance namespace), :coordinate (its source coordinate), :missing-reference (the [:interceptor ref-id] / [:resource-scope scope-id] of the unresolved reference), :recovery
:rf.error/image-standard-replacement-forbidden :error diagnostic An image selected a public app descriptor colliding with a framework STANDARD registration. Standards are PROTECTED — they are not part of app image layer order and each encodes an execution invariant, so shadowing one is a correctness violation, not an app policy choice; there is NO public opt-in (EP-0026 §Framework Standard Registrations). Assembly-time validation (rf/make-frame), thrown ex-info, not a trace; diagnostic-channel. Emitted by image_assembly.cljc’s check-standard-collision! :rename-the-app-id-or-deselect-it — the call throws; rename the app registration’s id so it does not shadow the standard, or do not select the namespace that defines it :kind, :id, :standard-coordinate, :app-coordinate (the colliding app descriptor’s source coordinate), :recovery
:rf.error/image-duplicate-image-id :error diagnostic In a MULTI-image :images composition every image MUST be nameable by a DISTINCT id (EP-0026 §Image Keys) — the shadow report identifies each image by id. TWO conditions fail here, both under this id: two images sharing an :id (they could not name exactly one image each), OR an anonymous image (no id) — un-nameable, it cannot participate in composition (an anonymous loser/winner would leave a degenerate {:image nil :shadowed-by nil} shadow-report entry, rf2-x76af2.30). Anonymous images are exempt ONLY in a single-image composition (a lone image has no cross-image shadow to name — the local-test / default-image case). Assembly-time validation (rf/make-frame), thrown ex-info, not a trace; diagnostic-channel. Emitted by image_assembly.cljc’s check-unique-image-ids! :give-each-image-a-distinct-id (duplicate-id branch) / :give-each-image-in-the-composition-a-distinct-id (anonymous branch) — the call throws; give each image in the composition a distinct :id (a lone single-image composition may stay anonymous) :duplicate-image-ids, :image-ids, :anonymous-image-count, :image-count, :recovery
:rf.error/make-frame-bad-opts :error diagnostic An rf/make-frame call's opts ARGUMENT was not a MAP — nil, a keyword, a vector, a string, or any other non-map (EP-0024 §One constructor — opts is the map carrying :images / :id / :capabilities / :adapter + record-config keys including :initial-events; API §make-frame). The public constructor has a MAP-shaped contract, so a non-map opts is rejected at the boundary BEFORE any destructuring / (apply dissoc opts …). nil is REJECTED too: the all-defaults frame is (make-frame {}) (there is no zero-arity), so nil names nothing the empty map does not — it is only ever a typo or plumbing failure. Frame-creation-time validation (rf/make-frame), thrown ex-info, not a trace; the frame-creation path is runtime/SSR logic not re-run per event, so it stays diagnostic-channel. Emitted by live_frame.cljc's validate-opts!. Per API §make-frame and 002 §Per-instance frames :pass-an-opts-map — the call throws; pass an opts map, e.g. (rf/make-frame {:images [my-image]}) (an all-defaults frame is (rf/make-frame {})) :received (a redaction-safe shape summary of the offending non-map value), :recovery
:rf.error/make-frame-bad-images :error diagnostic An rf/make-frame call's :images opt was not a VECTOR (EP-0023 §Image Composition / §Public API — ":images, always a vector"). :images is the only spelling and it is always a vector; even a single image is a one-element vector. A bare image map, a seq, or any other non-vector is rejected rather than coerced, so the one-spelling contract does not erode. Frame-creation-time validation (rf/make-frame), thrown ex-info, not a trace; the frame-creation path is runtime/SSR logic not re-run per event, so it stays diagnostic-channel. Emitted by live_frame.cljc's validate-images!. Per EP-0023 §Image Composition :wrap-the-images-in-a-vector — the call throws; supply :images as a vector, e.g. :images [my-image] :images (the offending non-vector value), :recovery
:rf.error/on-create-retired :error diagnostic A frame construction map (make-frame / a frame-root ENSURE mount) supplied the RETIRED :on-create key (EP-0027 §Backwards-compat). Frame setup is now the declarative :initial-events vector; {:on-create [:app/boot]} becomes {:initial-events [[:app/boot]]}. Pre-alpha clean break — no compatibility shim. PREFLIGHT validation (caught BEFORE any setup step runs / before any container exists), thrown ex-info, not a trace; the frame-construction path is boot/SSR/test logic not re-run per event, so it stays diagnostic-channel. Emitted by frame.cljc's reject-retired-construction-keys!. Per 002 §Frame creation and EP-0027 §Specification :use-initial-events — the call throws; replace :on-create with :initial-events (a vector of event-vector steps) :on-create (the retired value), :recovery, :reason
:rf.error/initial-db-retired :error diagnostic A frame construction map supplied the RETIRED :initial-db key (EP-0027 §Backwards-compat). Seeding app-db is now itself an event: {:initial-db {:n 0}} becomes {:initial-events [[:rf/set-db {:n 0}]]} (:rf/set-db is the framework-standard app-db seed event). Construction is events-only — one visible event script, no special-cased direct write; pre-alpha clean break, no shim. PREFLIGHT validation (caught before any setup step runs), thrown ex-info, not a trace; diagnostic-channel (boot/SSR/test path). Emitted by frame.cljc's reject-retired-construction-keys!. Per 002 §Frame creation and EP-0027 §Specification :use-rf-set-db — the call throws; replace :initial-db with a leading [:rf/set-db {…}] :initial-events step :initial-db (the retired value), :recovery, :reason
:rf.error/initial-events-bare-event :error diagnostic A construction map's :initial-events TOP-LEVEL value was a BARE event vector ({:initial-events [:rf/set-db {…}]}) rather than a vector OF steps (EP-0027 §:initial-events). :initial-events is an ordered vector of setup STEPS; a one-step setup pays one extra bracket — [[:rf/set-db {…}]]. The strict [[…]] shape is the bright line: accepting "one event or a vector of events" would reintroduce the [:a :b] ambiguity. PREFLIGHT validation (caught before any step runs), thrown ex-info, not a trace; diagnostic-channel. Emitted by frame.cljc's normalize-initial-events. Per EP-0027 §:initial-events :wrap-as-vector-of-steps — the call throws; wrap the single event as a one-step vector, e.g. [[:rf/set-db {…}]] :received (the bare event vector), :recovery, :reason
:rf.error/initial-events-bad-step :error diagnostic An :initial-events STEP was neither an event vector nor a {:event … :opts …} map (a string, a number, a keyword, a map missing :event, …), OR the top-level value was not a vector at all (EP-0027 §:initial-events). Each step is a bare event vector ([:app/boot]) or a map ({:event [:app/boot] :opts {…}}). PREFLIGHT validation (caught before any step runs), thrown ex-info, not a trace; diagnostic-channel. Emitted by frame.cljc's normalize-initial-events. Per EP-0027 §:initial-events :pass-event-vector-or-map-step / :pass-a-vector-of-steps — the call throws; make each step an event vector or a {:event … :opts …} map :received / :step (the offending value), :recovery, :reason
:rf.error/initial-events-bad-event :error diagnostic An :initial-events step's EVENT was missing, empty, or not an event vector — a bare empty step vector ([]), or a map step whose :event is absent / empty / non-vector (EP-0027 §:initial-events). A step's event must be a NON-EMPTY event vector naming a registered event id, e.g. [:app/boot]. In the map form :event is REQUIRED. PREFLIGHT validation (caught before any step runs), thrown ex-info, not a trace; diagnostic-channel. Emitted by frame.cljc's normalize-initial-events. Per EP-0027 §:initial-events :supply-a-non-empty-event — the call throws; supply a non-empty event vector as the step / as the map step's :event :step (the offending step), :recovery, :reason
:rf.error/initial-events-bad-opts :error diagnostic An :initial-events MAP step's :opts was not a map, or it supplied :frame (EP-0027 §:initial-events). :opts is the ordinary dispatch-sync opts (e.g. {:rf.cofx {:rf/time-ms …}} for a deterministic clock) — the SAME opt surface the hand-written setup loop passes — with ONE restriction: :frame is forced to the frame being constructed and may NOT be supplied. PREFLIGHT validation (caught before any step runs), thrown ex-info, not a trace; diagnostic-channel. Emitted by frame.cljc's normalize-initial-events. Per EP-0027 §:initial-events :pass-an-opts-map / :drop-the-frame-opt — the call throws; pass :opts as a map, and drop any :frame (the construction frame is implicit) :step (the offending step), :recovery, :reason
:rf.error/initial-events-step-failed :error diagnostic An :initial-events setup step FAILED during frame construction (EP-0027 §Failure). Construction-time :initial-events is STRICT: ANY setup-step failure tears the partially-created frame DOWN (destroy-frame!) so no half-created frame is left live, then raises this. The runtime's traced-and-recover leniency does NOT apply during construction. A failure is EITHER an escaping throw out of dispatch-sync (a coeffect-resolution throw — unregistered / missing-required declared cofx — escaping context assembly) OR an in-band failure the chain CAPTURES (so dispatch-sync returns nil normally): a handler-body throw surfaced as :rf.error/handler-exception (the [:rf/set-db x] bad-arg case — :rf/set-db raises :rf.error/set-db-bad-value from inside the handler via error/throw-error!), a user-interceptor throw (:rf.error/interceptor-exception), a coeffect-supplier throw (:rf.error/coeffect-exception), or a flow throw (:rf.error/flow-eval-exception). The runner detects an in-band failure by installing a transient always-on error listener around each step dispatch (matching those PRE-COMMIT categories against the frame). A POST-COMMIT :rf.error/fx-handler-exception (an :fx handler threw AFTER the db committed) is NOT a setup-step failure — the event committed and the fx throw is best-effort (the FX atomicity asymmetry); the SSR server error projector catches such render-walk/cascade fx throws, and a THROWN setup step is instead the OUTER :on-error transport path (011 §:on-error vs :error-view). The error names the failing step. Frame-construction-time, thrown ex-info, not a trace; diagnostic-channel. Emitted by frame.cljc's run-setup-events!. Per EP-0027 §Failure :fix-the-setup-step — the call throws after tearing down the partial frame; fix the failing setup event :step-index (the failing step's 0-based index), :event (the failing event vector), :frame, :cause (the original throwable; absent for an in-band capture), :recovery, :reason
:rf.error/initial-events-runner-unavailable :error diagnostic A frame was constructed with non-empty :initial-events, but the setup runner is unavailable — re-frame.router is NOT loaded, so the :router/dispatch-sync! late-bind hook the runner reaches dispatch-sync through is unregistered (EP-0027 §Construction). :initial-events is dispatched through the router's synchronous path; require re-frame.router (or re-frame.core, which does) before constructing a frame with :initial-events. It fails loud, tearing down the partial frame first so no half-created, never-setup frame is left live. The common path (re-frame.core requires re-frame.router, publishing the hook before any runtime frame construction) never triggers it. Frame-construction-time, thrown ex-info, not a trace; diagnostic-channel. Emitted by frame.cljc's run-setup-events!. Per EP-0027 §Construction :require-re-frame-router — the call throws after tearing down the partial frame; require re-frame.router (or re-frame.core) before constructing a frame with :initial-events :frame (the id under construction), :step-count (the dropped step count), :recovery, :reason
:rf.error/frame-construction-in-handler :error diagnostic A frame was constructed (make-frame) INSIDE an event handler — a run was in flight (trace/*handler-scope* bound) at construction (EP-0027 §Construction). Frames are created by the VIEW (frame-root, the ENSURE boundary — frame-provider is SCOPE-only and creates nothing) or at TOP LEVEL (make-frame in tests, boot, SSR per request); a handler changes app-db, and the view materializes frames from it. This REMOVES today's two-regime :on-create handling (the mid-run case was async-queued; it is now a fail-loud error). The just-created container is torn back down before throwing, so no half-registered frame is left. Construction-time, thrown ex-info, not a trace; diagnostic-channel. Emitted by frame.cljc's construction engine. Per 002 §Frame creation and EP-0027 §Construction :construct-frames-in-view-or-top-level — the call throws; move the frame creation to a frame-root in the view tree, or to an explicit make-frame at top-level boot :frame (the id under construction), :recovery, :reason
:rf.error/frame-construction-in-progress :error diagnostic A frame construction could not acquire its per-frame-id transaction because the id is already reserved by in-flight construction/destruction, or still names a lifecycle-dead/provisional raw row awaiting exact settlement. Admission is fail-fast on both hosts: no wait or queue, no adapter allocation, no adoption/refresh of the provisional or dead row. The reservation spans adapter callbacks, provisional seating, trace policy, synchronous setup, lifecycle trace/hook publication, finalisation, and exact rollback; same-id re-entry from any of those callbacks therefore gets this typed loss. Disjoint ids remain independent. Construction-time, thrown ex-info, not a trace; diagnostic-channel. Emitted by frame.cljc's construction transaction engine. Per 002 §make-frame is atomic :retry-after-frame-transaction — the call throws; retry only after the owning transaction settles :frame (the contended id), :reason (:reservation-held, :lifecycle-closing, :lifecycle-dead, or :orphaned-provisional), :owner-kind, :recovery
:rf.error/frame-id-taken :error diagnostic A frame construction requested internal CREATE-EXCLUSIVE mode (the reserved :rf.frame/must-create? key) but the id already names a live final frame (rf2-vxgfnd.76). In-flight same-id construction/destruction is the distinct fail-fast :rf.error/frame-construction-in-progress, never this category. Exclusive mode is the primitive ui.test's Tier-1 plan-frame acquisition rests on: a plan-bearing ui.test/render installs each FRESH ISOLATED test frame with must-create, so an ambient final frame under the same id is a COLLISION — never a silent adoption or surgical refresh (either would render ambient state and quietly pass the assertion). Ordinary (non-exclusive) sequential construction is UNAFFECTED: it falls through to idempotent re-registration. Construction-time, thrown ex-info, not a trace; diagnostic-channel (the failure is fully observable at the throwing call). Emitted by frame.cljc's transaction engine. Per 002 §make-frame is atomic :use-a-distinct-frame-id — the exclusive construction throws; use a distinct frame id, or destroy the pre-existing frame before constructing (a fresh test frame is created + seeded per plan) :frame (the colliding id), :recovery (:use-a-distinct-frame-id), :reason
~~:rf.error/frame-reset-in-handler~~ n/a (retired) RETIRED (rf2-lxwpob). The dedicated reset-frame! verb (and its atomic mid-cascade rejection guard) is gone — a full reset is reproducible by composition (destroy-frame! then make-frame), which has no equivalent joint-atomicity guard to violate. The construction half already rejects mid-cascade via :rf.error/frame-construction-in-handler. Per 002 §Resetting a frame — destroy + make-frame and EP-0027 §Reset.
~~:rf.error/make-frame-record-only-key~~ n/a (retired) RETIRED. There is one make-frame that honours image-selection AND record-config keys (:initial-events, :fx-overrides, :platform, :ssr, :doc, :preset, :tags) in one call, so there is no record-only-key fence to enforce — there is no key to reject. Per 002 §Per-instance frames, API §make-frame, and EP-0024.
:rf.error/reset-frame-removed :error diagnostic rf/reset-frame! was called — REMOVED in rf2-lxwpob (no alias, API-shrink #5 frame-lifecycle collapse). A hard error naming (destroy-frame! id) (make-frame config) — re-supplying the SAME config (which carries :id, and :images for an image-loaded frame) the caller already holds — as the replacement. Thrown by re-frame.frame's reset-frame! stub; it does NOT fan out on the always-on error-emit channel (the loud throw IS the migration alarm — diagnostic-channel, unlike :rf.error/inject-cofx-removed). Catalogued for consistency with the other removed-stub categories. Per 002 §Resetting a frame — destroy + make-frame :destroy-frame-then-make-frame-with-same-config — the call throws; compose destroy-frame! then make-frame with the same config :got
:rf.error/reload-images-removed :error diagnostic rf/reload-images! was called — REMOVED in rf2-lxwpob (no alias, API-shrink #5 frame-lifecycle collapse). A hard error naming re-make-frame-ing the SAME :id with a new :images vector as the replacement (image hot-reload folded into re-construction). Thrown by re-frame.live-frame's reload-images! stub; it does NOT fan out on the always-on error-emit channel (diagnostic-channel, unlike :rf.error/inject-cofx-removed). Catalogued for consistency with the other removed-stub categories. Per 002 §Image resolution and composition :re-make-frame-with-new-images — the call throws; re-call make-frame with the SAME :id and a new :images vector :got
:rf.warning/reprojection-failed :warning diagnostic A per-frame image-reprojection ASSEMBLY failure during the deferred (next-tick) reprojection flush (rf2-rf3zgt). re-frame.live-frame's resilient sweep (reproject-live-frames-resiliently!) isolates EACH frame's reprojection in its own try/catch, so one bad hot-reloaded namespace edit no longer aborts reprojection for every OTHER live frame in the same coalesced burst — the mid-sweep-abort defect this bead fixed. The failed frame is simply left on its PRIOR generation (indistinguishable from an unchanged frame to reproject-live-frames!'s callers), and the sweep CONTINUES to every remaining frame regardless of where in the enumeration order the failure fell. The SWEEP runs on both hosts under both gate values; only this DIAGNOSTIC is dev-only. That split is deliberate (rf2-9c2jf): the wiring was once gated on interop/debug-enabled? end to end, justified by "production never re-registers after boot, so there is nothing to reproject", and nothing enforces that ordering — make-frame seals a generation UNCONDITIONALLY (EP-0026 §Default Image) and registrar/lookup resolves through that seal, so under -Dre-frame.debug=false a frame's view of the registration pool froze at construction and every later reg-* dispatched as :rf.error/no-such-handler. Keeping a frame's generation in step with the registration pool is a CORRECTNESS invariant, not a diagnostic, so the maintainer is now ungated. The elision the gate used to buy is preserved by REACHABILITY instead: the wiring is installed by ensure-reprojection-installed!, which only make-frame reaches, so an app that never constructs a frame never roots the reprojection + assembly graph and :advanced + goog.DEBUG=false still trims it. The EMIT below remains dev-only — it sits inside trace/emit-error!'s interop/debug-enabled? gate, so the diagnostic itself costs a production build nothing. Emitted by live_frame.cljc's reproject-live-frames-resiliently!. Per EP-0023 §Hot Reload :no-recovery — the failed frame is left on its prior generation; the sweep continues to every other frame :frame (the frame left on its prior generation), :exception, :where (:reproject-live-frame!)
:rf.warning/reprojection-flush-failed :warning diagnostic The belt-and-suspenders OUTER try/catch around the deferred (next-tick) reprojection flush (deferred-flush!) — a defensive last resort for a failure OUTSIDE the per-frame boundary itself (e.g. enumerating image-loaded-frame-ids, the dirty-flag reset!), vanishingly unlikely but diagnosed here rather than silently discarded. A deferred background tick has no caller to surface a throw to (it runs on a next-tick macrotask / the JVM executor thread, outside any reg-* call frame), so the failure is DIAGNOSED, not re-thrown; ALWAYS-ON-axis promotion is deliberately NOT added (Spec 009 §Observability channels — an assembly failure inside a background freshener tick is not a fact an app must be able to react to at runtime). The flush itself is NOT dev-only (rf2-9c2jf — see :rf.warning/reprojection-failed for why the maintainer had to be ungated and how the production elision survives); this diagnostic is, gated on interop/debug-enabled? inside trace/emit-error!, so it DCEs in production alongside :rf.warning/reprojection-failed. Emitted by live_frame.cljc's deferred-flush!. Per EP-0023 §Hot Reload :no-recovery — the deferred flush tick ends; the dirty flag was already cleared before reprojecting, so a re-entrant reg-* during the flush still re-arms a fresh tick :exception, :where (:deferred-flush!)
:rf.error/source-store-clear-all-under-bound-store :error diagnostic source-store/clear-all! was invoked while a bound *source-store* was in flight (EP-0023 §Registration Source Store — the per-store source store + generation-bump cache-invalidation contract). clear-all! is a PROCESS-DEFAULT-ONLY fixture-reset surface BY CONTRACT: it ALWAYS targets the default kind->id->ns->descriptor store and bumps that store's generation directly, IGNORING any bound *source-store*. A silent run under a bound store would clear+bump the WRONG (default) store, leaving the bound store STALE with an un-bumped generation (so a cache keyed on the bound store's old generation would never invalidate) — so the contract is ENFORCED, not merely documented, and clear-all! FAILS LOUD here rather than mutating the wrong store. The targeted-mutation surfaces (record-descriptor! / forget-* / clear-kind!) all honor the binding and bump the bound store; a bound store is reset via its OWN seating path, not this surface. Fixture-reset-time validation; surfaced as a thrown ex-info from source_store.cljc's clear-all!, not a trace — the fixture-reset surface is dev/test-harness logic production never runs, so it stays diagnostic-channel. Emitted by source_store.cljc's clear-all!. Per EP-0023 §Registration Source Store :reset-the-bound-store-via-its-own-seating-path — the call throws; reset the bound store through its own seating path, or call clear-all! only with no *source-store* binding in flight (the process-default-only contract) :recovery
:rf.warning/schema-validator-unavailable :warning diagnostic A reg-app-schema (or reg-app-schemas) call was made while the :schemas/malli-validate late-bind hook is unbound AND validator-fn is still the framework default. Per 010 §Recommended soft-pass the default validator returns true ("pass") when the Malli adapter ns hasn't been required at app boot — every validation site soft-passes, so boundary-validated handlers silently accept untrusted input. Emitted at most once per process from the registration sites. Suppressed when (a) :schemas/malli-validate is bound (Malli adapter loaded), or (b) the app explicitly registered a non-default validator via set-schema-validator! (apps that opted out of Malli). Production elides via goog.DEBUG. :ignored — the registration completes normally; the warning is purely diagnostic :reason (an actionable string that names the two fixes — require re-frame.schemas.malli at app boot, or call set-schema-validator! with a non-default fn)
:rf.warning/schema-walker-opaque :warning diagnostic A reg-app-schema (or reg-app-schemas) call was made with a genuinely opaque schema value the walker cannot introspect — a compiled m/schema object, a map, or any other non-vector, non-keyword value. The schemas-walker (re-frame.schemas.walker) is pure data and handles only vector-form Malli EDN; per-slot :sensitive? / :large? flags inside an opaque value are silently skipped — the validation-failure trace won't redact the sensitive slot and the size-elision walker won't see the :large? declarations. Keyword schemas do NOT warn: a bare keyword is a valid, idiomatic Malli schema — a primitive type (:int / :string) or a registry reference (:my/user-schema) — and cannot itself carry per-slot props, so the walker provably skips nothing. A primitive keyword and a registry-ref keyword are indistinguishable without a Malli-registry consult (which would violate 010 §The :schema value is opaque to re-frame), so the keyword case is suppressed entirely rather than warning on every keyword to catch the rare registry-ref-hides-per-slot-flags shape (that advanced shape is covered by the walker docstring's discoverability caveat). The workable fix: register the vector form directly so the walker can introspect it (the removed handler-meta :sensitive? coarse fallback is no longer an option — sensitivity is path-marked at the schema slot only, per §The :sensitive? registration metadata key). Emitted at most once per process from the registration sites; symmetric with :rf.warning/schema-validator-unavailable. Production elides via goog.DEBUG. Per 010 §The :schema value is opaque to re-frame and finding #12 :ignored — the registration completes normally; the warning is purely diagnostic :path (the reg-app-schema registration path that tripped the warning), :schema-kind (one of :compiled-schema-object, :unknown), :reason (an actionable string that names the vector-form fix)
:rf.warning/large-value-unschema'd :warning diagnostic The rf/elide-wire-value walker observed a large string at a path with no {:large? true} schema metadata. Emitted at most once per (path, frame) pair. Advisory: add {:large? true} to the schema slot when the value should be elided. Per §Size elision in traces :warned-and-replaced — the warning fires; the unschema'd value is not auto-elided :frame, :path, :bytes, :hint
:rf.error/sanitised-on-projection :error always-on The active error projector threw or returned a non-:rf/public-error shape; the runtime fell back to the locked generic-500 public shape. Always-on: rides the always-on error-emit axis (surface #4) ALONGSIDE the dev trace, making the §Server error projection promise ("monitor dashboards see when the public boundary fell back to the generic-500 shape") TRUE under -Dre-frame.debug=false. NON-PROJECTING + RE-ENTRY GUARD: this IS the fallback path, so the always-on emit must be one-shot and must never re-enter projection — error-emit-projection-listener explicitly SKIPS this category (its recursion guard), and project-error fires at most once per call, so the record ships to off-box shippers only and cannot drive a second projection. Per 011 §Where sanitisation happens — before render :replaced-with-default — runtime falls back to the locked generic-500 public-error shape :projector-id, :original-operation, :projection-failure-reason, :reason, :exception-message (the projector-threw arm), :returned (the non-conforming-shape arm)
:rf.error/ssr-head-resolution-failed :error always-on An SSR host adapter's resolve-head (per 011 §Head/meta contract) caught a throw from the active route's :head fn (the rf/active-head walk or the rf/head-model->html emit). The host adapter degrades to an empty head fragment so the request still produces a response; the structured record carries the exception for production observability. Always-on: rides the always-on error-emit axis (surface #4) ALONGSIDE the dev trace — this EXECUTES the resolved 011 §resolve-head emits …-failed Option-B ruling ("the always-on error-emit substrate carries the trace to user observability stacks") that the impl had drifted from. NON-PROJECTING: a recoverable degradation (empty <head>, body still renders → 200), so the always-on error-emit-projection-listener skips it (re-frame.ssr.error-listener/non-projection-eligible-errors) — promotion ships the off-box record but NEVER flips the degraded-200 wire outcome. Emitted by re-frame.ssr.ring.lifecycle/resolve-head in the Ring host adapter; symmetric helpers in other host adapters MUST emit the same category :no-recovery — the head fragment is empty (""); :html-attrs and :body-attrs are nil; rendering proceeds against the empty head :frame, :exception
:rf.error/ssr-render-failed :error always-on An SSR host adapter caught a render-time Throwable while building the response body (the validate-tag-name! rejection of e.g. (keyword "has space"), a view-fn (throw (ex-info ...)), a hiccup-walker structural error). Synthesised by re-frame.ssr/project-render-exception! (see re-frame.ssr.error-listener/project-render-exception!) so render-time and drain-time SSR failures unify under the same error projector — the wire body is the projector's :message / :code for both paths. Always-on: rides the always-on error-emit axis (surface #4) ALONGSIDE the dev trace so an off-box shipper on a -Dre-frame.debug=false JVM SSR host sees the structured render-failure record. PROJECTION-ELIGIBLE: the projection that stamps the response status is driven DIRECTLY by project-render-exception!; the buffered duplicate the always-on listener appends is cleared in the same call (consume-pending-traces!), so promotion does NOT double-stamp or re-project — the wire status is unchanged. Emitted by the JVM reference adapter's re-frame.ssr.ring.pipeline/build-full-response at the outer try/catch around render-to-string. Per 011 §View-time exceptions and (Mike decision Option B — unify render-time and drain-time failure surfaces) :projected-to-public-error — the active error projector is driven against the synthesised trace event; the public-error's :status is stamped onto the response accumulator; rendering proceeds with the projector's :message / :code on the wire. Outer :on-error hook is reserved for transport-layer / projector-undeliverable failures :frame, :exception, :exception-message, :ex-class
:rf.error/ssr-streaming-writer-failed :error always-on A streaming-SSR writer thread caught a Throwable while draining a post-head-commit chunk (the shell pieces, a continuation template/delta, the final hydration payload, or the suffix close). The response head already committed the chunked 200 to the wire (the first byte landed), so the status can no longer change — the writer logs the trace and closes the pipe for a clean EOF. The :phase tag names WHICH chunk was in flight when the write threw, and :boundary-id (present only on continuation phases) names the specific continuation that was draining, so ops can distinguish a broken client pipe from a bad final payload from a specific boundary drain rather than seeing one undifferentiated event. Distinct from :rf.error/ssr-render-failed (request-thread render-time, pre-commit, projector-recoverable): this is post-commit, on the daemon writer thread, where no projector recovery is possible. Always-on: rides the always-on error-emit axis (surface #4) ALONGSIDE the dev trace — pure off-box telemetry for a long-lived JVM SSR host. NON-PROJECTING: post-head-commit (:committed? true), so there is no response status left to change — the always-on listener never stamps a status from it (no projection-eligibility). Emitted by re-frame.ssr.ring.streaming/run-streaming-writer! in the Ring host adapter. Per 011 §Failure semantics — inline fallback :truncate-and-close — the pipe is closed with whatever partial response was already flushed; the client sees a clean EOF (the success status is already on the wire and cannot be retracted) :frame, :exception (the throwable's message), :ex-class, :phase (one of :shell-prefix / :shell-html / :continuation-template / :continuation-delta / :final-payload / :suffix), :boundary-id (present only on continuation phases — its presence is itself the "failed inside a boundary drain" signal), :committed? (true — every writer phase runs post-head-commit)
:rf.error/ssr-ring-error-view-failed :error always-on A caller-supplied :error-view (registered-view keyword or 1-arity fn) FAILED while rendering the projected-error body: it either THREW, or it depended on a reactive sub that RECOVERED to nil under production hardening (which does not throw but buffers a fail-closed projection the host detects via a pending-error-trace peek — rf2-oytx7j's open-proof). Either way the Ring host adapter falls back ONCE to the locked default error template from the ORIGINAL public-error, WITHOUT re-projecting — "a buggy error-view must not bypass the error boundary". The recoverable-degradation sibling of :rf.error/ssr-head-resolution-failed (same fragility class). Always-on: rides the always-on error-emit axis (surface #4) ALONGSIDE the dev trace so an off-box shipper on a -Dre-frame.debug=false JVM SSR host sees a broken error-view. NON-PROJECTING: fires from the projected-error arm (a drain-time 5xx, a render-time throw, or a post-render recovered-to-nil sub — Spec 011 §Drain-time error classification) AFTER the projected status was ALREADY stamped; the always-on error-emit-projection-listener skips this category (re-frame.ssr.error-listener/non-projection-eligible-errors), so promotion ships the off-box record but NEVER re-projects or flips the stamped status. Emitted by re-frame.ssr.ring.pipeline/resolve-error-body in the Ring host adapter; symmetric helpers in other host adapters MUST emit the same category :fell-back-to-default-error-template — the buggy error-view is discarded; the locked host default error template renders the body; the projected status is unchanged :frame; and either :exception (the throwable's message) + :ex-class (the throw variant), or :reason (:reactive-sub-recovered-to-nil-in-error-view, the recovered-to-nil variant)
:rf.error/ssr-ring-response-status-invalid :error always-on The Ring response materialiser saw a non-integer :status on the resolved response accumulator and REWROTE the response to a fail-closed 500 (re-frame.ssr.ring.pipeline/fail-closed-status — Ring statuses must be integers, and "404" is not assumed to mean 404). The rewrite is the point: an app's 200 becomes a 500, so it must not happen in silence. Always-on (rf2-gblft): until this promotion the flip's only signal was the :rf.ssr/ssr-non-integer-status dev warning below, so on a -Dre-frame.debug=false JVM SSR host an operator saw a 500 with no record of why on either axis — measured 0 warnings under the real gate. It now fans a NON-EVENT union record through the :error-emit/dispatch-error-record hook ALONGSIDE that dev warning. What reaches it (re-measured after rf2-dtpfv / PR #7204): the reserved :rf.server/* fx now guard their own args in every build, so the framework's own fx path can no longer feed the flip ([:rf.server/set-status "not-an-int"] is refused upstream and the projector stamps a 500; [:rf.server/redirect {:status "302"}] never populates :redirect). What remains is a HOST that hand-builds the accumulator (re-frame.ssr.response/swap-response!) or calls the PUBLIC materialiser with its own response map — the last-line backstop the rf2-dtpfv ruling reserved this net for, which is exactly why a silent one is not good enough. NON-PROJECTING: listed in re-frame.ssr.error-listener/non-projection-eligible-errors — it fires at MATERIALISATION time, strictly after the response is resolved and flushed, so a projection could only fight the 500 it is already reporting. The record is FRAMELESS (:frame nil, the :rf.error/malformed-hydration-payload spelling): the materialiser is a pure response-map to Ring-map fn with no frame argument, and an ambient read would populate the slot on the error arm while leaving it nil on the very path this promotion exists for. Emitted by re-frame.ssr.ring.pipeline/report-non-integer-status!; symmetric materialisers in other host adapters MUST emit the same category :failed-closed-to-500 — the response status is forced to 500 (a valid, fail-closed Ring response); the offending status is never coerced or guessed Always-on record (axis 1) — the closed set #{:error :frame :time :where :status-type :reason :recovery}, the source of truth being re-frame.ssr.ring.pipeline/status-defect-record-slots: :frame (always nil), :where (:ssr-ring/ssr-response->ring-response), :status-type (the offending value's CLASS NAME — program structure, the documented :ex-class residual class, never the value), :reason (:non-integer-status, a closed framework keyword — never prose), :recovery. Dev trace (axis 2) — adds the offending VALUE: :status (the raw non-integer, e.g. "404") and a prose :reason, alongside the same :where / :status-type / :recovery. The raw :status is deliberately axis-2-only and its ABSENCE from the record is asserted by name (the rf2-s3n6h precedent — a bound that does not bound)
:rf.epoch.cb/listener-exception :rf.epoch.cb diagnostic An epoch-record listener registered through the time-axis tooling callback registry threw when invoked with a settled epoch record; the failing callback is isolated (the next cascade re-invokes it afresh) and this trace fires so devtools surface the broken callback. Op-type :rf.epoch.cb (the time-axis tooling family, NOT a runtime :rf.error/*); a dev/tooling diagnostic, not a production-reachable runtime error — it FAILS the promotion criterion and correctly stays diagnostic. Emitted by re-frame.epoch.listeners (the time-axis tooling family consumed by the Xray spec) :no-recovery — the listener invocation is over; the next cascade re-invokes the same fn afresh, no automatic remediation between now and then :frame, :cb-id, :rf.epoch/id, :message
:rf.warning/epoch-redact-fn-exception :warning diagnostic A user-supplied epoch-record redaction fn threw while projecting an epoch record; the runtime isolates the failure and falls back to the unredacted projected record. DCE'd under CLJS :advanced + goog.DEBUG=false (the emit + literals sit inside an interop/debug-enabled? gate). A dev advisory — FAILS the promotion criterion and stays diagnostic. Emitted by re-frame.epoch.assembly :warned-and-replaced — the redaction fn's throw is swallowed; the unredacted projected record is used :frame, :rf.epoch/id, :ex-msg
:rf.warning/resource-sub-scope-mismatch :warning diagnostic A :rf.scope/from-caller resource subscription resolved a scope with no active owner while a DIFFERENT scope for the same resource IS active — the sub will read :idle forever (a silent permanent skeleton). A dev advisory (dedupe-keyed so it fires once per [resource-id sub-scope active-scope]), interop/debug-enabled?-gated and DCE'd in production — FAILS the promotion criterion and stays diagnostic. Emitted by re-frame.resources.subs. Per 016 §Scope :fix-scope — pass the active scope to the subscription (the :hint names the fix) :resource-id, :sub-scope, :active-scope, :hint
:rf.warning/mutation-scope-mismatch :warning diagnostic The write-side complement of :rf.warning/resource-sub-scope-mismatch: a mutation's :invalidates descriptor resolved a scope that matched NO cache entry while the SAME tags DO match an entry in a DIFFERENT scope — the invalidation silently missed (the scoped read is never refreshed, no error is raised because a scoped invalidation matching nothing in its own scope is a legitimate "no match here"). The mutation-scope footgun: a :rf.scope/global-defaulted execution scope (no payload / spec :scope) invalidating tags owned by a session-/tenant-/user-scoped resource. Fires at mutation settlement (:after-success / :after-settle / :after-failure) and on :before-request timing, per dispatched descriptor; a :cross-scope? true descriptor (the audited deliberate escape) is never flagged, and a tag with no cache entry in ANY scope (a true nothing-to-invalidate) does not warn. Reuses the SHARED re-frame.resources.events/match-invalidation-keys :other-scope-hit? signal so the diagnostic, the dispatched :rf.resource/invalidate-tags, and the settlement trace never disagree. A dev advisory (dedupe-keyed so it fires once per [mutation-id descriptor-scope other-scope sorted-tags]), interop/debug-enabled?-gated and DCE'd in production — FAILS the promotion criterion and stays diagnostic. Emitted by re-frame.resources.mutation-events. Per 016 §Mutation scope is two distinct scopes :fix-scope — declare the matching scope on the execute payload :scope, or use a per-target :invalidates descriptor {:scope … :tags …} (the :hint names the fix) :rf.frame/id, :mutation, :instance, :descriptor-scope, :mutation-scope, :other-scope, :tags, :hint
:rf.warning/optimistic-force-clobber :warning diagnostic An optimistic mutation rolled back with :on-conflict :force over one or more entries whose per-entry :revision had moved since the optimistic apply (a competing authoritative write landed in between) — so :force restored the recorded (now-stale) :before, clobbering that concurrent write (EP-0019 Decision 3). :force is the deliberate single-writer last-write-wins escape; this advisory makes an unexpected clobber loud (the default :on-conflict :invalidate defers to the read path and never clobbers). A dev advisory, interop/debug-enabled?-gated and DCE'd in production — FAILS the promotion criterion and stays diagnostic. Emitted by re-frame.resources.mutation-events. Per 016 §Optimistic settle :review-on-conflict — if the forced entries can be written concurrently, use the :invalidate default (refetch the authoritative value on conflict) instead of :force :rf.frame/id, :mutation, :instance, :forced-keys, :reason
:rf.warning/optimistic-tags-descriptor-skipped :warning diagnostic A malformed :optimistic-tags descriptor — a non-map entry, a non-collection :tags, or a missing :patch fn — was warn-and-skipped rather than thrown (EP-0019). The :optimistic-tags normalization runs inline at :rf.mutation/execute time, BEFORE the request lowers, so a throw aborted the whole event and the authoritative write never fired — strictly worse than :invalidates, which validates post-write at settle. The malformed descriptor is dropped (the well-formed descriptors in the same plan still apply); the optimistic paint is reversible best-effort, so the authoritative reply still settles the cache via :populates / :invalidates. The fail-closed SCOPE boundary is unaffected (it lives downstream in target resolution — a nil-resolving {:from-db …} still drops the target with :target-unresolved evidence). A dev advisory, interop/debug-enabled?-gated and DCE'd in production — FAILS the promotion criterion and stays diagnostic. Emitted by re-frame.resources.mutation-events. Per 016 §Optimistic mutations :fix-descriptor — give the descriptor a :patch fn and a :tags collection (the :reason names the offending shape) :rf.frame/id, :mutation, :reason, :descriptor
:rf.warning/mutation-target-skipped :warning diagnostic A recoverable settle-time mutation target (an :populates / :patches / :removes arm reaching the post-write apply) was dropped-and-warned rather than stranding the committed mutation: an UNREGISTERED resource, a non-map target, or a non-keyword :resource. The server write already committed, so dropping the one bad sibling (while the valid siblings in the same arm still apply) beats stranding the whole instance — but the developer still needs the loud, recoverable tripwire (the asymmetry-fix is NOT a silent swallow). Cache-identity CORRUPTION (a reserved-scope typo / non-EDN scope) never reaches here — it still THROWS in the runtime classifier, fail-closed. A dev advisory, one-shot idempotent per [mutation-id arm reason resource target] (so a re-executed mutation warns once per genuine bad target), interop/debug-enabled?-gated and DCE'd in production — FAILS the promotion criterion and stays diagnostic. Emitted by re-frame.resources.mutation-events. Per 016 §Map-form exact resource targets :fix-mutation-target — fix the target so the cache consequence lands (register the resource, or supply the map-form exact target {:resource <id> :params … :scope …}; the :hint names the offending shape) :rf.frame/id, :mutation, :instance, :arm, :reason (:unregistered-resource / :non-keyword-resource / :non-map-target), :resource, :target, :hint
:rf.warning/on-spawn-return-ignored :warning diagnostic A machine :on-spawn advisory observer returned a non-nil value, which the runtime IGNORES (:on-spawn is an observer, not a cascade action — its return cannot drive a transition). The advisory names the working remedy (dispatch :rf.machine/update-snapshot against the spawned :system-id). A dev teaching advisory, DCE'd in production — FAILS the promotion criterion and stays diagnostic. Emitted by re-frame.machines.transition. Per 005 §:on-spawn :no-recovery — the returned value is discarded; the snapshot is returned unchanged; use the named remedy to update the spawned machine :actor-id (the spawning parent's LIVE actor INSTANCE; :machine-id reserved for the TYPE), :spawned-id, :returned, :remedy
:rf.error/safe-redirect-invalid-url :error always-on The :rf.server/safe-redirect fx received a :location string that could not be parsed as a URL, or a scheme-bearing http(s) URL whose host is not extractable — an opaque http:evil.example.com or an authority-less http:/evil, which parse cleanly with a nil host yet navigate off-origin (:reason :scheme-without-host, and the arm additionally carries :scheme-class). Per 011 §Redirect precedence. The redirect is rejected; the response accumulator's :redirect slot is unchanged. Always-on (rf2-6jqa8): fans a NON-EVENT union record through the :error-emit/dispatch-error-record hook ALONGSIDE the dev trace, so a -Dre-frame.debug=false host reports the rejection to an off-box shipper instead of rejecting it in silence; the record is a closed PROJECTION of the diagnostics — #{:frame :recovery :reason :scheme-class}, every value framework-owned — so no URL, no URL component and no allowlist rides off-box, while the dev trace keeps the EP-0015-scrubbed :location beside them. NON-PROJECTING: listed in re-frame.ssr.error-listener/non-projection-eligible-errors, so the rejection ships a record but NEVER stamps a status — promotion changes what shippers see, never what the wire does. See the always-on paragraph above for why that half is load-bearing. Emitted by re-frame.ssr.response/safe-redirect-fx. Per (Mike decision, Option A — ship safe-redirect-fx alongside redirect-fx) :no-recovery — the redirect is rejected; no Location header is set Always-on record (axis 1) — the closed projection #{:frame :recovery :reason :scheme-class}, every value a framework keyword or the frame's own id: :frame, :recovery, :reason (:parse-failed or :scheme-without-host), plus :scheme-class on the :scheme-without-host arm, where it is :http or :https (a slot whose source did not parse is omitted rather than carried as nil, so the per-arm key sets differ — the parse-failure arm carries no class at all). Dev trace (axis 2) — adds the URL and the raw components: :location, EP-0015-scrubbed inside the shared builder, and the raw :scheme spelling, alongside the same :frame / :reason. See the always-on paragraph above for why the record is built FROM the allow-list rather than scrubbed down to it, and why it carries the CLASS rather than the parsed component
:rf.error/safe-redirect-scheme-rejected :error always-on The :rf.server/safe-redirect fx received a :location whose scheme is one of the rejected set (javascript:, data:, vbscript:) — these schemes have no safe interpretation as redirect targets (XSS via javascript:, data-URL phishing, IE-era vbscript:) — or, on the second arm, any scheme outside http / https (mailto:, ftp:, file:, tel: …), which carries :reason :scheme-not-allowed. Consistent with the custom-editor scheme rejection (per Security §Editor URI scheme allowlist). Always-on (rf2-6jqa8): fans a NON-EVENT union record through the :error-emit/dispatch-error-record hook ALONGSIDE the dev trace, so a -Dre-frame.debug=false host reports the rejection to an off-box shipper instead of rejecting it in silence; the record is a closed PROJECTION of the diagnostics — #{:frame :recovery :reason :scheme-class}, every value framework-owned — so no URL, no URL component and no allowlist rides off-box, while the dev trace keeps the EP-0015-scrubbed :location beside them. NON-PROJECTING: listed in re-frame.ssr.error-listener/non-projection-eligible-errors, so the rejection ships a record but NEVER stamps a status — a rejected ?next=javascript:alert(1) must not become a 500, or the mitigation would itself be a denial of service. Emitted by re-frame.ssr.response/safe-redirect-fx. :no-recovery — the redirect is rejected; no Location header is set Always-on record (axis 1) — the closed projection #{:frame :recovery :reason :scheme-class}, every value a framework keyword or the frame's own id: :frame, :recovery, :scheme-class (the aggregatable probe class — :javascript / :data / :vbscript on the rejected-scheme arm, :other on the non-http(s) arm), plus :reason :scheme-not-allowed on that second arm. Note what the class does NOT do: it never spells the caller's scheme. An arbitrary scheme is caller-authored text, so mailto: and s3cr3t-probe-token: both arrive as :other — the operator keeps the distinction worth aggregating (WHICH class is being probed) and the record keeps no byte the caller chose. Dev trace (axis 2) — adds the URL and the raw components: :location, EP-0015-scrubbed inside the shared builder, and the raw :scheme spelling ("mailto"), alongside the same :frame / :reason. See the always-on paragraph above for why the record is built FROM the allow-list rather than scrubbed down to it, and why it carries the CLASS rather than the parsed component
:rf.error/safe-redirect-host-disallowed :error always-on The :rf.server/safe-redirect fx received a :location whose host is not permitted by the call's policy — either :relative-only? true was set and the URL was not a relative reference (it carried a scheme OR an authority, which closes the opaque-URI and protocol-relative bypasses), OR :allow [...] was set and the URL's host did not appear in the allowlist. The :reason tag (:relative-only-violation or :not-in-allowlist) discriminates the two modes. Mitigation for the open-redirect class (audit 2026-05-14 §P3.2): an attacker-controlled ?next=… URL parameter cannot redirect off-origin when the application uses :rf.server/safe-redirect instead of :rf.server/redirect. Always-on (rf2-6jqa8): fans a NON-EVENT union record through the :error-emit/dispatch-error-record hook ALONGSIDE the dev trace, so a -Dre-frame.debug=false host reports the rejection to an off-box shipper instead of rejecting it in silence — open-redirect probing against a production app is exactly the signal a security team needs and, before promotion, was exactly what it could not see; the record is a closed PROJECTION of the diagnostics — #{:frame :recovery :reason :scheme-class}, every value framework-owned — so no URL, no URL component and no allowlist rides off-box, while the dev trace keeps the EP-0015-scrubbed :location beside them. NON-PROJECTING: listed in re-frame.ssr.error-listener/non-projection-eligible-errors, so the rejection ships a record but NEVER stamps a status — promotion changes what shippers see, never what the wire does. Emitted by re-frame.ssr.response/safe-redirect-fx. :no-recovery — the redirect is rejected; no Location header is set Always-on record (axis 1) — the closed projection #{:frame :recovery :reason :scheme-class}, every value a framework keyword or the frame's own id: :frame, :recovery, :reason (one of :relative-only-violation, :not-in-allowlist) — and nothing else, since neither arm has a scheme to class. :host is DEV-TRACE-ONLY. It reads like the most useful thing this row could carry, and it is the one slot deliberately given up: on both arms it is by construction a host the app did NOT authorise (that is what each :reason means), the redirect is already refused so there is nothing left for an operator to block, and it was this record's last unbounded caller-authored string — able to carry a sentinel outright, and able to be varied per request to write unbounded distinct values into a metrics dimension. :reason keeps the discrimination an operator acts on. Dev trace (axis 2) — adds the URL, the host and the policy: :location, EP-0015-scrubbed inside the shared builder, :host (the rejected host), and :allowlist (the allowlist vector, when supplied), alongside the same :frame / :reason. None of the three reaches an off-box shipper: :allowlist is additionally the app's own security configuration, and :reason :not-in-allowlist discriminates the arm without naming the boundary being probed. See the always-on paragraph above
:rf.error/redirect-retired-target-key :error diagnostic A :rf.server/redirect / :rf.server/safe-redirect fx args map carried a retired redirect-target spelling — :url or :to. The canonical (and only) target key is :location: these fx write an HTTP Location response header, so they use header vocabulary (routing / navigation surfaces may use :url / :to). Per EP-0007 §One name per fact there is no back-compat alias — the retired spelling fails loudly, NAMING :location, rather than degrading into the generic no-target warning the host adapter emits for a target-less redirect (which would hide the vocabulary mistake). Thrown ex-info from re-frame.ssr.response/redirect-fx and safe-redirect-fx (the shared reject-retired-redirect-keys! guard), before any other validation. Per 011 §Standard fx and EP-0007 §One name per fact :no-recovery — the call throws; the redirect is rejected and no Location header is set. The fix: rewrite the retired key as :location :where (rf.ssr/response), :reason (names :location and the retired spelling), :retired-keys (vector of the retired keys supplied — [:url], [:to], or both), :canonical-key (:location)
:rf.error/redirect-invalid-location :error diagnostic A :rf.server/redirect / :rf.server/safe-redirect fx received a :location carrying a CR / LF / NUL char — the header-splitting injection vector (a user-controlled ?next=…%0d%0a… param URL-decodes into literal CRLF and would split the header on the wire). The shared CRLF/NUL gate (validate-redirect-location!) fails fast at fx-handler time so the malformed redirect target never reaches the wire, and is run by BOTH fx as a defence-in-depth first step (per 011 §Standard fx and Spec 011 §CRLF fail-fast). This is the ONLY structural gate on the caller-trusted :rf.server/redirect path: no URL-shape check is applied — a raw space or other RFC 3986 shape quirk every browser accepts in a Location header passes through (URL-shape + origin/allowlist validation is :rf.server/safe-redirect's job, which runs its own richer emit-based parse gate surfacing the :rf.error/safe-redirect-* categories). On CLJS the fx is no-op'd by :rf.fx/skipped-on-platform (server-only), so this is a JVM-only gate. Thrown ex-info from re-frame.ssr.response/redirect-fx and safe-redirect-fx. Per 011 §Standard fx :no-recovery — the call throws; the redirect is rejected and no Location header is set. The fix: strip CR/LF/NUL from the :location before setting it :rf.error/id, :where (rf.ssr/response), :reason (names the CR/LF/NUL violation), :location (the rejected location), :recovery (:no-recovery)
:rf.epoch/restore-unknown-epoch :error diagnostic restore-epoch! was called with an epoch-id that is not in the frame's current epoch history (either never recorded or aged out by :depth). Per Tool-Pair §Time-travel :no-recovery — restore rejected; the frame's state is unchanged :frame, :rf.epoch/id, :history-size
:rf.epoch/restore-schema-mismatch :error diagnostic 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). Per Tool-Pair §Time-travel :no-recovery — restore rejected; the frame's state is unchanged :frame, :rf.epoch/id, :schema-digest-recorded, :schema-digest-current, :failing-paths
:rf.epoch/restore-missing-handler :error diagnostic 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. Per Tool-Pair §Time-travel :no-recovery — restore rejected; the frame's state is unchanged :frame, :rf.epoch/id, :missing (vector of {:kind :id})
:rf.epoch/restore-version-mismatch :error diagnostic The frame's recorded :rf/snapshot-version (per Spec-Schemas §:rf/machine-snapshot) is incompatible with the currently-loaded machine definition. Per Tool-Pair §Time-travel :no-recovery — restore rejected; the frame's state is unchanged :frame, :rf.epoch/id, :machine-id, :version-recorded, :version-current
:rf.epoch/restore-during-drain :error diagnostic 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. Per Tool-Pair §Time-travel :no-recovery — restore rejected; the user retries after settle :frame, :rf.epoch/id
:rf.epoch/restore-non-ok-record :error diagnostic restore-epoch! was called against an epoch record whose :outcome is not :ok (a halted-run record kept for devtools introspection; see Spec-Schemas §:rf/epoch-record §Outcomes). Restore is rejected because a halted record's :db-after is partial state the run never settled to. :no-recovery — restore rejected; the frame's state is unchanged :frame, :rf.epoch/id, :rf.epoch/outcome, :halt-reason
:rf.epoch/replace-during-drain :error diagnostic a pair-tool injection (replace-frame-state!) was called while the frame's drain was still running. Pair-tool injection is rejected; the caller retries after settle. Per Tool-Pair §Pair-tool writes :no-recovery — pair-tool injection rejected; the caller retries after settle :frame
:rf.epoch/replace-schema-mismatch :error diagnostic a pair-tool injection was called with a value that fails the frame's currently-registered schemas — a PRESENT app-db partition against the app-schema set, or a PRESENT runtime-db partition against the framework-owned runtime-db validator (reg-runtime-schema). An absent partition key is not walked (it is preserved, not written). The injection is rejected; the targeted partition is unchanged. Per Tool-Pair §Pair-tool writes and 010 §Per-frame schemas :no-recovery — pair-tool injection rejected; the targeted partition is unchanged. The failing paths are surfaced in :tags :failing-paths :frame, :failing-paths
:rf.epoch/replace-history-disabled :error diagnostic a pair-tool injection (replace-frame-state!) was called while the epoch ring buffer is disabled ((rf/configure! {:epoch-history {:depth 0}})). Each injection records a synthetic :rf.epoch/db-replaced undo-anchor so restore-epoch! can rewind PAST it — its caller's invariant is "undo works after this call". Under depth 0 the ring retains no history (Tool-Pair §Time-travel — consume via register-epoch-listener!), so the anchor cannot land and the invariant is unsatisfiable. The injection is rejected loudly rather than returning a false true (the in-artefact analogue of the absent-artefact :rf.error/epoch-artefact-missing throw — a silent success would lie about the undo invariant). The targeted partition is unchanged. Per Tool-Pair §Pair-tool writes :no-recovery — pair-tool injection rejected; the caller must re-enable history (:depth > 0) before injecting if it needs undo :frame
:rf.error/replace-frame-state-bad-keys :error diagnostic replace-frame-state! was called with a frame-state map carrying no recognized partition key (:rf.db/app / :rf.db/runtime), or carrying an unrecognized key alongside them (rf2-t3lftq — API-shrink #3). replace-frame-state! is a PARTIAL-PATCH surface: a present key replaces that partition, an absent key preserves it, so an accidental typo key (e.g. :rf.db/apps) or an empty {} would otherwise silently no-op the whole call while returning true — a false success against the caller's undo invariant. Checked BEFORE frame resolution (a caller-input-shape error, independent of the target frame's existence); rejected loudly rather than treated as a silent no-op. Per Tool-Pair §Pair-tool writes :no-recovery — the injection is rejected; the frame-state is unchanged :frame, :reason (:no-recognized-keys or :unknown-keys), :keys (the caller-supplied map's key set)
:rf.error/no-such-fx :error always-on A dispatched fx-id has no registered handler (and was not redirected by :fx-overrides). Per 002 §:fx ordering and atomicity guarantees. Emitted by re-frame.fx/handle-one-fx after override resolution and reserved-id matching both miss :no-recovery — the fx is dropped; cascade continues with remaining :fx entries :rf.fx/id, :rf.fx/args, :frame
:rf.error/fx-registration-invalid :error diagnostic A reg-fx call carried a malformed registration shape — no callable handler was supplied (e.g. the metadata-only (reg-fx :my/fx {:doc "…"}), whose (fn [ctx args] …) was omitted). Rejected at REGISTRATION time rather than deferring to a misleading fire-time :rf.error/fx-handler-exception (an NPE blaming the ABSENT handler for "throwing"). The input-side counterpart of :rf.error/cofx-registration-invalid (above) — the register site validates the callable BEFORE the registrar write so the fail-loud is symmetric across reg-fx / reg-cofx. Per 001 §Registration. Registration-time. :no-recovery — the registration is rejected :rf.fx/id, :reason
:rf.error/unregistered-cofx :error always-on A :rf.cofx/requires declaration referenced a coeffect id with no reg-cofx registration (the typo case). Per 001 §:rf.cofx/requires. Fired at registration where statically checkable, else at first processing — typos die before dispatch semantics apply. :no-recovery — the registration / dispatch is rejected :rf.cofx/id, :failing-id (the declaring handler / entry), :rf.trace/event-id (when available)
:rf.error/missing-required-cofx :error always-on A declared recordable fact is absent and cannot be ensured — the :strict mint policy (no generator runs; the Tool-Pair replay / :test preset binding), or any mode for a provided fact whose value was not stamped onto the token. Per 002 §Mint policies. :no-recovery — the cascade halts before the handler runs :rf.cofx/id, :failing-id, :rf.trace/event-id (when available)
:rf.error/cofx-value-invalid :error always-on A supplied, replayed, or generated recordable value failed the registration's :schema. Fires in production as well as dev — a causal-token contract validation (the :dispatched-at precedent: folding an out-of-contract value into the ledger is corrupt durable state). Per 002 §Satisfaction. The :schema-when-declared half ships with the generator machinery (slice-B.7) — every recordable value reaching the fold (present on the token OR freshly generated at processing-start) is validated against its reg-cofx :schema before delivery, through the shared set-schema-validator! seam (a nil validator / absent schemas artefact is a no-op; the check fails CLOSED on a validator that throws). The structural-EDN-always half — rejecting a non-EDN recordable value (a host handle: DOM node, Promise, function, atom, Date, JS / Java object) independent of a declared :schema, with reason :non-edn-recordable-value — ships for the SUPPLIED path in slice A: every supplied :rf.cofx value (other than the framework's :rf/time-ms) is walked at the dispatch boundary (re-frame.router/build-envelopevalidate-cofx!), AFTER the map-shape check and BEFORE the :schema validation. This structural walk is DEV-MODE (gated on interop/debug-enabled?; DCEs under :advanced + goog.DEBUG=false) — it catches a dev-time author error and the value is identical in production, so dev-time catching suffices; the declared-:schema check stays the always-on production causal-token contract (hence the row's always-on channel). The GENERATED-value structural check ships in slice B — a freshly generated recordable value is walked by the same re-frame.recordable predicate at the generator write-back site (re-frame.cofx/run-generator), AFTER the :schema check and BEFORE the value is written back into the in-flight :rf.cofx record, so a generator minting a host handle fails loudly at the source rather than far away at replay / Xray / SSR. Like the supplied-path walk it is DEV-MODE (gated on interop/debug-enabled?). Emitted from re-frame.cofx's satisfaction step (the :schema half) and generator write-back (the slice-B generated structural half) and from re-frame.router's dispatch boundary (the slice-A supplied structural half) :no-recovery — the cascade halts :rf.cofx/id, :value / :preview (a safe pr-str ONLY when the value is itself recordable — NEVER the raw host object), :explain (validator explanation when available), :reason (:non-edn-recordable-value for the structural half), :path + :bad-type (the structural half — the path to the bad leaf and its host class), :failing-id, :rf.trace/event-id (when available)
:rf.error/cofx-name-collision :error diagnostic A reg-cofx id collides with the fold's argument keys (:db / :event); or a :rf.cofx/requires declares the same id twice (any args) in one consumer scope. Reserved for these genuine call-time name-collision cases — a reg-cofx id colliding with another registered coeffect id across namespaces is NOT a call-time collision: it is caught generically at image assembly as :rf.error/image-duplicate-id (above), uniformly with every other registered kind. A malformed registration shape (bad metadata, missing supplier) is :rf.error/cofx-registration-invalid (below), not a collision, and the owner-qualified rf.-prefixed-namespace rule is a lint diagnostic, not a registration-time collision (001 §Collisions). Per 001 §Collisions. Registration-time. :no-recovery — the registration is rejected :rf.cofx/id
:rf.error/cofx-registration-invalid :error diagnostic A reg-cofx call carried a malformed registration shape — an invalid metadata map / provider grade (e.g. {:provided? true} without :recordable? true), or no supplier for a non-provided id. Distinct from :rf.error/cofx-name-collision (above), which is reserved for genuine duplicate-ownership / name-collision cases; the registrar discriminates so the taxonomy stays crisp (a bad shape is not a collision). Per 001 §reg-cofx. Registration-time. :no-recovery — the registration is rejected :rf.cofx/id, :reason
:rf.error/cofx-request-invalid :error diagnostic A malformed :rf.cofx/requires at registration — a non-vector, or a vector carrying a non-id entry. Per 001 §:rf.cofx/requires. Registration-time. :no-recovery — the registration is rejected :failing-id, :received
:rf.error/inject-cofx-removed :error always-on inject-cofx (or inject-cofx*) was called — removed (no alias). A hard error naming :rf.cofx/requires as the replacement; fires in production too (a correctness contract). Per 001 §inject-cofx is removed. Lands with the facade change (slice A.3) :no-recovery — the call is rejected :rf.cofx/id (the id passed to the removed inject-cofx, when available)
:rf.error/frame-destroyed :error always-on A dispatch / dispatch-sync / subscribe arrived against a frame that is unregistered or whose (:lifecycle frame-record) carries :destroyed? true. Per 002 §Frame lifecycle. The runtime recovers (dispatch / dispatch-sync no-op — the event is not enqueued; subscribe returns nil) and emits a production-survivable record through the always-on error-emit listener (surface #4) — NOT just the dev trace. Recovery (not throwing) is race-safe (teardown / hot-reload races vs. real use-after-destroy bugs are indistinguishable) while the broad listener keeps the diagnostic observable in production. The internal observation port (006 §The internal observation port) adds a throwing emit surface for the SAME category — probe/acquire! against a destroyed frame fan the always-on record then throw typed (internal fail-loud; the ViewCell maps the throw to the view error boundary); the public recovery column is unchanged. The compiled-view (frame) operation bundle (re-frame.ui, rf2-vxgfnd.230) is a further throwing surface for the SAME category: a bundle op (:dispatch / :dispatch-sync / :subscribe) invoked after its captured frame incarnation was destroyed, or a (frame) read resolving an absent/closing incarnation (:op :capture), fans the always-on record — carrying :frame, the failing :op, and the attempted :event/query — through the shared error-emit/emit-error-both! seam THEN throws typed. The incarnation fence emits at the ui source BEFORE delegating to core's dispatch/subscribe, so the same failure is never ALSO fanned by the router/subs recover-and-emit path (source-level provenance; no double emission); all four bundle arms route through one ui-frame emit-and-throw helper so their two-channel contract cannot drift. Emitted from router.cljc, subs.cljc, substrate/observation.cljc (throwing), and ui/frames.cljc (throwing) :replaced-with-default — dispatch / dispatch-sync recover (no-op); subscribe returns nil (the framework's built-in recovery for an invalid operation); the observation-port and (frame)-bundle surfaces throw instead — same id, several surfaces :frame, :event (the attempted event / query vector — elided in the :dispatch / :dispatch-sync realm and fail-closed to :rf/redacted under an unresolvable frame, but egressed RAW in the :subscribe realm because a subscription's query vector is IDENTITY, not payload, and never consults frame policy (rf2-zwgqe / rf2-alk8a — see 015 §Data-Classification)), :event-id (the vector head), :op (the failing operation realm — :dispatch / :dispatch-sync / :subscribe / :capture — a small closed-enum public realm attribution present wherever the emit site KNOWS the realm: the ui throwing (frame) surface (all four values, :capture being ui-only), the core capture-frame stale-op pre-check seam, AND the core router/subs late captured-op recovery fences (rf2-a2x2w — the A→B incarnation mismatch and the post-token-match / pre-enqueue / pre-drain-acquire window; carries :dispatch / :dispatch-sync / :subscribe). rf2-alk8a extends :op :subscribe to the ordinary address-directed SUBSCRIBE recover-and-emit path too: the core subs emit-frame-destroyed-recovery! and the internal observation port's throw-frame-destroyed! are subscribe-realm by construction, so they stamp it unconditionally — the realm stamp is what routes the query vector raw and resolves the coord realm-exact. It also STEERS error-emit/error-source-coord's realm-exact :source-coord resolution — [:event id] for a dispatch / dispatch-sync, [:sub id] for a subscribe (omitted when the sub-id is unregistered, never stealing a same-keyword event's coord). It is absent only on the ordinary address-directed DISPATCH recover-and-emit path (a dispatched event vector is payload, not identity → the record keeps its tight keyset, its per-path :event elision + unresolvable-frame fail-closed, and the legacy [:sub]-then-[:event] source-coord fallback)), plus the DEV-TRACE payload slots, which differ per emitter and are reconciled against the live emits in Spec-Schemas §FrameDestroyedTags (rf2-g8ict): the bare :event (router's recover-and-emit path and the ui (frame) bundle — the ui arm egresses the raw query vector in the :subscribe realm (identity — rf2-wd4ac, mirroring the core subscribe emitters) but the source-REDACTED body in the :dispatch / :dispatch-sync realms, nil for :capture), :query-v + :op :subscribe (subs' recover-and-emit path — subscribe-realm by construction, rf2-alk8a), :reason (:frame-destroyed, router + ui), and the internal observation port's namespaced trio :where / :rf.sub/id / :rf.sub/query-v plus :op :subscribe on the always-on record (matching its :rf.error/observation-retry-exhausted sibling row above). The bare :event spelling is the ERROR-tag spelling and is classification-aware — re-frame.classification/project-trace-event walks :event and :rf.event/v through the same project-event-tags chokepoint; :rf.event/v is the dispatch-PIPELINE spelling and is not stamped by any frame-destroyed emitter. :recovery rides the envelope's top level, not :tags (build-event hoists it on every branch)
:rf.error/write-after-destroy :error always-on The substrate adapter's replace-container! choke point was called with a nil container — a scheduled drain raced frame destruction and reached the per-event :db commit after frame/app-db-container started returning nil for the destroyed frame (the choke point covers the router :db commit, drain rollback, flows, epoch restore, and SSR write paths in one place). The underlying adapter's replace-container! is NOT invoked; the write is dropped. It rides the always-on axis: the dropped write is a suppressed write the next op cannot locally observe, is production-reachable in long-lived SSR / multi-frame hosts, and silence compounds with process lifetime — all three legs of the promotion criterion hold. This is the write-path consistency partner of :rf.error/frame-destroyed (previous-but-one row), which already surfaces the SAME destroy-race production-survivably on the dispatch / subscribe paths. substrate/adapter.cljc cannot static-require re-frame.error-emit (load order), so the emission rides the :error-emit/dispatch-on-error late-bind hook; the dev error trace stays for the in-process tooling surface (DCE'd in production). Payload is structured-only (no raw values). Per 006 §replace-container! and EP-0008 :ignored — the write is dropped and the frame is gone (mirrors :rf.error/frame-destroyed's recovery posture); the report is the production-survivable breadcrumb :reason
:rf.error/flow-eval-exception :error always-on A flow's :output fn threw during the recompute walk inside an event handler's interceptor pipeline (per 013 §Flow tracing). Distinct from :rf.flow/failed, which is the per-flow op-type-:flow trace; this is the cascade-level error event the router emits when the throw escapes the flow walk :no-recovery — the cascade halts; the snapshot is uncommitted. The per-flow :rf.flow/failed op-type-:flow event also fires for attribution :frame, :event (the event vector — the bare ERROR-tag spelling), :exception, :where :flow-eval, :flow-id (the failing flow's id — the prod-surviving attribution; the flow id alone, as there is no real flow value to carry). :where :flow-eval and :flow-id ride the record as top-level slots (lifted out of the thrown :exception's ex-data), so the flow is still identifiable under an egress profile that drops :exception (the :rf.egress/public-error projection profile, 015 §Data-Classification) — attribution survives egress independent of ex-data (rf2-z1332c)
:rf.error/machine-raise-depth-exceeded :error diagnostic A machine action's :raise cascade exceeded its depth limit (default 16). Per 005 §Bounded depth. The cascade halts; the snapshot is not committed :no-recovery — the :raise cascade halts; the snapshot is not committed :actor-id (the LIVE actor INSTANCE whose macrostep aborted; :machine-id reserved for the TYPE), :depth
:rf.error/machine-always-depth-exceeded :error diagnostic A machine's :always microstep loop exceeded its depth limit (default 16). Per 005 §Bounded depth. The cascade halts; the snapshot is not committed :no-recovery — the :always microstep loop halts; the snapshot is not committed :actor-id (the LIVE actor INSTANCE whose macrostep aborted; :machine-id reserved for the TYPE), :depth, :path (the visited-states vector)
:rf.error/machine-unresolved-guard :error diagnostic A machine's :guard reference is a keyword that does not resolve in the machine's :guards map. Per 005 §Guards and Spec-Schemas §:rf/transition-table. Surfaced at registration time (registration fails) and as a fallback at transition time :no-recovery — registration fails (or, at runtime fallback, the transition is rejected) :guard (the unresolved keyword), :machine-id
:rf.error/machine-unresolved-action :error diagnostic A machine's :action reference is a keyword that does not resolve in the machine's :actions map. Per 005 §Actions and Spec-Schemas §:rf/transition-table. Surfaced at registration time (registration fails) and as a fallback at transition time :no-recovery — registration fails (or, at runtime fallback, the action is skipped) :action (the unresolved keyword), :machine-id
:rf.error/machine-unresolved-on-spawn :error diagnostic A :spawn (or :spawn-all child) :on-spawn keyword ref resolves against NEITHER the machine's :on-spawn-actions map NOR its :actions fallback — the SAME two-registry order apply-on-spawn resolves through at runtime — following the FULL ref-chase, so a multi-hop or cyclic indirection is caught here too (exactly as the :guard / :action ref checks are). Mirrors :rf.error/machine-unresolved-action's fail-fast contract; previously a dangling :on-spawn ref silently resolved to "no callback" (the same branch a genuinely-absent :on-spawn takes), so the intended side effect never ran with no signal anywhere. An inline-fn :on-spawn needs no resolution; an absent :on-spawn is fine (the spawn simply has no callback). Surfaced at registration time. Emitted by re-frame.machines.lifecycle-fx.validation (machines/lifecycle_fx/validation.cljc). Per 005 §Declarative :spawn and Spec-Schemas §:rf/state-node :fix-registration — the call throws; registration is rejected. Register the callback under the machine's :on-spawn-actions (or :actions) map, or fix the :on-spawn ref :state, :where (:spawn / :spawn-all-child), :on-spawn (the unresolved keyword ref), :known-on-spawn-actions (the declared :on-spawn-actions keys), :known-actions (the declared :actions keys)
:rf.error/machine-bad-guard-form :error diagnostic A machine's :guard value is neither a keyword nor a fn (per 005 §Guards). Surfaced at registration time :no-recovery — registration fails :guard (the offending value)
:rf.error/machine-bad-action-form :error diagnostic A machine's :action value is neither a keyword nor a fn (per 005 §Actions). Surfaced at registration time :no-recovery — registration fails :action (the offending value)
:rf.error/machine-bad-state-form :error diagnostic A snapshot's :state is neither a keyword nor a vector path (per 005 §State paths). Surfaced at runtime when normalising the snapshot's state :no-recovery — the snapshot's state is rejected at normalisation; downstream walks halt :state (the offending value)
:rf.error/machine-bad-on-clause :error diagnostic A state-node's :on <event-id> value is not one of the four legal shapes (keyword target, vector path target, vector of guarded transition maps, or single transition map; per 005 §Transitions). Surfaced at registration time :no-recovery — registration fails :value (the offending shape)
:rf.error/machine-bad-always :error diagnostic A state-node's :always value is not one of the four legal shapes (keyword target, vector path target, single transition map, or a guarded candidate-vector; per 005 §Eventless :always transitions) — resolved through the SAME shared candidate grammar :on / :after use (rf2-0k0f3x), so a malformed value throws this categorised error rather than an uncategorised platform throw (e.g. an assoc on a non-map) at the first macrostep. Surfaced at the first macrostep that walks the offending state's :always (no registration-time backstop — matches :on / :after's value-form checking, which is likewise a runtime-normaliser concern) :no-recovery — the macrostep fails atomically; the snapshot is not committed :value (the offending shape)
:rf.error/machine-action-wrote-db :error diagnostic A machine action's effect map (or an :rf.machine/update-snapshot patch) contained :db. Per 005 §Hard-disallow :db. The runtime drops the :db key; remaining effects flow through :logged-and-skipped — the :db key is dropped from the action's effect map; remaining effects flow through :actor-id (the LIVE actor INSTANCE whose action wrote :db; :machine-id reserved for the TYPE), :action-id, :state-path, :offending-value (the rejected :db value — the whole app-db the callback tried to write, inherently the most sensitive payload and not snapshot-shaped, so it is summarized to :rf/redacted at the trace egress chokepoint before reaching listeners / epoch capture / AI-MCP / logs — the structural slots locate the offending action)
:rf.error/machine-grammar-not-in-v1 :error diagnostic A machine definition uses a grammar feature whose capability the running implementation does not claim per the 005 §Capability matrix — the port-relative unclaimed-capability disposition. The v1 CLJS reference claims :fsm/history (:type :history) and parallel regions (:type :parallel), so it never raises this for them; a leaner port that omits a capability rejects the corresponding key here (the illustrative trigger is whichever key the port left unclaimed — e.g. :type :parallel, :type :history, :tags, :spawn-all). Registration is rejected. Per 005 §How conformance is graded (the unclaimed-grammar error category) :no-recovery — registration is rejected :machine-id, :feature (the unclaimed key)
:rf.error/machine-state-not-in-definition :error diagnostic A snapshot's :state references a state-id that is not declared in the machine's :states definition (e.g. a snapshot from an older version of the machine). Per 005. (Older drafts spelled this :rf.warning/machine-state-not-in-definition; the :rf.error/ form is canonical) :no-recovery — the transition is rejected :machine-id, :state
:rf.error/machine-snapshot-version-mismatch :error diagnostic A persisted machine snapshot's :rf/snapshot-version is incompatible with the currently-loaded machine definition (per Spec-Schemas §:rf/machine-snapshot). Distinct from :rf.epoch/restore-version-mismatch, which is the epoch-history restore path. (Older drafts spelled this :rf.warning/machine-snapshot-version-mismatch; the :rf.error/ form is canonical) :no-recovery — the snapshot is rejected :machine-id, :version-recorded, :version-current
:rf.error/machine-always-self-loop :error diagnostic An :always entry's :target resolves to the declaring state itself (keyword equal to the state's own key, or vector equal to its own path). An internal :always with no :target is permitted (the action-microstep pattern). Per 005 §Self-loop forbidden at registration. Registration is rejected :no-recovery — registration is rejected :state (the declaring state-keyword), :machine-id
:rf.error/machine-compound-state-missing-initial :error diagnostic A compound state declares :states but no :initial. Per 005 §Initial-state cascading. Registration is rejected :no-recovery — registration is rejected :machine-id, :state
:rf.error/machine-bad-schemas :error diagnostic A machine spec's :schemas is present but is NOT a map. The machine-level :schemas declaration must be a map of schema categories (e.g. {:data <schema>}). Surfaced at registration time. Per 005 §The :schemas map :no-recovery — registration is rejected :schemas (the offending value)
:rf.error/machine-bad-schemas-key :error diagnostic A machine spec's :schemas map carries a sub-key outside the closed accepted set #{:data :events :output :tags :meta} — including [:schemas :input] (state input is not adopted). Surfaced at registration time. Per 005 §The :schemas map :no-recovery — registration is rejected :schemas-key (the offending sub-key), :accepted (the accepted set)
:rf.error/machine-final-state-compound :error diagnostic A state declaring :final? true ALSO declares :states (or :initial). Compound states cannot themselves be final — their finality is expressed by a leaf inside them. Surfaced at registration time. Per 005 §Final states :no-recovery — registration is rejected :machine-id, :state
:rf.error/machine-final-state-has-transitions :error diagnostic A :final? state ALSO declares :on, :always, :after, :spawn, or :spawn-all. Final means final — no further transitions (:entry / :exit actions ARE permitted). Surfaced at registration time. Per 005 §Final states :no-recovery — registration is rejected :machine-id, :state, :offending-keys
:rf.error/machine-output-key-without-final :error diagnostic A non-final state declared :output-key. The key is only legal on a state with :final? true. Surfaced at registration time. Per 005 §Final states :no-recovery — registration is rejected :machine-id, :state, :output-key
:rf.error/machine-error-flag-without-final :error diagnostic A non-final state declared :error?. The flag designates an ERROR terminal (re-frame2's spelling of XState v5's error final — a child finishing via an error leaf routes to the spawning parent's :spawn :on-error transition rather than :on-done); it is only legal on a state with :final? true and is meaningless on a non-final state. Symmetric with :output-key. Surfaced at registration time. Per 005 §:on-error and 005 §Final states :no-recovery — registration is rejected :machine-id, :state, :error? (the offending value)
:rf.error/machine-bad-on-error-clause :error diagnostic A :spawn-bearing state's :spawn :on-error value is not one of the four legal :on-shaped transition shapes (keyword target, vector-path target, single transition map {:target :guard :actions}, or a non-empty guarded candidate vector). Absent :on-error is fine — the spawn simply has no failure routing. Surfaced at registration time. Per 005 §:on-error :no-recovery — registration is rejected :machine-id, :state, :on-error (the offending value)
:rf.error/machine-timeout-without-on-timeout :error diagnostic A state-level or :spawn-level :timeout was declared with no :on-timeout — a timeout with no transition is meaningless. :timeout REQUIRES :on-timeout. Surfaced at registration time. Per 005 §:timeout / :on-timeout :no-recovery — registration is rejected; add :on-timeout or remove :timeout :state, :site (:state / :spawn), :timeout (the offending duration)
:rf.error/machine-on-timeout-without-timeout :error diagnostic A state-level or :spawn-level :on-timeout was declared with no :timeout — the transition has no deadline to fire it. Symmetric with :rf.error/machine-timeout-without-on-timeout. Surfaced at registration time. Per 005 §:timeout / :on-timeout :no-recovery — registration is rejected; add :timeout or remove :on-timeout :state, :site (:state / :spawn), :on-timeout (the offending transition)
:rf.error/machine-bad-timeout-duration :error diagnostic A state-level or :spawn-level :timeout duration is neither a POSITIVE INTEGER (literal ms) nor a valid ISO-8601 duration string ("PT5S", "PT2M", …). The XState "5s" / "10ms" readable shorthand is REJECTED (operator-ruled divergence), as are a non-positive integer, a fn, a vector, and a malformed ISO string. Surfaced at registration time. Per 005 §:timeout / :on-timeout :no-recovery — registration is rejected; use a positive-integer ms or an ISO-8601 duration string :state, :site (:state / :spawn), :timeout (the offending duration)
:rf.error/machine-timeout-after-collision :error diagnostic A :timeout resolves to a millisecond value that is ALSO an explicit :after delay-key on the SAME state node. The two would collide when the timeout desugars onto :after, silently dropping one of the authored intents. Surfaced at registration time. Per 005 §:timeout / :on-timeout :no-recovery — registration is rejected; give the timeout a distinct duration, or fold it into the :after entry directly :state, :ms (the colliding ms), :after-keys (the node's existing :after delay-keys)
:rf.error/machine-choice-missing-choice :error diagnostic A :type :choice transient state declares no :choice candidate vector — a choice state MUST name the guarded candidates it routes among. Surfaced at registration time. Per 005 §:type :choice :no-recovery — registration is rejected; add a :choice [{:guard … :target …} … {:target <default>}] vector :state
:rf.error/machine-choice-without-type :error diagnostic A state declares a :choice candidate vector but is not a :type :choice state — a :choice slot is meaningful only on a transient choice state. Surfaced at registration time. Per 005 §:type :choice :no-recovery — registration is rejected; add :type :choice, or move the candidates to :always :state
:rf.error/machine-bad-choice :error diagnostic A :type :choice state's :choice value is not a declarative, NON-EMPTY vector of guarded-candidate maps. A function-valued :choice is REJECTED (the operator-ruled A2 / C1 divergence — re-frame2's :choice is a declarative candidate ARRAY, never XState's choice-function); a keyword, an empty vector, or a single map is likewise malformed. Surfaced at registration time. Per 005 §:type :choice :no-recovery — registration is rejected; declare a [{:guard … :target …} … {:target <default>}] candidate vector :state, :choice (the offending value)
:rf.error/machine-choice-extra-keys :error diagnostic A :type :choice state also declares ordinary waiting-state behaviour — one or more of :entry / :exit / :on / :always / :after / :timeout / :on-timeout / :spawn / :spawn-all / :initial / :states / :final? / :output-key. A choice state ONLY routes its guarded candidates immediately on entry. Surfaced at registration time. Per 005 §:type :choice :no-recovery — registration is rejected; remove the extra keys, or use an ordinary state with :always :state, :extra-keys (the forbidden keys present)
:rf.error/machine-choice-no-default :error diagnostic A :type :choice state whose every :choice candidate is GUARDED — there is no unconditional default / else branch. If every guard fails the choice state has no candidate to take and is stuck (the static "no matching candidate + no default" rejection). Surfaced at registration time. Per 005 §:type :choice :no-recovery — registration is rejected; add a final unguarded candidate, e.g. {:target <fallback>} :state, :choice (the candidate vector)
:rf.error/machine-choice-self-loop :error diagnostic A :type :choice state declares a :choice candidate that targets its own declaring state — an immediate eventless self-loop (runs to depth-exceeded or is a no-op). Rejected exactly as an :always self-loop is. Surfaced at registration time. Per 005 §:type :choice :no-recovery — registration is rejected; target a distinct state :state
:rf.error/machine-bad-internal-events :error diagnostic A machine's :internal-events declaration is malformed — it must be a set of keywords (#{:tick :retry/internal}). A vector is the rejected XState array form (the operator-ruled set-form divergence — re-frame2 uses a Clojure set: membership is the natural shape, order is irrelevant, duplicates impossible); a non-set collection, or a set with a non-keyword member, is likewise malformed. Surfaced at registration time. Per 005 §Public / private :internal-events :no-recovery — registration is rejected; declare a #{…} set of keywords :internal-events (the offending value)
:rf.error/machine-unknown-node-key :error diagnostic A machine state node (root, descendant, or parallel-region root) declares an unknown bare key — one outside the closed :rf/state-node vocabulary (:on / :entry / :spawn / :always / :after / :tags / :meta / …). A bare unknown key reads as a typo (an XState-trained author's :invoke for :spawn, or :on-entry for :entry) and would be silently ignored — the spawn never fires, the action never runs — so it fails loud rather than swallowing (the no-silent-swallow policy; :meta is the sanctioned bare free slot, and a namespaced key is the open user-extension carve-out that passes). :type :history / :type :choice pseudo-states carry their OWN closed key-sets and are validated by :rf.error/machine-history-extra-keys / :rf.error/machine-choice-extra-keys instead. Surfaced at registration time. Per Conventions §Reserved state-node keys + §No silent swallow and Spec-Schemas §:rf/state-node :no-recovery — registration is rejected; fix the key (:invoke:spawn, :on-entry:entry), use :meta for tooling metadata, or namespace a user extension :state, :offending-keys (the bare keys present), :valid-keys (the accepted vocabulary)
:rf.error/machine-parallel-region-order-required :error diagnostic A :type :parallel machine with more than eight regions declared no explicit :region-order. A :regions map literal past the array-map threshold is a hash-map whose key iteration is hash order — authored declaration order is lost at read time and diverges between CLJ and CLJS — so the canonical region declaration order (which governs :data accumulation, :fx / cascade ordering, spawn allocation, initial-entry / destroy-exit order, root multi-target apply, and the finalization first-region tie-break) cannot be recovered from the map. Surfaced at registration time. Per 005 §Parallel regions and Spec-Schemas §:rf/state-node :fix-registration — registration is rejected; declare :region-order [<region> …] listing every region in declaration order (or keep the region count at/below the array-map threshold) :regions (the region keyset), :region-count
:rf.error/machine-parallel-region-order-mismatch :error diagnostic A :type :parallel machine's explicit :region-order is not an exact permutation of its :regions keyset — a missing, extra, or duplicate region. The order vector must list every declared region exactly once. Surfaced at registration time. Per 005 §Parallel regions and Spec-Schemas §:rf/state-node :fix-registration — registration is rejected; declare :region-order as a vector with one entry per region, no missing / extra / duplicate :region-order (the declared vector), :regions (the region keyset)
:rf.error/machine-unknown-spawn-key :error diagnostic A :spawn spec — or a :spawn-all child spec — declares an unknown bare key outside the closed spawn-spec vocabulary (:machine-id / :definition / :data / :id-prefix / :on-spawn / :on-done / :on-error / :start / :fixed-actor-id / :system-id / :timeout / :on-timeout, plus :id on a :spawn-all child). A bare typo (:machine for :machine-id, :on-complete for :on-done) would leave the spawn silently under-specified and mis-fire, so it fails loud (the no-silent-swallow policy); a namespaced key passes (the runtime stamps :rf/parent-id / :rf/invoke-id on declarative spawns, and user extensions may namespace). Surfaced at registration time. Per Conventions §Spawn-spec keys + §No silent swallow and Spec-Schemas §:rf/state-node :no-recovery — registration is rejected; fix the key or namespace a user extension :state, :where (:spawn / :spawn-all-child), :offending-keys, :valid-keys
:rf.error/machine-bad-tags :error diagnostic A state node's :tags slot is not a set of keywords (#{:loading :busy}). A vector or single keyword was formerly SILENTLY COERCED to a set — the runtime no longer coerces: :tags is a strict [:set :keyword], mirroring its sibling set-valued slot :internal-events (which hard-rejects exactly that shape). A non-set value, or a set with a non-keyword member, is rejected. Surfaced at registration time. Per 005 §State tags + Spec-Schemas §:rf/state-node :no-recovery — registration is rejected; declare a #{…} set of keywords :state, :tags (the offending non-set value)
:rf.error/machine-internal-event-reserved :error diagnostic A machine declares a reserved :rf/* framework event in :internal-events — the synthetic creation marker :rf.machine/start, the :rf.machine/done completion signal, :rf.machine.timer/after-elapsed, the :rf.machine.spawn/* family, etc. A framework-owned lifecycle event is inherently public traffic threaded through the standard dispatch pipeline; it cannot be repurposed as a machine's private event (the public / private split). Surfaced at registration time. Per 005 §Public / private :internal-events and Conventions §The single-root reserved set :no-recovery — registration is rejected; declare a non-reserved internal event name :internal-events, :reserved (the reserved members)
:rf.error/machine-internal-event-external-dispatch :error diagnostic An external dispatch of a declared private :internal-event arrived at the machine dispatch boundary (rf/dispatch [:my-machine [:tick]] where :tick is in the machine's :internal-events). An internal event is machine plumbing reachable only via an internal :raise; an outside caller (a view, a test, another handler) must not drive the machine with it. The dispatch is refused — no state change reaches runtime-db (a benign no-op, the committed snapshot untouched), exactly the shape an unhandled event takes. Surfaced at dispatch time. Per 005 §Public / private :internal-events :no-recovery — the external dispatch is refused (no state change); raise the event internally via :raise, or expose a public :on clause under a different name :actor-id (the LIVE actor INSTANCE that received the refused dispatch; :machine-id reserved for the TYPE), :event (the refused inner event), :event-id (its head), :frame
:rf.error/machine-cofx-requires-inline :error diagnostic A :rf.cofx/requires recordable-coeffect declaration was found on an INLINE machine callback rather than on a NAMED :guards / :actions entry map (the {:rf.cofx/requires [...] :fn (fn [...] ...)} form) — e.g. placed directly on an :on / :always / :after / :on-done / :entry / :exit slot value, or on a :guards / :actions entry that is a map carrying :rf.cofx/requires but no :fn. A fact-consuming guard / action MUST be a named entry so the declared diet sits with the code that can be checked against it (the silent-nil hole consumer attachment closes). Surfaced at registration time by the pure consumer-attachment indexer. Per 005 §Consumer attachment — declaring requirements on named entries and EP-0017 §7. :no-recovery — registration is rejected; move the inline fn into :guards / :actions and declare :rf.cofx/requires on its entry :where (the declaring site), :offending (the offending entry / value)
:rf.warning/machine-cofx-consume-undeclared :warning diagnostic A recommended consumer-attachment lint (dev-only): a NAMED machine guard / action whose :fn source reads a registered recordable-coeffect leaf off the :rf.cofx record that the entry did NOT declare in its :rf.cofx/requires. The framework cannot ENSURE an undeclared fact, so the read silently binds nil (live) or whatever a fixture happened to supply (test). A source-form heuristic over the macro-captured :source-code — shallow (a recommendation, not a hard gate; the ensure-set still only ensures DECLARED facts, so an undeclared read is un-ensured, never wrong). DCE'd in production. Emitted by re-frame.machines.cofx-attach (machines/cofx_attach.cljc). Per 005 §Consumer attachment and EP-0017 §9. :warned-and-proceeded — the lint is advisory; declare the leaf in the entry's :rf.cofx/requires so the framework ensures it :machine-id, :slot (:guards / :actions), :entry-id, :rf.cofx/id (the undeclared leaf)
:rf.warning/machine-cofx-ambient-durable :warning diagnostic A recommended consumer-attachment lint (dev-only): a NAMED machine ACTION declared an AMBIENT-grade coeffect id in its :rf.cofx/requires. An action may write durable :data; folding an ambient (re-run-on-replay) read into durable state violates "durable state folds facts, never reads" — the value re-reads the host on replay rather than re-presenting the recorded fact. Mechanically checkable (the id's reg-cofx grade is ambient, not :recordable?). DCE'd in production. Emitted by re-frame.machines.cofx-attach (machines/cofx_attach.cljc). Per 005 §Consumer attachment and EP-0017 §9. :warned-and-proceeded — the lint is advisory; register the fact :recordable? (or read it off :data / the event payload) if the action's :data write depends on it :machine-id, :slot (:actions), :entry-id, :rf.cofx/id (the ambient id)
:rf.warning/machine-source-unstamped :warning diagnostic A machine spec arrived at the registration home carrying no per-element source metadata — none of the :source-coords / :source-code co-locations the reg-machine macro (inline literal) and defmachine (value at its definition site) stamp onto each :guards / :actions / :on-spawn-actions entry and :states-tree map node. Xray's machine panel reads that surface to navigate a live snapshot back to the guard / action / state-node definition, so click-to-source is silently unavailable for the machine. The footgun shape is a plain (def m {…}) + (reg-machine :id m): the macro's literal-walk sees only the m symbol and captures nothing. Keyed on the observable property (the arriving spec carries no source metadata), NOT on symbol-vs-expression spelling — an inline literal and a defmachine value both arrive source-bearing and do not warn. A genuinely runtime-built spec (reg-machine* / computed / fixture-synthesised) structurally cannot carry author coords and may ignore this. Dev-only (interop/debug-enabled?-gated, DCE'd in production), once per id. Emitted by re-frame.machines.lifecycle-fx.registration/maybe-warn-source-unstamped! (machines/lifecycle_fx/registration.cljc). Per 005 §Source-coord stamping :warned-and-proceeded — registration succeeds and the machine runs unchanged; only tooling source-navigation is degraded. Fix: use defmachine for a named, reusable spec, or pass the spec literal inline to reg-machine :machine-id, :reason
:rf.error/machine-spawn-all-bad-shape :error diagnostic A child invoke-spec inside a :spawn-all block is missing :id or both :machine-id and :definition; or :spawn-all is not a vector; or the join-event slots are missing per the required-iff rules. Surfaced at registration time. Per 005 §Spawn-and-join via :spawn-all :no-recovery — registration is rejected :machine-id, :state, :reason
:rf.error/machine-spawn-all-duplicate-id :error diagnostic Two :spawn-all children collide on an id. Two surfacings, one category. (1) Registration — two child invoke-specs inside the same :spawn-all block share a logical :id keyword (each :id must be unique); surfaced at registration time by validate-spawn-all!, which throws (registration rejected). (2) Spawn-time (rf2-qlzh9) — two distinct logical children whose spawn args resolve to the same actor address (a :fixed-actor-id literal shared by two children, or a fixed id colliding with a generated <type>#n) would silently overwrite one prepared entry / one live actor; :rf.machine/spawn-all-init's admission preflight detects the aliasing and rejects the whole invoke atomically before any live join, child effect, snapshot, or prepared scratch is published — it seeds the childless reject sentinel exactly like the unregistered-TYPE reject, and emits this diagnostic once naming the offending logical child ids and resolved addresses. Registration guards only logical :ids, so a resolved-address alias passes reg-machine and is caught here. :collisions is an ordered vector of pairs, not a map (rf2-hys95): the collision GROUPS ride in first-appearance order and each group's child ids in declaration order, so a developer reading the reject to find WHICH declarations collided sees them in the order they wrote them, identically on CLJ and CLJS. A map presentation would degrade past the 8-entry array-map threshold to host-dependent hash order. Emitted by re-frame.machines.lifecycle-fx.spawn (machines/lifecycle_fx/spawn.cljc). Per 005 §Spawn-and-join via :spawn-all :no-recovery — registration is rejected (surfacing 1); the whole :spawn-all invoke is rejected fail-closed, spawning nothing (surfacing 2). Give each colliding child a distinct :fixed-actor-id, or drop the fixed id so the runtime allocates a unique generated address :machine-id, :state, :duplicate-id (registration); :parent-id, :invoke-id, :collisions ([[<resolved-address> [<logical-child-id> …]] …] — an ordered vector of pairs in first-appearance order, never a map), :frame, :recovery (spawn-time)
:rf.error/machine-spawn-all-with-spawn :error diagnostic A state node declares both :spawn and :spawn-all. The combination is rejected. Surfaced at registration time. Per 005 §Spawn-and-join via :spawn-all :no-recovery — registration is rejected :machine-id, :state
:rf.error/machine-spawn-bad-shape :error diagnostic A single :spawn-bearing state node's spawn-spec does NOT declare exactly one of :machine-id / :definition — it declares BOTH (ambiguous: an inline :definition would be initialised while :rf/machine-type stamps the registered :machine-id, so a later restore could materialise a different machine type) or NEITHER (nothing to instantiate; deferred to a late actor-id allocation failure). "Exactly one of :machine-id or :definition" is the registration-time XOR constraint. Surfaced at registration time. Per 005 §Declarative :spawn and Spec-Schemas §:rf/state-node :no-recovery — registration is rejected :machine-id, :state, :spawn (the offending spec)
:rf.error/machine-spawn-unregistered-type :error always-on A :rf.machine/spawn (or a :spawn-all per-child) named a :machine-id that resolves to no registered machine TYPE, and the spawn carried no inline :definition. The spawn is rejected fail-closed — NO snapshot, NO spawned-id allocation, NO :system-id binding, NO [:rf.runtime/machines :spawned …] slot, NO spawn-order record, NO :start (or synthetic [:rf.machine.spawn/spawned]) dispatch — there is no implicit "spec-less spawn" lifecycle. Distinct from the registration-time :rf.error/machine-spawn-bad-shape (which catches the no-id / both-id shape at reg-machine time): THIS is a runtime reject of a well-shaped spawn whose referenced TYPE is not registered (a load-order / platform-gated / typo'd id). Always-on: spawning an unregistered type is a production-reachable fail-closed boundary fact an off-box shipper on a goog.DEBUG=false build must still see, so it rides the always-on error-emit axis (surface #4) as a NON-EVENT union record via the :error-emit/dispatch-error-record late-bind hook (machines ships above core's require graph), ALONGSIDE the dev error trace (DCE'd in production) — the same always-on-plus-dev-trace shape :rf.error/write-after-destroy carries. For :spawn-all, an unregistered child TYPE rejects the whole invoke atomically: :rf.machine/spawn-all-init seeds a childless reject sentinel ({:rf/spawn-all-rejected? true}) at the join slot — physically present in runtime-db but carrying no :children, so it is a reject marker, NOT a live child-bearing join — and the registered siblings' per-child spawns read it and suppress themselves before installing, so a malformed set spawns nothing rather than orphaning the siblings under no live join. Because the sentinel is childless, no child is ever spawned to complete: a stray completion hits the join interceptor's childless-slot no-op so the :all join cannot deadlock, and parent-exit teardown clears the sentinel (per 005 §Spawn-and-join via :spawn-all §Errors). Payload is structural-only (no spawn args — :start / :data may hold application data). Emitted by re-frame.machines.lifecycle-fx.spawn (machines/lifecycle_fx/spawn.cljc). Per 005 §Spawning §Errors :no-recovery — the spawn is rejected fail-closed; register the machine (rf/reg-machine) before spawning it, or supply an inline :definition :machine-id (the unregistered TYPE), :frame, :reason, :recovery
:rf.error/machine-spawn-all-bad-child-id :error diagnostic A :spawn-all join received a done/failed signal whose inbound child-id is NOT in the seeded :children map — a forged / unknown child-id (a hand-crafted dispatch, copy-paste from a sibling :spawn-all, typo, or a cascaded event from a sibling parent) that the runtime would otherwise silently fold into :done / :failed, collapsing the join early. Gated runtime check (security-audit finding F1): the join state is NOT mutated. Emitted by re-frame.machines.lifecycle-fx.join (machines/lifecycle_fx/join.cljc). Per 005 §Spawn-and-join :event-dropped — the forged signal is dropped with a no-op fx; the join state is preserved :actor-id (the parent's live instance address), :invoke-id (the invocation path), :child-id, :children (the seeded child-id set), :kind (:done / :failed), :frame, :recovery
:rf.error/machine-destroy-bad-arg :error diagnostic A :rf.machine/destroy fx received a MAP arg the runtime cannot honour; the actor is NOT torn down and NO :rf.machine/destroyed trace fires (fail-loud, no dishonest terminal). Four :causes (rf2-3phait / rf2-nvxehu closed the map-form grammar to a discriminated union). :unverified-reap — a {:rf/reap true :rf/parent-id p :rf/invoke-id i :rf/child-id c} reap request whose claim does NOT match the live join state (the named :spawn-all join at [:rf.runtime/machines :spawned p i] does not own c, or c is not in :done ∪ :failed): the cancellation-SUPPRESSING :rf.machine/join-reaped destroyed reason may be selected ONLY when the runtime can PROVE, against durable join state, that the named actor is the completed / failed child of the named join — an in-progress actor's teardown is always an :explicit cancellation. :unresolved-join (rf2-nvxehu) — a reap whose terminal claim the join state DOES substantiate, but arriving AHEAD of the join attempt's :resolved? latch: reaping is a POST-resolution act, so a non-decisive completed child of a still-waiting :all join cannot be reaped early through the public reserved-fx boundary. This restriction belongs to the caller-visible reserved reap FORM; ordinary direct / parent-exit teardown does not accept a caller-selected reason and instead derives :rf.machine/join-reaped automatically from authenticated current private membership plus :done ∪ :failed. :slot-shape-mismatch (rf2-3phait) — a well-formed tracked / spawn-all form whose addressed [:rf.runtime/machines :spawned p i] slot holds the OTHER form's shape (the tracked single-:spawn form resolving a :spawn-all join-state MAP — consuming it as an actor id would clear the join slot and orphan every live child; or the spawn-all form resolving an actor-id KEYWORD). :unknown-shape — a map matching NONE of the known destroy shapes (the keyword actor-id form, the tracked {:rf/parent-id :rf/invoke-id} :spawn exit-cascade form, the :rf/spawn-all form, or the verified reap); notably the pre-auth forgery {:rf/actor-id … :rf/reason …}, which used to mint a caller-chosen destroyed reason and thereby suppress the cancellation terminal of an in-progress actor at the public reserved-fx boundary. The reap reason + reaped actor-id are runtime-derived from live join state, never caller-supplied, so :rf.machine/join-reaped cannot be forged (rf2-3lyqzu). Sibling of the :rf.error/machine-spawn-all-bad-child-id join-child forgery guard. Emitted by re-frame.machines.lifecycle-fx.destroy (machines/lifecycle_fx/destroy.cljc, via machines/lifecycle_fx/traces.cljc). Per 005 §Spawn-and-join :no-teardown — the destroy fx is refused; no snapshot / timer / resource teardown runs and no :rf.machine/destroyed trace fires :cause (:unverified-reap / :unresolved-join / :slot-shape-mismatch / :unknown-shape), :arg (the offending fx arg), :frame, :recovery (:no-teardown)
:rf.warning/spawn-all-join-unsatisfiable :warning diagnostic A :spawn-all join just became UNSATISFIABLE: a child FAILED, the spec declares no :on-any-failed, and enough children have now failed that the :all success condition is unreachable — the join would otherwise hang forever, silently. A one-shot dev-advisory fired on the fold that FIRST makes the join unsatisfiable (it was satisfiable before this fold and this fold did not resolve), so the operator sees the dead join + the likely fix. A config-footgun nudge in the dev-advisory family alongside :rf.warning/machine-cofx-consume-undeclared and :on-spawn-return-ignored (the request is not recovered, but the actor is not crashed). DCE'd in production. Emitted by re-frame.machines.lifecycle-fx.join (machines/lifecycle_fx/join.cljc). Per 005 §Spawn-and-join via :spawn-all :join-hangs — the advisory is informational; the join cannot resolve and will hang. Declare an :on-any-failed transition to handle child failures :actor-id (the parent's live instance address), :invoke-id (the invocation path), :join (the join policy, :all), :done (the resolved-done child-id set), :failed (the failed child-id set), :total (the child count), :frame, :reason
:rf.error/machine-after-fn-threw :error diagnostic A machine :after fn-form delay resolver threw while computing the delay ms. The exception is observable; the resolver still falls through to no-clock recovery. Emitted by re-frame.machines.timer (machines/timer.cljc). Per 005 §Delayed :after transitions :no-clock-configured — the delay-fn throw is caught; the timer falls through to the no-clock-configured recovery :exception, :frame, :recovery
:rf.error/machine-after-sub-threw :error diagnostic A machine :after sub-vector dynamic-delay subscription threw on deref while resolving the delay ms. The exception is observable; the resolver falls through to no-clock recovery. Emitted by re-frame.machines.timer (machines/timer.cljc). Per 005 §Delayed :after transitions :no-clock-configured — the sub-deref throw is caught; the timer falls through to the no-clock-configured recovery :exception, :rf.sub/id (the dynamic-delay subscription id), :rf.sub/query-v (its full subscription vector) — canonical subscription identity, not the bare :sub-id, :frame, :recovery
:rf.error/machine-after-watch-failed :error diagnostic add-watch threw while wiring a machine :after dynamic-delay subscription's change-detection watcher. Surfaced rather than silently dropped — without the watch the sub-changed re-resolution will not fire, so the author needs a signal that the dynamic-delay subscription is not actually wired up. Emitted by re-frame.machines.timer (machines/timer.cljc). Per 005 §Delayed :after transitions :static-delay — the timer falls back to the static (already-resolved) delay; the dynamic re-resolution is not active :exception, :actor-id (the timer's owning LIVE actor INSTANCE; :machine-id reserved for the TYPE), :rf.sub/id (the dynamic-delay subscription id), :rf.sub/query-v (its full subscription vector) — canonical subscription identity, not the bare :sub-id, :frame, :recovery
:rf.error/machine-parallel-nested-not-supported :error diagnostic A parallel region's own state-tree declares :type :parallel (nested parallel regions). Not supported in v1. Surfaced at registration time. Per 005 §Parallel regions and the 005 §Capability matrix :no-recovery — registration is rejected :machine-id, :state
:rf.error/machine-non-parallel-root-after-not-supported :error diagnostic A non-parallel (flat / compound) machine root declares :after — hand-authored, or lowered from a root :timeout / :on-timeout — and is rejected: root-level :after scheduling + resolution is supported ONLY for a :type :parallel machine root (the root-owned, machine-lifetime deadline). On a flat / compound root the timer would register but NEVER schedule or fire — the birth-time scheduler runs only from the parallel initial cascade, and no resolver fires a flat root's empty decl-path ([]) :after — so the intended "whole-machine deadline" would silently never elapse. Checked on the DESUGARED machine, so a root :timeout is caught via its lowered :after form in the SAME check as a directly-authored root :after. A :type :parallel root's :after is unaffected (that IS the supported, scheduled, resolved feature). Surfaced at registration time. Emitted by re-frame.machines.lifecycle-fx.validation (machines/lifecycle_fx/validation.cljc). Per 005 §Root-level :after :fix-registration — the call throws; registration is rejected. Move the deadline onto the machine's :initial state's own :after / :timeout, or make the root :type :parallel :after (the offending root :after map)
:rf.error/machine-parallel-output-key-conflict :error diagnostic A finishing PARALLEL machine declares :output-key on more than one region's final leaf with DIFFERENT keys, so the reported result is ambiguous. The runtime scans EVERY region's final leaf for :output-key (not just the first region's, per 005 §Final states) and, on a genuine cross-region conflict, deterministically keeps the FIRST region's declaration (state-map order — the stable, documented tiebreak; last-region-loses would be just as arbitrary). Surfaced at runtime when the machine reaches its final configuration. Emitted by re-frame.machines.lifecycle-fx.finalize (machines/lifecycle_fx/finalize.cljc). Per 005 §Final states :first-region-output-key-used — the first region's :output-key (state-map order) wins; declare :output-key on a single region (or the same key consistently) to make the reported result unambiguous :actor-id (the finishing machine's live instance address; :machine-id reserved for the TYPE), :frame, :output-keys (the distinct conflicting keys), :chosen (the first-region key that won), :reason, :recovery (:first-region-output-key-used)
:rf.error/machine-history-misplaced :error diagnostic A :type :history pseudo-state was declared somewhere with no owning compound state — at the machine root, or directly under a :type :parallel root that has no enclosing compound region. A history node must live inside a compound's :states (it records THAT compound's configuration). Surfaced at registration time by the pure validator. Per 005 §Pseudo-state constraints :no-recovery — registration is rejected :state (the misplaced history node's key, or :rf/root for a root-level history machine; :region for a region body), :feature :history (see §History-error tag layering below)
:rf.error/machine-history-extra-keys :error diagnostic A :type :history pseudo-state declared a key beyond the three the history grammar permits (:type / :deep? / :default-target) — e.g. :states, :initial, :on, :always, :after, :spawn, :spawn-all, :entry, :exit, :tags, or :final?. A pseudo-state is never occupied, so transition / lifecycle / projection keys are meaningless on it. Surfaced at registration time by the pure validator. Per 005 §Pseudo-state constraints :no-recovery — registration is rejected :state (the history node's key), :feature :history, :offending-keys (the extra keys) (see §History-error tag layering below)
:rf.error/machine-history-bad-default-target :error diagnostic A :type :history pseudo-state's :default-target does not resolve to a real state — a keyword that is not a direct child of the owning compound, or a vector path the definition does not declare (a dangling / misplaced :default-target). Surfaced at registration time by the pure validator. Per 005 §Pseudo-state constraints :no-recovery — registration is rejected :state (the history node's key), :feature :history, :default-target (the unresolvable value) (see §History-error tag layering below)
:rf.error/machine-history-duplicate :error diagnostic A compound state declared more than one :type :history pseudo-state in its :states. A compound may own at most one history node (deep-vs-shallow is the single node's :deep?, not a reason for two). Surfaced at registration time by the pure validator. Per 005 §Pseudo-state constraints :no-recovery — registration is rejected :state (the owning compound's key), :feature :history, :history-keys (the duplicate history-child keys) (see §History-error tag layering below)
:rf.error/no-such-route :error diagnostic A route-url call (or one of its callers) addressed a :route-id that is not in the routing registrar (per 012) :no-recovery — the call throws; the caller chooses how to surface the failure :route-id
:rf.error/missing-route-param :error diagnostic A route-url build-from-pattern call did not supply a value for a required path parameter (per 012 §URL building) :no-recovery — the call throws; the caller chooses how to surface the failure :param (the missing param keyword), :route-id
:rf.error/route-url-non-edn-value :error diagnostic A route-url call supplied a used path-param value or a (non-nil) query value that is not an admitted URL scalar — a host value outside the canonical-EDN identity domain (a function, atom / promise, raw JS object, DOM node, or non-portable number), OR an instant / host Date (a portable EDN identity, but not a round-trippable URL segment: its host str is host-divergent and match-url has no instant coercion vocabulary). re-frame2 fails closed at the URL-emission boundary rather than host-stringify the value into a fabricated or host-divergent route identity. The query-KEY side is already guarded by the canonical-order sort (:rf.error/non-edn-identity); this is the path-param + query-VALUE companion. Per EP-0012 §Canonical EDN identity (host str / object stringification / object identity MUST NOT invent a route identity) and Conventions §Canonical EDN identity. The narrower URL-scalar domain (over the general CEDN-1 domain that admits instants + composites) is documented at the route-url boundary. :no-recovery — the call throws BEFORE any URL string is built; the caller encodes the value as a portable EDN scalar (e.g. an ISO-8601 string for an instant) at the boundary first :route-id, :slot (:params / :query), :param (the offending key), :value, :rf.error/cause (the underlying :rf.error/non-edn-identity ex-data, for the host-value class)
:rf.error/can-leave-non-boolean :error diagnostic A route's :can-leave guard subscription returned a non-boolean value; the contract requires true (allow) or false (block). The runtime BLOCKS the navigation (fail-closed) and emits the loud diagnostic. Emitted by re-frame.routing.decisions (routing/decisions.cljc, closed contract). Per 012 §Navigation blocking :blocked-navigation — the navigation is blocked; fix the :can-leave sub to return a boolean ((boolean …) / (not …)) :route-id, :query, :value (the non-boolean return), :reason, :recovery, :frame (the navigating frame, so the diagnostic enters that frame's epoch trace-events and obeys the frame trace-disable gate)
:rf.error/can-enter-non-boolean :error diagnostic A target route's :can-enter guard subscription returned a non-boolean value; the contract requires true (allow) or false (block). The entry mirror of :rf.error/can-leave-non-boolean, sharing the same closed contract. The runtime DENIES the entry (fail-closed — terminal, per 012 §Entry is terminal) and emits the loud diagnostic. Emitted by re-frame.routing.decisions (routing/decisions.cljc, shared closed contract). Per 012 §Navigation blocking :blocked-navigation — the entry is blocked; fix the :can-enter sub to return a boolean ((boolean …) / (not …)) :route-id (the target route), :query, :value (the non-boolean return), :reason, :recovery, :frame (the navigating frame, so the diagnostic enters that frame's epoch trace-events and obeys the frame trace-disable gate)
:rf.warning/can-leave-subs-artefact-missing :warning diagnostic A route declared a :can-leave guard but the subscriptions artefact's subscribe-once hook is not bound (the subs feature is not on the classpath), so the guard cannot be evaluated. The navigation is ALLOWED (fail-open: a missing-artefact misconfiguration must not silently trap the user on a page). Emitted by re-frame.routing.decisions (routing/decisions.cljc). Per 012 §Navigation blocking :warned-and-allowed — the guard cannot run; navigation proceeds. The fix is to add the subscriptions artefact so the guard is evaluable :query, :frame (the navigating frame)
:rf.error/unsupported-scroll-strategy :error always-on The :rf.nav/scroll fx was handed a :strategy outside the closed :top / :restore / :preserve vocabulary — classically a MAP, the shape earlier drafts of Spec 012 advertised as "host-extensible". No registry, callback, or late-bound hook ever interpreted such a value, so it previously validated at the args boundary, rode the planner verbatim, and landed in the handler's nil default: no scroll, no diagnostic, no clue (rf2-px26m). The map form is now removed from the schema and the spec, and this category is the ALWAYS-ON leg of the rejection — the registration's :schema catches the same values one step earlier (:rf.error/schema-validation-failure :where :fx-args, fx skipped) but only when the OPTIONAL schemas artefact is on the classpath. That is why the category is always-on and not diagnostic (rf2-2hkfy): the leg exists precisely for the schemas-less host, and while it emitted through trace/emit-error! alone it was gated on interop/debug-enabled? and DCE'd under :advanced + goog.DEBUG=false — so a schemas-less PRODUCTION app reached the handler, performed no scroll, emitted no surviving record and returned nil, reproducing the very defect rf2-px26m closed, for the consumers least likely to notice. It now fans through re-frame.error-emit/emit-error-both!: axis 1 (the dispatch-on-error! listener registry, ungated) carries the record to off-box shippers in production, axis 2 keeps the unchanged dev trace. The rejection is unconditional on every build; only the dev-trace half is stripped. Navigation is unaffected; only the scroll is skipped. The two channels carry DIFFERENT payloads (rf2-s3n6h). A per-call :rf.route/navigate :scroll opt is runtime data, not necessarily static author configuration, and on the schemas-less path it may be any map / string / collection / host value. dispatch-on-error! passes the positional :event through elision/elide-wire-value but merges record-attrs UNCHANGED — so a raw copy of the rejected value in the attrs would bypass the elision seam on the one channel that ships off-box, unbounded (measured before the fix: a 2000-key strategy produced a 4.8 MB record, on a record whose :event slot the seam had already redacted). The always-on record is therefore structural: no raw :strategy, and a CONSTANT :reason that names the vocabulary and the fix rather than interpolating the offending value — which also keeps pr-str of an arbitrary value off the rejection path on every build. The raw value rides the dev trace alone. Emitted by re-frame.routing.scroll (routing/scroll.cljc, CLJS branch — the fx is :platforms #{:client}, so the JVM path short-circuits to :rf.fx/skipped-on-platform first). Per 012 §Custom scroll strategies :no-scroll — the effect performs no scroll; set the route's :scroll metadata (or the :rf.route/navigate :scroll opt) to :top / :restore / :preserve, or to false to suppress the effect deliberately Always-on record (axis 1) — structural only: :supported ([:top :restore :preserve]), :strategy-type (a closed-vocabulary SHAPE tag from re-frame.error/diag-value-summary's :type axis — :map / :string / :vector / :set / :seq / :keyword / :symbol / :number / :boolean / :nil / :fn / :scalar — which cannot reproduce the value; only :type is taken, because the record wants a discriminator rather than a size. When this row landed, taking only :type was also load-bearing: the summary's :keys leg was unbounded in map-key count and reproduced key content. rf2-210uq removed that leg, so the whole summary is now content-free and fixed-size, and this record could carry it entire — it does not, because :strategy-type is the closed axis consumers pin), :reason (the CONSTANT diagnosis), :recovery, :frame (the navigating frame, when the fx context carries one). Dev trace (axis 2) — adds the raw value: :strategy (the rejected value verbatim, for local debugging; DCE'd under goog.DEBUG=false) alongside the same :supported / :reason / :frame (the emit site supplies :recovery :no-scroll on this axis too, but build-event hoists it to the envelope top level, so it is a :tags key on axis 1 only)
:rf.error/navigate-bad-request :error diagnostic A [:rf.route/navigate {request}] event carried a structurally-invalid request map. The always-on structural gate rejected it BEFORE any guard ran (slice unchanged, no push). :reason names the first violation: :bad-event-arity (the event vector is not exactly [:rf.route/navigate {request}] — e.g. a third positional opts element), :request-not-a-map (a non-map payload), :unknown-keys (a key outside the closed roster :to/:url/:params/:query/:fragment/:replace?/:scroll/:bypass-leave?/:query-merge — NAMESPACED keys included; the offending keys are reported in total canonical order so a heterogeneous EDN-key set never trips compare), :to-url-exclusive (both :to and :url), :url-excludes-address (:url beside :params/:query/:query-merge), :params-requires-destination (:params on an in-place request — path params require a :to/:url destination), :query-exclusive (both :query and :query-merge), :query-merge-in-place-only (:query-merge on a destination request), :no-destination-or-change (empty map, pure-policy map, or no destination/in-place change), or :no-current-route (an in-place request before any current route exists). Emitted by re-frame.routing.navigate (routing/navigate.cljc). Per 012 §Navigation is an event :no-recovery — navigation is rejected; the slice is unchanged (no push). Fix the request map's shape :where (:event), :reason, :keys (the offending keys), :frame (when scoped)
:rf.error/prefetch-bad-address :error diagnostic A [:rf.route/prefetch {address}] event carried an address prefetch will not plan against. TWO gates run, in this order, both BEFORE any planning (no ensures dispatched, NO summary trace, current route readiness untouched). (1) The always-on structural gate — the value is not a closed :rf/route-address: :reason is :request-not-a-map (a non-map payload), :unknown-keys (a key outside the closed address roster :to/:params/:query/:fragment — a raw :url escape, a policy :replace?/:scroll, or an edit :query-merge all reject here; NAMESPACED keys included; the offending keys are reported in total canonical order so a heterogeneous EDN-key set never trips compare), :missing-to (a missing or non-keyword :to), or :bad-address (a structurally-wrong address value — e.g. a non-map :params / :query, or a non-string :fragment). Prefetch accepts ONLY the named-address form; it never takes a raw :url. (2) The named-destination resolution gate — the address is well-formed but does not resolve against the route registry and its declared schemas, adjudicated by the SAME boundary route-url and the programmatic door use, so prefetch can only ever warm the registered, validated destination a full activation would (previously an unregistered :to returned no work after a success summary trace, and a registered /:id route with :id omitted warmed the WRONG resource identity). :reason is the bare name of that boundary's error id: :no-such-route, :missing-route-param, :route-url-validation, or :route-url-non-edn-value, with :unresolved-destination as the total fall-through — disjoint from the structural reasons above. This gate reports STRUCTURE only: the offending param key or slot under :keys plus the :route-id, never the offending value (the boundary's own ex-data embeds the raw params and an explainer that reproduces them, the carrier class the navigate door redacts at its emit site). Distinct from :rf.error/resource-route-plan (an address that DOES resolve, whose resource plan could not be built — that carries :plan-cause :prefetch, 016 §Route integration). Emitted by re-frame.routing.prefetch (routing/prefetch.cljc). Per 012 §Route-plan prefetch :no-recovery — the prefetch is rejected before planning; no ensures dispatched, current readiness unchanged. Fix the address to a closed :rf/route-address naming a registered destination whose params satisfy the route's schemas :where (:event), :reason, :keys (the offending keys / param / slot), :route-id (the requested destination, on a resolution rejection), :recovery, :frame (when scoped)
:rf.error/route-link-bad-prefetch :error diagnostic A route-link's :prefetch link-behaviour control carried a PRESENT value other than :intent — an unsupported mode (:render, :viewport), a boolean, an explicit nil/false, or a typo. :intent is the only accepted value (there is no render mode, viewport mode, global default, or hover-delay knob), and an ABSENT :prefetch is the only way to be passive. Validated in routing's ONE shared link calculation on BOTH hosts — href-attrs (the rf/route-link CLJS render and the SSR shell), link-model (the compiled ui/route-link, likewise), and prefetch-payload itself — so every link surface Spec 012 declares behaviourally identical rejects it identically, server-side included. Previously an equality-or-nil test STRIPPED the bad value and rendered a silently passive link. Emitted by re-frame.routing.link (routing/link.cljc). Per 012 §Linking from views :no-recovery — a caller bug at the render site; the render throws. Pass :prefetch :intent or omit the key :where (rf/route-link), :slot (:prefetch), :value (the rejected value), :accepted (:intent), :recovery
:rf.error/invalid-route-classification :error diagnostic A reg-route's :sensitive / :large projection-relative data-classification declaration is structurally malformed: a non-vector axis ({:sensitive :not-a-vector}), a non-sequential path entry ({:sensitive [:not-a-path]}), or a non-EDN-identity path segment (an opaque host object / fn — the latter surfaced verbatim as :rf.error/bad-path from re-frame.path/normalize-concrete, the fail-closed :rf/path boundary). Thrown at reg-route time (caller bug; dev and prod), BEFORE any state mutates and before the route can ever activate — the fail-loud-on-malformed posture (a FORGOTTEN classification is fail-open). Value-independent: the SHAPE of the projection-relative declaration is validated, never the runtime value. The declarations lower into the per-frame elision registry (re-rooted under [:rf.runtime/routing :current …]) at route activation and drop at route change (the singleton current-route). Mirrors frame construction's :rf.error/bad-frame-classification. Thrown by re-frame.routing.classification (reached from re-frame.routing.registry/reg-route). Per 012 §Route data classification :fix-route-classification — supply a vector of valid projection-relative :rf/path vectors (e.g. {:sensitive [[:query :token]]}); the call throws until corrected :route-id, :axis (the offending :sensitive / :large key), :bad-path / :bad-value, :bad-segment, :rf.error/cause (the inner :rf.error/bad-path id when a segment is non-EDN)
:rf.error/invalid-url-strategy :error diagnostic A frame declared a custom :url-strategy (in its frame config) that is not a valid strategy map — a non-map value (including an explicit nil — presence semantics: only OMITTING the key selects the default), or a map missing a callable leg for a host-required key. On both hosts :encode and :decode must be callable fns; on CLJS the three browser legs :push! / :replace! / :install-listener! must be callable too (SSR never executes them, so JVM validation does not require them). Validated fail-loud at TWO seams: (1) the registration-time PREFLIGHT (rf2-ktmto9) — re-frame.frame's construction engine invokes the routing artefact's :routing/preflight-frame-config! hook with the final expanded config BEFORE any candidate-derived write, so a malformed declaration on FIRST registration leaves no frame record / registrar row / trace-policy state / URL claim / listener / trace event / :initial-events effect, and a failed RE-registration preserves every previously committed value and emits no :rf.frame/re-registered; and (2) a DEV-ONLY consult tripwire (re-frame.routing.strategy/url-strategy-from-config, interop/debug-enabled?-gated and DCE'd in production builds; rf2-ecb4sx). Because the preflight is the SOLE config-commit chokepoint into the frame store the consult points read, the four consult points (the URL-owner listener install reconcile-url-listener!, the route-link href render — re-run per render — and the :rf.nav/push-url / :rf.nav/replace-url fxs) are TRUSTED READS: they resolve an already-validated strategy verbatim and pay no per-consult re-validation. The dev tripwire re-checks only in development, so a future write-path bypass (a typo, a hand-rolled adapter, a hot-reload intermediate value that somehow reached the store) still throws with a canonical structured error rather than a raw host nil-function / TypeError at an arbitrary downstream call site. Extension keys are permitted and preserved; the unset/default branch skips validation (the shipped history-url-strategy is known-good). Thrown by re-frame.routing.strategy (routing/strategy.cljc). Per 012 §URL strategies :no-recovery — the registration / resolution throws; fix the declared :url-strategy so every host-required leg is a callable fn (or drop the key to use the default history strategy) :url-strategy (the offending value), :required (the host-required leg keys), :missing (the missing / non-callable legs; present on the missing-leg branch, absent on the non-map branch), :frame (the declaring frame id; present on the registration-preflight seam)
:rf.error/app-schemas-bad-arg :error diagnostic a schema opts surface was given a frame-target argument that is neither a keyword frame-id, a frame value, nor an opts map — OR an explicit :frame opt that resolves to a non-keyword frame target (per 010 §App-db schemas / §Per-frame schemas) :no-recovery — the call throws :received (the offending value), :expected (the contract string)
:rf.error/app-schema-bad-path :error diagnostic reg-app-schema / reg-app-schemas was called with a path that is not a get-in/assoc-in-shaped path — a non-sequential scalar such as a bare keyword (:n), string, number, nil, map, or set, rather than a sequential collection of keys (or [] for the root). Validated and thrown at registration time, BEFORE the per-frame side-table is mutated, so a malformed path can never reach validate-app-schema!'s (get-in db path) and trigger the silently-swallowed throw the router treats as a validation pass (per 010 §App-db schemas) :no-recovery — the call throws; the registration is rejected and nothing is stored :received (the offending path), :expected (the contract string), :rf.error/id
:rf.error/app-schema-runtime-path :error diagnostic reg-app-schema / reg-app-schemas was called with a well-SHAPED path whose first segment reaches into the runtime-db partition — a :rf.runtime/* keyword (:rf.runtime/machines, :rf.runtime/routing, :rf.runtime/elision, …), the :rf.db/runtime container root, or the app-db :rf/runtime root. App schemas validate ONLY app-db ((get-in app-db path)), so a runtime path either detonates every dev commit (a normal [:map …] schema over the nil app-db slot) or silently installs a validator the author falsely believes guards runtime-db — a category error with no behaviour to soft-land and no legitimate caller, hence a hard reject (distinct from the SHAPE error :rf.error/app-schema-bad-path). Validated and thrown at registration time, BEFORE any per-frame side-table mutation, so the bad path can never land; reg-app-schemas rejects the whole batch atomically. The runtime-db partition is framework-owned — the framework validates it (machine :snapshots refined per-machine from each machine's [:schemas :data]) and user code MUST NOT register schemas against it (per Conventions §Reserved runtime-db keys) — so the honest remedy is to drop the runtime path, NOT to call a (non-public, framework-owned) runtime-db registrar (per 010 §App schemas validate the app-db partition only and Conventions §Reserved runtime-db keys) :no-recovery — the call throws; the registration (or whole batch) is rejected and nothing is stored :received (the offending path), :frame (the resolved registration frame, or nil when no scope is established), :reason (states the honest remedy — drop the runtime path; runtime-db is framework-owned — and does NOT direct the user at a non-public, framework-owned API), :rf.error/id
:rf.error/app-schema-bad-metadata :error diagnostic reg-app-schema was called in its 3-slot (path metadata schema) form with a middle metadata argument that is not a map. Per 001 §Registration grammar the schema is the positional value slot — (reg-app-schema [:user] UserSchema) (2-slot) / (reg-app-schema [:user] {:frame :session} UserSchema) (3-slot); the optional middle map carries only metadata (:frame, :doc, open :my/* keys). Thrown at the authoring boundary in dev AND prod (a caller bug, not user input), BEFORE the per-frame side-table is mutated, so a mis-shaped registration can never land. The common slip is passing the schema where the metadata map goes (per 010 §App-db schemas) :no-recovery — the call throws; the registration is rejected and nothing is stored :path (the registration path), :received (the offending middle metadata arg), :rf.error/id
:rf.error/app-schemas-bad-batch :error diagnostic reg-app-schemas was called with a first argument that is not a {path -> schema} map — nil, a vector, a string, a seq of pairs, a set, etc. Validated and thrown at registration time, BEFORE any per-frame side-table mutation, so a malformed batch is rejected atomically rather than silently no-op'ing to []. Without this check (reg-app-schemas nil) (and any non-map) iterated zero entries and returned [] — indistinguishable from the documented {} no-op — so a boot/config/schema-loader bug passing nil got a false green with schema enforcement silently disabled. The empty map {} is accepted (the documented no-op returning []) (per 010 §App-db schemas) :no-recovery — the call throws; the whole batch is rejected and nothing is stored :received (the offending value), :expected (the contract string), :rf.error/id
:rf.error/unknown-preset :error diagnostic The frame config's :preset value is not in the closed set #{:default :test :story :ssr-server} (per Spec-Schemas §:rf/preset-expansion) :no-recovery — the call throws; registration of the offending frame fails :preset (the offending value), :valid (the closed set)
:rf.error/retired-registration-key :error diagnostic A reg-* registration-metadata map (reg-event / reg-sub / reg-fx / reg-cofx / reg-interceptor) carries a RETIRED v1 BARE key — the canonical case is :spec, renamed to :schema per MIGRATION §M-54. In v1 :spec silently carried the registration's payload schema, so swallowing it here would DISABLE that registration's validation — a soft-pass hiding bugs. Rejected loud in dev AND prod (mirrors re-frame.image's retired-image-key redirect), naming the canonical replacement, rather than silently swallowed. Per Conventions §No silent swallow. Emitted by re-frame.reg-meta/validate-registration-metadata! (reg_meta.cljc) :fix-registration — the call throws; the registration is rejected. Rename the retired key to its canonical replacement (:spec:schema) :kind, :id, :retired-key, :replacement
:rf.warning/unknown-registration-key :warning diagnostic A reg-* registration-metadata map carries a BARE (unqualified) key outside the kind's recognised vocabulary (Spec-Schemas §:rf/registration-metadata + the per-kind refinement) — a likely typo of a real key. NAMESPACED extension keys (:myapp/*, :rf.cofx/requires, …) are the open-map carve-out and pass silently. The unknown bare key is retained on the stored meta but never read; the cascade continues safely, so this is a WARNING (not an error), per Conventions §No silent swallow. Dev-gated (DCE'd under :advanced + goog.DEBUG=false). Emitted by re-frame.reg-meta/validate-registration-metadata! (reg_meta.cljc) :warned — the registration succeeds; fix the typo or namespace the extension key :kind, :id, :unknown-keys, :known (the recognised bare vocabulary), :reason
:rf.error/adapter-already-installed :error diagnostic A second install-adapter! call was made without an intervening dispose-adapter! (per 006 §Single adapter per process) :no-recovery — the call throws; the existing adapter remains installed :installed (the existing adapter), :attempted (the offending second adapter)
:rf.error/no-adapter-specified :error diagnostic (rf/init! …) was called with no args, nil, or a non-map argument (e.g. a keyword). The only legal call shape is (rf/init! adapter-map) — require the adapter ns and pass its adapter Var, e.g. (rf/init! reagent/adapter). Per 006 §Adapter selection at boot and. Surfaced as a thrown ex-info, not a trace :no-recovery — the call throws :where ('init!), :received (when nil/keyword/non-map), :expected, :reason
:rf.error/render-on-headless-adapter :error diagnostic render was called on the plain-atom (JVM/SSR) adapter, which only supports render-to-string (per 006) :no-recovery — the call throws; user should use render-to-string on this adapter :reason
:rf.error/hiccup-on-element-render-slot :error diagnostic render on a React-hook (element-shaped) adapter received CLJS data — a hiccup vector / seq / map — where a React ELEMENT is required (per 006; hiccup mounts only on the ratom-family substrates). Thrown by re-frame.substrate.spine/make-render BEFORE any root is created, replacing React's cryptic per-child "Objects are not valid as a React child" spray with one structured diagnostic; enforcing on every build (a programmer error, not user input). The ex-data carries an EP-0015-safe shape summary of the tree, never the raw tree :no-recovery — the call throws; nothing is mounted; build the tree with the substrate's element macro (e.g. UIx $) :reason, :render-tree/summary
:rf.error/derived-container-replaced :error diagnostic replace-container! was called on a derived container (a make-derived-value result). Derived containers are read-only — there is no slot to write into — so the core's replace-container! choke point both emits this trace AND throws the canonical ex-info (per §The thrown-error shape). Per 006 §make-derived-value :no-recovery — the call throws; the adapter replace-container! is not invoked. Write to the source container(s) instead :reason; thrown ex-data also carries :rf.error/id, :where ('rf/replace-container!)
:rf.error/no-hiccup-emitter-bound :error diagnostic render-to-string was called before the SSR namespace bound the hiccup emitter via set-hiccup-emitter! (per 011) :no-recovery — the call throws; SSR namespace must be required so set-hiccup-emitter! runs :reason, :render-tree
:rf.error/ssr-streaming-unsupported-opt :error diagnostic stream-handler was constructed with a non-nil :html-shell opt. Streaming flushes its envelope as a split prefix/suffix straddling the continuation chunks, so a one-piece shell callback can never run after streaming has started — the streaming handler fails closed at construction time rather than silently dropping the opt (a fail-OPEN gap, since a custom shell commonly carries CSP nonces, asset URLs, or root markup). An absent or explicit-nil :html-shell constructs cleanly. Per 011 §Streaming does NOT accept :html-shell. Surfaced as a thrown ex-info, not a trace :no-recovery — handler construction throws; the caller uses the split-envelope shell-hook opts (:head / :body-end / :script-src / :app-element-id) under streaming, or the non-streaming ssr-handler for a one-piece shell :opt-key (:html-shell), :got (the offending value), :recovery
:rf.error/frame-context-corrupted :error diagnostic A function-component frame-id read (_currentValue on the shared React context) observed a value coerce-context-value cannot resolve to a frame keyword AND is not the no-provider sentinel — false, a number, an empty string, or a JS object. Real-world triggers: a subtree rendered through an unwrapped portal, a Provider authored with a non-keyword :value, or a library mutating _currentValue externally. The runtime emits the diagnostic and returns nil (NOT a synthesised :rf/default — per the carried invariant, 002 §Frame target resolution); a subsequent public frame-scoped operation reading that nil reports the always-on :rf.error/no-frame-context. Emitted by re-frame.adapter.context/function-component-current-frame (adapter/context.cljs). Per 006 §Frame-provider via React context and 002 §React context reader :no-frame-context — the corrupted context is reported as its own distinct category and the resolution chain returns nil (it is NOT folded into ordinary 'no scope', and NOT replaced with a synthesised :rf/default); the public frame-scoped op then fails loudly with :rf.error/no-frame-context :received (the offending value), :type (a short keyword tag — :nil / :boolean / :number / :string / :empty-string / :keyword / :symbol / :map / :vector / :sequential / :collection / :fn / :js-object), :recovery (:no-frame-context), :reason
:rf.error/flow-cycle :error diagnostic A flow registration introduced a cycle in the flow-dependency graph (per 013 §Topological ordering). Registration is rejected :no-recovery — flow registration is rejected :cycle (the offending flow ids)
:rf.error/flow-missing-id :error diagnostic A reg-flow call's flow map omitted :id (per 013) :no-recovery — flow registration is rejected :flow (the offending map)
:rf.error/flow-bad-id :error diagnostic A reg-flow call's flow :id was present but not a keyword (the public FlowMeta schema requires [:id :keyword] per Spec-Schemas §FlowMeta; the :flow-id trace/error slot carries it unchanged, so a non-keyword id leaks an arbitrary shape downstream) :no-recovery — flow registration is rejected :flow, :reason
:rf.error/flow-bad-inputs :error diagnostic A reg-flow call's flow :inputs was not a vector of paths (per 013) :no-recovery — flow registration is rejected :flow, :reason, :bad-entries? (vector of the offending entries when at least one entry was malformed — the entries that were not a non-empty vector of scalar path-keys; omitted when :inputs itself was not a vector)
:rf.error/flow-bad-output :error diagnostic A reg-flow call's flow :output was not a fn (per 013) :no-recovery — flow registration is rejected :flow, :reason
:rf.error/flow-bad-path :error diagnostic A reg-flow call's flow :path was not a vector (per 013) :no-recovery — flow registration is rejected :flow, :reason, :bad-elements? (vector of the offending path elements when the failure mode was non-scalar elements — values that were not a keyword / string / integer / symbol / boolean; omitted when :path itself was not a vector or was empty)
:rf.error/flow-reserved-output-path :error diagnostic A reg-flow call's flow :output-path was well-formed (a non-empty vector of valid segments) but rooted at the reserved runtime-db partition key :rf.db/runtime. That leading key is reserved for the INPUT side (a runtime-input? flow input opts into the runtime-db partition); a flow OUTPUT is always an app-db write (evaluate-flow! assoc-ins the derived value into the app-db partition), so a :rf.db/runtime-rooted output would write the reserved partition key INSIDE app-db — a namespace-squat / spurious-topo-edge / false-cycle footgun. Fires AFTER the :rf.error/flow-bad-path shape rules confirm a non-empty vector of valid segments, so it is a DISTINCT discriminator from that shape family (the path is well-formed; it just names a reserved partition). The reservation is positional — :rf.db/runtime is legal DEEPER in an :output-path, where it names an ordinary app-db key. Per 013 :fix-registration — flow registration is rejected; re-root the :output-path at an ordinary (non-:rf.db/runtime) app-db key :flow, :reason, :bad-elements (the offending leading segment, [:rf.db/runtime])
:rf.error/flow-bad-marks :error diagnostic A reg-flow call's flow carried a malformed output data-classification key — a non-vector :sensitive / :large, or a subpath entry within one that is not a vector of path segments (per 013 / 015 §Registration-owned transient classification). A flow's :sensitive / :large classify the flow's OWN output subpaths only; a flow does not inherit its inputs' classification, and there is no :rf.egress/output-sensitivity declassification key or enum (a sensitive flow output is just a classified db path). Flow output classification is a fail-closed safety surface — a malformed declaration is rejected at registration before any flow state mutates, never silently dropped. Distinct from the registration / commit-plane surface's :rf.error/bad-classification (reg-* classification metadata) and :rf.error/classification-effect-shape (the commit-plane effects) so consumers route the faults apart :no-recovery — flow registration is rejected :flow, :reason, :bad-key (the offending classification key), and one of :bad-value / :bad-entries (the offending value or subpath entries)
:rf.error/flows-artefact-missing :error diagnostic A flow API (reg-flow, clear-flow, the flow fxs) was called but the optional day8/re-frame2-flows artefact is not on the classpath. Per MIGRATION §M-31 artefact splits. Surfaced as a thrown ex-info, not a trace :no-recovery — the call throws an ex-info; user adds day8/re-frame2-flows to deps :where (the calling fn), :reason
:rf.error/ssr-artefact-missing :error diagnostic An SSR API (render-to-string, render-tree-hash, reg-error-projector, project-error) was called but the optional day8/re-frame2-ssr artefact is not on the classpath. Surfaced as a thrown ex-info, not a trace :no-recovery — the call throws an ex-info; user adds day8/re-frame2-ssr to deps :where (the calling fn), :reason
:rf.error/routing-artefact-missing :error diagnostic A routing façade export (reg-route, route-link) or another re-frame.core-routing late-bind wrapper was called — OR a frame config declared :url-strategy at construction (rf2-ktmto9: frame construction fails loud when the :routing/preflight-frame-config! hook is unpublished, rather than storing a strategy nobody can validate or execute; a :url-bound?-only config without the key remains registrable before routing loads) — but the optional day8/re-frame2-routing artefact is not on the classpath / not yet required. Surfaced as a thrown ex-info, not a trace :no-recovery — the call throws an ex-info; user adds day8/re-frame2-routing to deps and requires re-frame.routing at app boot (before frame construction, for the :url-strategy case) :where (the calling fn), :reason, :frame (the declaring frame id; present on the frame-config preflight case)
:rf.error/schemas-artefact-missing :error diagnostic A schemas API (reg-app-schema) was called but the optional day8/re-frame2-schemas artefact is not on the classpath. Surfaced as a thrown ex-info, not a trace :no-recovery — the call throws an ex-info; user adds day8/re-frame2-schemas to deps :where (the calling fn), :path, :reason
:rf.error/machines-artefact-missing :error diagnostic A machines API (reg-machine, reg-machine*) was called but the optional day8/re-frame2-machines artefact is not on the classpath. Surfaced as a thrown ex-info, not a trace :no-recovery — the call throws an ex-info; user adds day8/re-frame2-machines to deps :where (the calling fn), :machine-id, :reason
:rf.error/resources-artefact-missing :error diagnostic A resources API (reg-resource / clear-resource / resource-meta / resource-state / resources / install-revalidation-listeners!, or any mutation surface — reg-mutation / clear-mutation / mutation-meta / mutation-state / mutations) was called but the optional day8/re-frame2-resources artefact (per 016 §Implementation status) is not on the classpath. re-frame.core MUST NOT :require the artefact; the public surface is published through the late-bind table, so an absent artefact raises rather than silently no-opping. Surfaced as a thrown ex-info, not a trace :no-recovery — the call throws an ex-info; user adds day8/re-frame2-resources to deps :where (the calling fn), :resource-id / :mutation-id / :frame-id (the carried call context, per surface), :reason
:rf.error/http-artefact-missing :error diagnostic A managed-HTTP API was called but the optional day8/re-frame2-http artefact (per 014 §Implementation status) is not on the classpath. Surfaced as a thrown ex-info, not a trace :no-recovery — the call throws an ex-info; user adds day8/re-frame2-http to deps :where (the calling fn), :reason
:rf.error/epoch-artefact-missing :error diagnostic a dev-only pair-tool injection write surface (replace-frame-state!, per Tool-Pair §Pair-tool writes) was called but the optional day8/re-frame2-epoch artefact is not on the classpath. The wrapper cannot degrade silently — its caller's invariant is "undo works after this call" — so it raises rather than returning a sentinel like the other epoch surfaces (epoch-history / restore-epoch! / register-epoch-listener! / projected-record / projected-history, which return [] / false / nil). Surfaced as a thrown ex-info, not a trace :no-recovery — the call throws an ex-info; user adds day8/re-frame2-epoch to deps :where (the calling fn), :reason
:rf.error/poll-until-timeout :error diagnostic re-frame.test-support/poll-until exhausted its bounded deadline (:timeout-ms, default 2000) before (pred) returned truthy. The single normative discriminator for the throw — test code pattern-matches on (:rf.error/id (ex-data e)), never a boolean marker key. JVM throws synchronously; CLJS rejects the returned js/Promise with the same ex-info-shape error. A test-support surface (not a production runtime path), so it never reaches the always-on error-emit listener. Surfaced as a thrown ex-info, not a trace. Per 008 §poll-until :no-recovery — the deadline elapsed; the throw propagates (JVM) / rejects the promise (CLJS) :where ('rf/poll-until), :elapsed-ms, :label, :reason
:rf.warning/route-shadowed-by-equal-score :warning diagnostic A reg-route registered a pattern whose structural :rf/route-rank tuple (rules 1-5) equals an already-registered pattern's and the two patterns are co-matchable — some URL matches both (the rule-6 "same URL family", decided by language intersection over the patterns' segment automata; equal rank alone MUST NOT warn — rank tuples ignore literal text, so /x/:id and /y/:slug tie structurally yet never co-match, rf2-6gzobp). The EARLIER registration wins the rule-6 tiebreak at match time (stable sort), so the NEW route is the shadowed one (per Spec-Schemas §:rf/route-rank and 012 §Route ranking algorithm) :warned-and-replaced — the new route registers but the existing equal-score winner beats it wherever both match :route-id (the new, shadowed id), :shadowed-by (the existing winner it can never beat), :rank (the tied rules-1-5 structural tuple)
:rf.warning/no-not-found-route :warning diagnostic An unmatched URL arrived but no :rf.route/not-found route was registered. The runtime falls back to a built-in placeholder view (a minimal <h1>Not Found</h1> page) so the request still produces a response. Per 012 §Route-not-found :warned-and-replaced — falls back to the built-in placeholder; the warning surfaces the missing registration :url, :frame, :reason
:rf.warning/route-classification-query-key-unpromoted :warning diagnostic A reg-route declared a :sensitive / :large [:query k] classification path for a query key k the route does NOT promote to a keyword via :query / :query-defaults. An unpromoted query key stays a STRING in the route slice, so the keyword classification path never matches it and the value SILENTLY ships raw at egress (fail-open — the hygiene bargain, not a security boundary). An authoring footgun, not a contract break, so it WARNS, never throws. A no-op when the route declares no classification, names no [:query k] path, or promotes every classified query key. Emitted by re-frame.routing.classification/advise-query-promotion! (routing/classification.cljc). Per 012 §Route data classification :warned-and-recovered — the route still registers; the advisory names the unpromoted key(s) and the fix (add the key to the route's :query schema so the slice carries the keyword key the path targets) :route-id, :query-keys (the classified-but-unpromoted query keys), :promoted-keys (the route's declared query vocabulary), :advice (the fix sentence)
:rf.http/cljs-only-key-ignored-on-jvm :warning diagnostic A managed-HTTP request supplied a CLJS-only key — one of the six the JVM transport cannot honour (:mode, :cache, :referrer, :integrity, :credentials on the :request map, plus the top-level :abort-signal) — that is silently no-op on the JVM. One trace fires per occurrence. Per 014 §JVM transport — degraded behaviour for CLJS-only options :ignored — the unsupported key is dropped; the request proceeds with the remaining keys :key, :url, :sensitive? (the URL is redacted on the trace surface when the request is sensitive — per 014 §Privacy)
:rf.http/binary-decode-degraded-on-jvm :warning diagnostic An explicit binary :decode:blob, :array-buffer, or :form-data — is HONOURED on the JVM (jvm-fetch reads ofByteArray and rides the raw bytes), but the returned value is a byte[], NOT the native browser Blob / ArrayBuffer / FormData object the CLJS Fetch path yields. That host-shape difference is a degradation worth surfacing: a caller asking for a Blob gets bytes and would otherwise never learn. One trace fires per occurrence. Distinct from :rf.http/cljs-only-key-ignored-on-jvm — the decode is honoured, not ignored. Only an EXPLICIT binary :decode is flagged (:auto resolves to :blob from the response Content-Type, unknown at dispatch time). Per 014 §JVM transport — degraded behaviour for CLJS-only options :ignored — informational; the decode proceeds and yields a byte[], the request completes normally :decode (the requested binary decoder), :url, :sensitive? (the URL is redacted on the trace surface when the request is sensitive — per 014 §Privacy)
:rf.http/retry-attempt :info diagnostic A managed-HTTP retry-lifecycle marker, emitted in two arms discriminated by :next-backoff-ms. Intermediate (:next-backoff-ms non-nil) — an attempt failed with a retryable category and the runtime is ACTUALLY retrying: the trace fires when the next attempt starts, not merely when a retry is scheduled, so a request cancelled during its backoff window never produces one. Terminal retry-sequence stop marker (:next-backoff-ms nil) — the retry sequence has ended and nothing further is scheduled. It is not always "exhaustion": it fires in either of two cases — the retry budget was spent (the last permitted attempt also failed with a retryable category), or a later attempt (2+) failed with a category outside :retry :on, so the sequence stopped before the budget was spent (it is NOT emitted for a non-retried, non-eligible terminal failure). Per 014 §Retry and backoff :retried (intermediate — the runtime actually retried; emitted when the next attempt starts, :next-backoff-ms non-nil, so it is never stamped on a retry cancelled in its backoff window; the consumer sees the trace and the eventual final outcome via :on-failure / :on-success) / :no-recovery (terminal stop marker — the retry sequence ended (budget spent, or a later failure fell outside :retry :on), :next-backoff-ms nil, no further attempt occurs; the honest disposition, since stamping :retried on the marker would claim a retry that never ran) :request-id, :url, :attempt, :max-attempts, :failure (a :rf.http/* failure-category map), :next-backoff-ms (non-nil on the intermediate arm, nil on the terminal stop marker)
:rf.http/aborted-on-actor-destroy :info diagnostic A managed-HTTP request was aborted because the spawned state-machine actor that issued it was destroyed (parent state exit, parent's :after firing, :spawn-all cancel-on-decision, frame destroy, or imperative [:rf.machine/destroy]). The reply lands as a standard :rf.http/aborted failure with :reason :actor-destroyed. Per 014 §Abort on actor destroy and 005 §Cancellation cascade — in-flight :rf.http/managed aborts n/a — informational lifecycle trace :request-id (when set), :actor-id (the destroyed spawned-actor address), :url
:rf.http/aborted :error diagnostic A managed-HTTP request was aborted (:user, :actor-destroyed, :timeout, :request-id-superseded, :epoch-restored, or :frame-destroyed). The abort is surfaced on the dev trace bus (URL redacted when the request is sensitive); the :request-id-superseded reason additionally suppresses the failure reply (supersede semantics), the :epoch-restored reason suppresses it (epoch restore unwound the timeline), the :frame-destroyed reason suppresses it (the request's owning frame was destroyed, so a live cancellation reply into the dead frame is invalid — rf2-j538f7.8), and an :actor-destroyed abort whose reply target is OBSOLETE (it addresses the destroyed actor itself) suppresses the failure reply as a :rf.http/stale-suppressed outcome (see that row), while the other reasons dispatch the failure reply normally. :frame-destroyed is the frame-teardown sibling of :epoch-restored — both are members of re-frame.http.transport's reply-suppressing-abort-reasons set and are emitted by re-frame.http.registry (abort-in-flight-on-frame-destroyed! / abort-in-flight-for-frame!); it catches the PLAIN managed requests a destroyed frame issued (actor-owned work already took the more specific :actor-destroyed path). Distinct from the :rf.http/aborted FAILURE-CATEGORY map that rides :on-failure (this catalogue row is the trace emit). Emitted by re-frame.http.transport/abort! (http/http_transport.cljc). Per 014 §Abort :no-recovery — the request is cancelled; an abort with a still-meaningful target dispatches the failure reply, supersede + epoch-restore + frame-destroy + obsolete-actor-target suppress it :kind (:rf.http/aborted), :request-id, :reason, :actor-id, :url, :recovery
:rf.http/stale-suppressed :info diagnostic A managed-HTTP request's app reply was SUPPRESSED and recorded the uniform reply-envelope way — a canonical :status :stale / :rf.reply/work-status :suppressed row carrying the suppressed attempt's :rf.reply/work-id (and, for supersession, the current/superseding work-id inside the :rf.reply/current correlation gate). Four triggers: (1) SUPERSESSION — a fresh request with the same :request-id replaced this one; carries :rf.reply/stale-reason :rf.http/request-id-superseded, :recovery :superseded-by-fresh-request, and both the carried (superseded) + current (superseding) work-id inside :rf.reply/carried / :rf.reply/current (the two =-distinct via the per-request-id issuance counter); complements the :rf.http/aborted :reason :request-id-superseded trace; emitted by re-frame.http.transport. (2) ACTOR-DESTROY OBSOLETE TARGET — an :actor-destroyed abort whose reply target addresses the destroyed actor itself (the machine-shape wrapper's [self-id [:rf.http/failed]] default) is obsolete; carries :rf.reply/stale-reason :rf.http/actor-destroyed-target-obsolete, :recovery :actor-destroyed-target-obsolete, and a carried work-id with no current successor (:rf.reply/current nil); the :rf.http/aborted trace still fires alongside. (3) EPOCH RESTORE — epoch restore unwound the request's timeline; carries :recovery :suppressed-on-epoch-restore and a carried work-id with no current successor (restore replaces the attempt with nothing); emitted by re-frame.http.registry's abort-in-flight-for-frame!. (4) FRAME DESTROY — the request's owning frame was destroyed (rf2-j538f7.8) and the PLAIN managed request it issued (ordinary event-handler issuance, no actor id) was aborted at the frame-teardown boundary with :reason :frame-destroyed; carries :recovery :suppressed-on-frame-destroy and a carried work-id with no current successor (frame destroy replaces the attempt with nothing); emitted by re-frame.http.registry's abort-in-flight-on-frame-destroyed!. The frame-destroy sibling of the epoch-restore boundary — the two share the identical carried-id-against-nil-current suppression gate (one emit-frame-boundary-stale-trace! walk) but stay discriminable on the trace stream via :recovery (:suppressed-on-frame-destroy vs :suppressed-on-epoch-restore) so tooling never mislabels a destroy as a restore. All four keep the late completion from delivering to the original :rf/reply-to target. Per Conventions §The naming rules the row's work identity rides ONLY under :rf.reply/work-id (the bare :work/id duplicate was dropped — rf2-o6c2jr); the :rf.reply/carried / :rf.reply/current correlation payloads still nest a :work/id as data. Dev-only (interop/debug-enabled?-gated). Per 014 §:request-id (internal), 014 §Abort on actor destroy, Managed-Effects §Stale suppression / §Cancellation, and Managed-Effects §SSR, preload, hydration, and restore :superseded-by-fresh-request (supersession) / :actor-destroyed-target-obsolete (obsolete actor target) / :suppressed-on-epoch-restore (restore) / :suppressed-on-frame-destroy (frame destroy) — no app target runs for the suppressed attempt :rf.reply/work-id (carried), :rf.reply/work-kind (:http), :rf.reply/status (:stale), :rf.reply/work-status (:suppressed), :rf.reply/stale-reason, :rf.reply/carried, :rf.reply/current (nil for actor-destroy / restore / frame-destroy)
:rf.warning/failure-swallowed :warning diagnostic A managed-HTTP request failed but :on-failure nil silenced the reply — the failure was dropped with no handler. Emitted once per process so the dropped failure is observable in dev / tooling rather than vanishing (intentional fire-and-forget can ignore it). Aborts are EXCLUDED — a cancelled request that no longer wants its reply is correct-by-design silence. Dev-only (interop/debug-enabled?-gated). Emitted by re-frame.http.transport (http/http_transport.cljc). Per 014 §Failure handling :no-recovery — the failure was already dropped; supply :on-failure to handle it :url, :failure (the :rf.http/* failure-category map), :reason
:rf.warning/http-header-invalid :warning diagnostic A managed-HTTP request header was rejected by the underlying transport's header builder — the JVM java.net.http builder (.header threw) or the CLJS Fetch Headers.append (which throws a TypeError on a bad name or a CR/LF value). Dev-gated trace naming the offending header key and the cause; URL routed through privacy redaction so a denylisted query param is scrubbed. The offending pair is omitted and the request proceeds with the remaining valid headers (the trace is the alarm, not a request-sinking failure) — so an invalid request header stays on the managed path rather than escaping as :rf.error/fx-handler-exception. Emitted by re-frame.http.transport-jvm (http/http_transport_jvm.cljc) and re-frame.http.transport-cljs (http/http_transport_cljs.cljc). Per 014 §JVM transport :no-recovery — the offending header is not applied; fix the header value :url, :header, :cause, :sensitive? (when the request is sensitive)
:rf.warning/http-malli-absent :warning diagnostic A real :decode schema rode a managed-HTTP request but malli.core is not on the classpath, so the decode/validate delays resolve to nil and validation is SKIPPED — unchecked data flows to :accept. Emitted once per process so the dropped validation is observable rather than silent (Spec 014 §JSON decoder hardening, "no silent fallback"). Emitted by re-frame.http.decode/warn-malli-absent! (http/http_decode.cljc). Per 014 §JSON decoder hardening :warned-and-skipped — schema validation is skipped; the parsed value flows to :accept unchecked. Add the Malli dependency to enable schema-driven decode :reason, :schema
:rf.http.interceptor/registered :info diagnostic A reg-http-interceptor succeeded on a frame's request-side middleware chain. Per 014 §Middleware n/a — informational lifecycle trace :frame, :id
:rf.http.interceptor/cleared :info diagnostic A clear-http-interceptor removed an existing interceptor slot (no trace fires for clear-of-unknown-id). Per 014 §Middleware n/a — informational lifecycle trace :frame, :id
:rf.error/http-interceptor-failed :error diagnostic An HTTP interceptor's :before or :after fn threw. On the request side: the runtime emits this category, then re-throws — re-frame.fx catches the re-throw and emits the cascade-level :rf.error/fx-handler-exception; the request is NOT dispatched. On the response side: the runtime emits this category, then re-throws into the reply-dispatch path; :on-success / :on-failure do not fire. Per 014 §Middleware §Failure mode :no-recovery — the interceptor's throw propagates; the request is not dispatched (request side) or the reply is not delivered (response side) :frame, :interceptor-id, :url, :cause, :phase (:after for response-side throws; absent for :before)
:rf.error/http-bad-interceptor :error diagnostic reg-http-interceptor was called with invalid args — non-keyword positional id, non-map interceptor-map, non-fn :before / :after, missing both :before and :after (a no-op interceptor), or non-keyword :frame (shape iii — (reg-http-interceptor id interceptor-map)). Surfaced as a thrown ex-info from the registration call, not a trace. Per 014 §Middleware :no-recovery — the call throws an ex-info; registration fails :where ('rf/reg-http-interceptor), :received (a map of {:id :interceptor-map}), :reason
:rf.error/http-bad-retry-on :error diagnostic A :rf.http/managed fx was invoked with a :retry :on set that contains a non-retryable or unknown category. The closed retryable set is #{:rf.http/transport :rf.http/cors :rf.http/timeout :rf.http/http-4xx :rf.http/http-5xx}; :rf.http/aborted / :rf.http/decode-failure / :rf.http/accept-failure are explicitly rejected, and any keyword outside :rf.http/* is rejected. Surfaced as a thrown ex-info from the fx-call site, not a trace. Per 014 §Closed-set :retry :on validation :no-recovery — the call throws an ex-info; the request is not dispatched :where (':rf.http/managed), :bad-members (the offending keywords from :on), :retryable-set (the closed set), :reason
:rf.error/http-bad-request :error diagnostic A :rf.http/managed fx was invoked with a request envelope whose required :url was missing / nil / a non-string / a blank string. :url is the only required key in the envelope (014 §Request envelope). Validated AFTER the :before interceptor chain runs (a :before may legitimately set the url), so a base-URL-prefix interceptor is honoured; the throw fires only when no source produced a non-blank url. Surfaced as a thrown ex-info from the fx-call site, not a trace — without it a nil url surfaces downstream as an opaque :rf.http/transport failure. Per 014 §Request envelope :no-recovery — the call throws an ex-info; the request is not dispatched :where (':rf.http/managed), :url (the offending value), :reason
:rf.error/http-schema-non-json-content-type :error diagnostic A 2xx response under a Malli :decode schema declared a present, non-JSON Content-Type (e.g. application/edn, text/plain). The schema decode path is JSON-only (it wires Malli's json-transformer), so a non-JSON-MIME body is a contract mismatch — rejected up-front with this discriminator rather than silently JSON-parsing (and failing) the body, which would have surfaced as a confusing schema-validation failure masking the real MIME-mismatch cause. A nil/absent Content-Type stays JSON-eligible. Thrown by decode-response-body; the transport (handle-response!) classifies it as :rf.http/decode-failure (with :schema-validation-failure? false). Per 014 §Decoding §Schema-driven :no-recovery — the throw classifies as :rf.http/decode-failure; the failure path runs :where ('rf.http/decode-response-body), :content-type (the offending value), :schema, :reason
:rf.route.nav-token/stale-suppressed :error diagnostic An async result arrived carrying a :nav-token that no longer matches the active route's token; the result is silently suppressed. Per 012 §Navigation tokens. (:op-type :error because the suppression is the failure mode the consumer needs to see) :logged-and-skipped — the async reply is suppressed; the active navigation cascade continues unchanged :carried-token, :current-token, :rf.trace/event-id
:rf.frame/drain-interrupted :frame diagnostic Before dequeue, an ordinary drain observed that its exact frame incarnation was claimed for destruction (or already dead/absent). An authored callback already on the stack may return and entered authored :after callbacks may unwind, but its returned framework tail is inert. The claim cuts pre-claim waiting work and the observing check removes claim-to-dead arrivals before invocation. One exact router generation emits at most one report, whose :dropped-count combines claim-time and check-time removals; the private exact-token cleanup cascade is the sole executable exception. Per 002 §Edge cases worth pinning. Lifecycle event, not error-shaped n/a — lifecycle event, not error-shaped. Dropped queued work is silent apart from this lifecycle trace :frame, :dropped-count
:rf.machine.event/unhandled-no-op :rf.machine diagnostic An unknown user event arrived at a machine and no transition matched at any state-node along the active path (nor the root :on / its :* wildcard). The snapshot is unchanged. Benign, not an error — xstate-v5 parity: v5 removed the strict flag, so an unhandled event is ignored. re-frame2 keeps this info-grade observability trace (benign is not invisible) so a debugger reports it; the op-type is the machine-activity family :rf.machine, so it is NOT classified as an issue (no pink wash, no issues ribbon). Reserved-:rf/* exemption: NOT emitted for framework lifecycle traffic whose event-id is reserved-:rf/* — the synthetic creation marker [:rf.machine/start] (cascade-threaded :event placeholder; the eager kick is a pure init that stops before this site), the spawn kick-off [:rf.machine.spawn/spawned], the stories :rf.story.lifecycle/* / :rf.assert/* pings — which are framework init, not unknown user events (creation ran the initial-entry cascade; aligns with xstate's own xstate.init). Labelling only — severity stays benign; gated by transition/unhandled-event-no-op?. For a parallel-region machine it fires exactly once, only when every region declines. Per 005 §Transition resolution. To fail loudly on unknown, use a :* wildcard whose action throws (→ :rf.error/machine-action-exception). Emitted by machines/transition.cljc (flat / compound) + machines/parallel.cljc (parallel-region aggregate). Retires :rf.error/machine-unhandled-event (and the earlier :rf.warning/ spelling) — consciously moved OUT of the error catalogue per Mike's ruling n/a — benign no-op; the snapshot is unchanged :actor-id (the LIVE actor INSTANCE that received the unknown event; :machine-id reserved for the TYPE), :event, :state
:rf.machine/started :rf.machine diagnostic A machine ran its initial-entry cascade — its BIRTH. Emitted at the single creation site (maybe-boot) on BOTH the eager [:machine-id [:rf.machine/start]] kick and the lazy first-real-event path. The :cause tag (:rf.machine.start/cause{:explicit :lazy :spawned}) records how it started. Op-type :rf.machine (activity family, never an issue). Emitted ONLY when initial-entry actually runs — a throwing initial-:entry short-circuits to :rf.error/machine-action-exception instead, and restoration paths (SSR / restore-epoch! / replace-frame-state!) install a present, non-pending snapshot and emit none. Per 005 §The :rf.machine/started trace. Consumer: Xray's epoch panel renders it as a [START] badge n/a — lifecycle/activity trace; the snapshot was installed :machine-id, :frame, :state, :data, :cause
:rf.error/invalid-cofx :error diagnostic A caller-supplied :rf.cofx is structurally malformed at the PUBLIC dispatch boundary — a non-nil, non-map value, or a non-integer :rf/time-ms. The :rf.cofx map is the durable causal token (replay / restore / SSR-hydration fold its :rf/time-ms), so a malformed token would corrupt durable state. The guard is production-reachable — checked in re-frame.router/build-envelope before the clock stamp, NOT gated on interop/debug-enabled? (it fires in :advanced + goog.DEBUG=false production too — a corrupt durable causal token is a production data-integrity bug) — but it is a pure throw-error! that does NOT fan out on the always-on error-emit listener, so its catalogue Channel is diagnostic (the thrown-ex-info-is-diagnostic-channel rule). Per 002 §Recordable coeffects :no-recovery — the dispatch is rejected at the boundary; supply a nil or map :rf.cofx with an integer :rf/time-ms :event-id, :event, :supplied
:rf.error/missing-path-param :error diagnostic A :rf/path template instantiation found no binding for a [:rf.path/param :name] segment — an unbound parameter fails closed rather than producing a partial path. Thrown by re-frame.path; dev-only path validation. Per Conventions §The :rf/path algebra :supply-binding — provide a binding for the named param and retry :param, :bad-path
~~:rf.error/reload-no-such-frame~~ n/a (retired) RETIRED (rf2-lxwpob). The dedicated reload-images! verb is gone — image hot-reload is folded into make-frame re-construction, whose target IS the :id supplied in opts (no separate frame-id-or-value target-resolution step to fail on). Per 002 §Image resolution and composition and EP-0023 §Hot Reload.
:rf.error/frame-no-generation :error diagnostic A frame-targeted registrar query (registrations / handler-meta :frame arity) named a :frame that does not resolve to a live frame carrying a sealed image generation — no default fallback. Thrown by re-frame.core; dev/tooling-time guard. Per 002 §Frame target resolution :target-a-live-frame-id-or-a-direct-frame-object — pass a live image-loaded frame id or frame object :frame, :live-frame-ids
:rf.error/registrar-query-needs-frame :error diagnostic A map-shaped registrar query carried no :frame key — a frameless query map is an error, not a default read. Also covers a non-map value reaching a map-only 1-arg registrar-query arity — e.g. handler-meta's ([arg] …) arity has no positional 1-arg default-store read (unlike registrations, whose 1-arg arity doubles as the keyword read), so a bare (handler-meta :event) hits this same guard rather than crashing (rf2-wa38hs). Thrown by re-frame.core. Per 002 §Frame target resolution :no-recovery — pass {:frame f :kind k …} or use the keyword arity :received-keys (a vector of the map's keys when arg is a map; the raw non-map value otherwise)
:rf.error/invalid-platform :error diagnostic rf/init-platform! received a platform keyword other than :server or :client. Thrown by re-frame.core; idempotent / re-callable boot guard. Per 011 §Effect handling on the server :no-recovery — pass :server or :client :expected, :received
:rf.error/non-edn-identity :error diagnostic An identity value (a path segment, a recordable key) is outside the portable CEDN-1 EDN domain — a host object or otherwise non-portable value — and is rejected rather than silently host-hashed. Thrown by re-frame.identity. Per Conventions §Canonical EDN identity :encode-as-portable-edn — re-express the value as portable EDN data :bad-value, :bad-type
:rf.error/bad-frame-classification :error diagnostic A frame-config data-classification declaration is structurally malformed (a bad key, a non-EDN path segment, a mismatched carrier, a malformed entry). Thrown by re-frame.frame-classification; registration-time validation. Per 002 §Frame target resolution :fix-frame-classification — correct the classification declaration and re-register :frame, :bad-key, :bad-path, :bad-segment, :bad-carrier, :bad-entry, :bad-value
:rf.error/bad-classification :error diagnostic A reg-event / reg-fx / reg-cofx / reg-sub registration's :sensitive / :large classification metadata (or a subsystem reg-machine / reg-resource / reg-mutation / reg-route projection-relative declaration) is malformed — a non-vector axis value, or an entry that is not an :rf/path vector. A typo is a loud error at registration, never a permissive no-op. Distinct from :rf.error/classification-effect-shape (the commit-plane DURABLE-app-db effects, validated pre-commit at dispatch) — this is the registration-time transient / subsystem-declaration validator. Thrown by re-frame.classification/validate-classification! (subsystem variants by the owning artefact, e.g. re-frame.machines.classification/validate-machine-classification! raising :rf.error/invalid-machine-classification). Per 015 §Registration-owned transient classification and 015 §Failure posture :fix-classification — supply a vector of valid :rf/path vectors (e.g. {:sensitive [[:password]]}) :bad-path, :classification-effect-shape, :flow-bad-marks
:rf.error/bad-path :error diagnostic A :rf/path value is structurally malformed — a nil path (the root is the explicit [], never nil), a non-sequential container, or a non-EDN-identity segment. Thrown by re-frame.path (normalize / normalize-concrete); the fail-closed :rf/path-algebra boundary. A pure throw-error! (does not fan out on the error-emit listener), so its catalogue Channel is diagnostic. Per Conventions §The :rf/path algebra :fix-path — supply a sequential vector of EDN-identity segments (the root is []) :bad-path, :bad-segment
:rf.error/unknown-egress-profile :error diagnostic A :rf.egress/profile value passed to project-egress (or the epoch projected-record boundary) is outside the closed six-member profile enum — a typo is a loud error, never a silent fall-through to a permissive walk. Thrown by re-frame.projection (the shared unknown-egress-profile-ex builder both closed-enum guards route through). A pure throw-error!, so its catalogue Channel is diagnostic. Per 015 §The graduation gate :use-a-known-profile — pass one of the closed :rf.egress/* profiles :profile, :valid
:rf.error/adapter-disposed :error diagnostic A runtime substrate op ran after terminal teardown of the exact installed adapter generation was claimed (destroy-adapter!) — including from a cleanup callback while that generation is still draining. Distinct from never-installed (:rf.error/no-adapter-installed). Thrown by re-frame.substrate.adapter's require-adapter! and by re-frame.ui.client root-creation admission. Per 006 §The adapter API contract :install-a-fresh-adapter — after teardown settles, install a fresh adapter with (rf/init! adapter) before using the runtime or creating a public compiled Root :recovery
:rf.error/adapter-teardown-in-flight :error diagnostic A public compiled Root creation was admitted under a FRESH adapter generation while a PREDECESSOR generation's Root teardown was still in flight — destroy-adapter! returned synchronously but react-dom DEFERRED an in-render unmount, so the predecessor's host cleanup authority has not settled. Distinct from :rf.error/adapter-disposed (no fresh adapter is installed): here a successor IS installed but must not become usable until the predecessor settles, else a deferred predecessor cleanup could mutate a same-id frame the new Root reseats (rf2-9pyles). Thrown by re-frame.ui.client root-creation admission. Per 006 §Successor-generation settlement fence :retry-after-teardown-settles — retry the mount/create-root once the predecessor deferred teardown settles; the exact id/container is reusable then :recovery
:rf.error/no-adapter-installed :error diagnostic A runtime substrate op ran BEFORE any adapter was installed (rf/init! not yet called) — distinct from disposed (:rf.error/adapter-disposed). Thrown by re-frame.substrate.adapter's require-adapter!. Per 006 §The adapter API contract :no-recovery — call (rf/init! …) to install an adapter first (none)
:rf.error/feature-not-loaded :error diagnostic An optional-feature surface was used but its implementation artefact is not on the classpath. Thrown by re-frame.features; the ex-info carries the exact Maven coordinate + require ns to add. Per API §Feature inspection :no-recovery — add the named Maven coordinate and require its namespace at boot :feature, :maven, :require-ns
:rf.error/unknown-feature :error diagnostic A feature keyword passed to the feature-inspection surface is not in the known optional-feature registry (a typo or a non-feature keyword). Thrown by re-frame.features; lists the known features. Per API §Feature inspection :no-recovery — pass a known feature keyword :feature, :known
:rf.error/defwrapper-bad-args :error diagnostic A defwrapper macro call's second argument is neither a docstring nor an attr-map. Thrown at macroexpansion from re-frame.core-artefact; the optional-artefact wrapper-authoring path. Per Conventions §Optional-artefact wrapper convention :fix-registration — pass a docstring or attr-map (or omit the second arg) :got
:rf.error/defreg-macro-bad-delegate :error diagnostic A defreg-macro call cannot resolve its delegate symbol in re-frame.core (a typo'd or non-existent reg-fn name). Thrown at macroexpansion from re-frame.core-reg-macros. Per Conventions §Optional-artefact wrapper convention :fix-registration — name an existing re-frame.core reg-fn as the delegate :sym
:rf.error/reg-view-bad-args :error diagnostic reg-view's second argument was not an args vector — the defn-shape (reg-view sym [args] body) was violated. Thrown at macroexpansion from re-frame.core-reg-view-macro; names reg-view* for runtime registration. Per 002 §with-frame and with-new-frame :fix-registration — pass an args vector, or use (reg-view* :id render-fn) for runtime registration :sym, :got, :args-after-sym
:rf.error/with-frame-vector-form :error diagnostic with-frame (the pin-to-existing-frame form) was given a VECTOR argument — the caller meant with-new-frame (which evals, binds, runs, and destroys). Thrown at macroexpansion from re-frame.core-reg-view-macro. Per 002 §with-frame and with-new-frame :use-with-new-frame — use with-new-frame [sym expr] for the eval/bind/destroy form :got
:rf.error/with-new-frame-keyword-form :error diagnostic with-new-frame (the eval/bind/destroy form) was given a KEYWORD argument — the caller meant with-frame (which pins to an existing frame-id). Thrown at macroexpansion from re-frame.core-reg-view-macro. Per 002 §with-frame and with-new-frame :use-with-frame — use with-frame :keyword to pin to an existing frame :got
:rf.error/with-new-frame-bad-binding :error diagnostic with-new-frame's binding was neither a 2-element [sym expr] vector nor a keyword — a wrong-arity vector or a non-vector value (almost certainly a typo of the binding form). Thrown at macroexpansion from re-frame.core-reg-view-macro. Per 002 §with-frame and with-new-frame :fix-registration — use a 2-element [sym expr] binding vector :got
:rf.error/invoke-handler-bad-node :error diagnostic The rf/invoke-handler test helper received a non-vector node — it expects a hiccup vector. Thrown by re-frame.test-helpers; a public test-support surface (dev/test-only). Per 008 §Pattern 5 — single-frame e2e fixture :no-recovery — pass a hiccup vector node :node, :event-key
:rf.error/invoke-handler-missing :error diagnostic The rf/invoke-handler test helper found no handler fn at the given event key on the supplied node. Thrown by re-frame.test-helpers; a public test-support surface (dev/test-only). Per 008 §Pattern 5 — single-frame e2e fixture :no-recovery — supply a node carrying a handler at the named event key :node, :event-key
:rf.error/flow-path-overlap :error diagnostic Two flows in the same frame have overlapping output :paths (one a prefix of the other, identical included). Their relative evaluation order is undefined (the topo-sort dependency rule never compares :path against :path), so the shared slot would be written last-write-wins in map-iteration order. Rejected at reg-flow. Thrown by re-frame.flows.topo; registration-time. Per 013 §Topological sort and cycle detection :fix-registration — give each flow a disjoint :path :overlap (the offending {:flow-ids [a b] :paths [pa pb]})
:rf.error/flow-frame-not-live :error diagnostic A reg-flow targeted a frame that is not live (absent / never registered, or torn down by destroy-frame!). Registering against a dead frame would resurrect per-frame flow state and break the frame-destroy isolation contract, so the MUTATING registration path rejects (clear-flow keeps its idempotent absent-frame no-op). Thrown by re-frame.flows.registry; registration-time. Per 013 §Frame-destroy teardown :fix-registration — register the flow against a live frame :frame, :flow
:rf.error/invalid-flow-metadata :error diagnostic A reg-flow (3-slot grammar (reg-flow flow-id metadata derive-fn), rf2-bqstzr) was given a MIDDLE metadata slot that is not a map, or a :derive was left INSIDE the metadata map (the pure derivation is the THIRD slot — its one home). Both guards run BEFORE the flow-map reconstruction so a malformed call fails loudly at the authoring boundary naming the flow, rather than leaking a raw host exception. Mirrors reg-machine's :rf.error/invalid-machine-opts and reg-route's :rf.error/route-bad-metadata non-map / mislocated-key guards. Thrown by re-frame.flows.registry; registration-time / dev+prod (a caller bug). Per 013 §The registration shape :fix-registration — the call throws; pass a metadata map with :inputs / :output-path, and put the pure :derive fn in the third slot :id, :value (the rejected non-map metadata or the mislocated :derive)
:rf.error/malformed-json :error diagnostic A decoded JSON payload exceeded the per-call unique-key cap (default-max-decoded-keys, overridable via :rf.http/max-decoded-keys) — a keyword-interning DoS guard. Thrown during json-parse; the transport classifies it as :rf.http/decode-failure (:reason :too-many-keys). Per 014 §Keyword-interning cap :no-recovery — raise :rf.http/max-decoded-keys for this request or review the upstream service :cause, :limit
:rf.error/http-timeout :error diagnostic The per-attempt wall-clock timeout elapsed before the request (headers + body read) completed and was aborted. Emitted by the CLJS Fetch transport's timeout handler; classified as :rf.http/timeout and routed through maybe-retry! (distinct from the wire-level :rf.http/timeout op-type). Per 014 §:timeout-ms security defaults :no-recovery — the request was aborted after timeout; retry per the configured retry policy :elapsed-ms, :limit-ms
:rf.error/http-schema-validation-failed :error diagnostic A decoded 2xx response body failed its Malli :decode schema validation. Thrown during the schema-decode phase in re-frame.http-decode; the transport classifies it as :rf.http/decode-failure. Per 014 §Decoding :no-recovery — the server returned a structurally invalid response; investigate the upstream service :schema, :value
:rf.error/http-interceptor-bad-return :error diagnostic An HTTP :before / :after interceptor returned a non-map value; the threaded ctx must always be a map. Thrown by re-frame.http-middleware; chain-execution validation. Per 014 §Middleware :no-recovery — fix the interceptor to return the transformed ctx map :id, :returned
:rf.error/http-bad-reply-target :error diagnostic A :rf.http/managed request's :reply-to / :on-success / :on-failure reply target was neither an event vector nor nil — it cannot be dispatched. Thrown at dispatch time by re-frame.http-managed's args validation (validate-reply-target!), BEFORE run-attempt! issues the request (rf2-bvw9ut) — so a typo'd bare-keyword target fails fast at the fx-call site rather than being issued and then throwing async in the reply tail (the rf2-ln85eg leak). re-frame.http-encoding's reply-event builder keeps the same check as belt-and-braces for any non-args-map descriptor path. Per 014 §Request envelope / §Reply addressing :no-recovery — make the reply target an event vector (or nil) :key (the offending :reply-to / :on-success / :on-failure slot), :value
:rf.error/http-no-reply-target :error diagnostic A :rf.http/managed fx was invoked with NO reply target — none of :reply-to, :on-success, :on-failure was supplied. The co-located default (the reply merged under :rf/reply back to the originating event id when no target was given) was retired pre-alpha (rf2-et4c1s), so an unaddressed request now fails loud at the dispatch site rather than silently routing the reply back to the dispatching event. Thrown by re-frame.http-managed's args validation (validate-reply-target!); fx-call-time, before run-attempt!. Per 014 §Reply addressing :no-recovery — supply :reply-to (one target for both success and failure) or :on-success / :on-failure (an explicit nil silences a branch) :where (':rf.http/managed), :args-keys (the args-map keys supplied), :reason
:rf.error/http-reply-tail-failed :error diagnostic A managed-HTTP REPLY TAIL threw AFTER the transport already completed — a throwing :after interceptor (also emitted as :rf.error/http-interceptor-failed at the interceptor site), or a malformed reply target the dispatch-time guard did not catch (:rf.error/http-bad-reply-target, belt-and-braces path). Per 014 §Middleware §Failure mode a response-side throw MUST NOT be reclassified as a transport rejection: this boundary catches it and surfaces it once here rather than (a) routing it into the transport-rejection classifier — on CLJS the Fetch .catch fed classify-cljs-error:rf.http/transportmaybe-retry!, re-sending a request whose wire outcome already succeeded (a retry-storm; the retry mints a fresh handle that bypasses the once-only reply guard), or (b) letting it vanish into the unobserved JVM whenComplete future (a silent swallow, the caller hangs). The response-side analogue of the request-side :rf.error/fx-handler-exception boundary. The request is already finalised (the once-only reply CAS was won and the registry cleared before the reply tail ran), so NO retry / re-send fires and no teardown is repeated; the reply itself is not delivered (delivery is what threw). Dev-only (interop/debug-enabled?-gated). Emitted by re-frame.http.transport (http/http_transport.cljc) :no-recovery — the reply could not be delivered; fix the :after interceptor or reply target so it does not throw :url, :kind (:success / :failure — the reply branch whose delivery threw), :reply-error-id (the caught throw's :rf.error/id, e.g. :rf.error/http-interceptor-failed), :cause, :sensitive? (the URL is redacted on the trace surface when the request is sensitive), :recovery, :reason
:rf.error/machine-reserved-meta-in-opts :error diagnostic A reg-machine* opts map carried the framework-owned :rf/machine? / :rf/machine keys — the registration home stamps these automatically. Thrown by re-frame.machines.lifecycle-fx.registration; registration-time. Per 005 §reg-machine — public registration surface :drop-reserved-keys — remove the reserved keys; the registration home stamps them :machine-id, :opts
:rf.error/spawn-timeout-ms-removed :error diagnostic A :spawn / :spawn-all spec carried the unsupported :timeout-ms / :on-timeout slots — a spawn declares no timeout of its own; express a timeout as the state's :after delayed transition (per 005 §Delayed :after transitions). Registration-time validation, thrown ex-info. Emitted by machines/lifecycle_fx/validation.cljc's validate-no-spawn-timeout-ms! :express-the-timeout-as-after — remove the keys; declare the timeout as an :after transition on the awaiting state :machine-id, :state, :keys
:rf.error/invalid-machine-opts :error diagnostic A reg-machine / reg-machine* 3-arity was given a MIDDLE opts slot that is not a registration-metadata map (a vector, string, number, … — a non-map in the middle opts slot: (reg-machine* machine-id {…} machine)). The non-map guard runs BEFORE the reserved-key contains? / assoc so a malformed opts fails loudly at the authoring boundary naming the machine, rather than leaking a raw host IllegalArgumentException ("Key must be integer"). The 2-arity (reg-machine* machine-id machine) has no opts (it normalises to {}). Mirrors reg-route's :rf.error/route-bad-metadata non-map guard and the reg-resource / reg-mutation metadata-slot map gate. Thrown by re-frame.machines.lifecycle-fx.registration; registration-time / dev+prod (a caller bug). Per 005 §reg-machine — public registration surface :fix-registration — the call throws; pass an opts map (or use the 2-arity with no opts) :machine-id, :value (the rejected non-map opts)
:rf.error/machine-schema-requires-reg-machine :error diagnostic A machine spec carrying a [:schemas :data] schema was passed to make-machine-handler OUTSIDE the registration home — the schema would validate nothing and :sensitive? slots would egress raw. Thrown by re-frame.machines.lifecycle-fx.registration; must register via reg-machine / reg-machine*. Per 005 §reg-machine — public registration surface :use-reg-machine — register the machine through reg-machine / reg-machine* :schemas
:rf.error/invalid-machine-classification :error diagnostic A reg-machine spec declared a malformed projection-relative :sensitive / :large data-classification — a non-vector axis, a non-path entry, or an invalid :rf/path segment. A machine declares :sensitive / :large as a vector of snapshot-relative :data-rooted :rf/path vectors (e.g. {:sensitive [[:data :token]]}), lowered per actor instance at spawn into the per-frame elision registry. Fail-loud-input at registration (the hygiene helper declared wrong is an author bug). Thrown by re-frame.machines.classification; registration-time / dev+prod. Per 005 §Privacy — redacting machine :data at trace egress and 015 §Machine-owned durable classification :fix-registration — declare :sensitive / :large as a vector of :data-rooted path vectors :machine-id, :axis, :value
:rf.error/mutation-invalid-invalidation :error diagnostic A mutation :invalidates arm returned a malformed descriptor (not a map, collection of maps, or tag-set). Thrown by re-frame.resources.mutation-runtime; validation at the invalidation boundary. Per 016 §Scoped invalidation descriptors (per-target) :fix-invalidates — return a valid invalidation descriptor (map / collection of maps / tag-set) :arm, :invalidates
:rf.error/mutation-invalid-target :error diagnostic A mutation patch/populate target carried a reserved-scope typo, a non-serializable scope/params value, or violated the map-form exact-target shape. Thrown by re-frame.resources.mutation-runtime; validation at the mutation boundary. Per 016 §Scoped invalidation descriptors (per-target) :fix-mutation-target — correct the target's scope / params / shape :arm, :target
:rf.error/mutation-non-serializable-instance-id :error diagnostic A mutation instance id is not serializable EDN — a host/opaque value (function, promise, date, DOM node, AbortController, JS object) or a non-portable number — rejected at the durable boundary. Thrown by re-frame.resources.mutation-runtime. Per 016 §Resource identity :fix-instance-id — use a serializable EDN instance id :instance-id
:rf.error/resource-invalid-scope :error diagnostic A resource scope is a reserved :rf.scope/* typo, a wrapped reserved scope (e.g. the singleton [:rf.scope/global]), or a non-serializable/host value. Thrown by re-frame.resources.state's canonicalize-scope. Per 016 §Scope resolution :fix-scope — use a valid serializable scope value :resource-id, :scope
:rf.error/resource-cross-scope-cause-required :error diagnostic A :cross-scope? true :rf.resource/invalidate-tags dispatch carried no :cause evidence — cross-scope is the audited escape that can stale/refetch data across every user/tenant/frame, so it requires a recorded cause. Thrown by re-frame.resources.events. Per 016 §The cross-scope lattice — three precise rungs :fix-cause — supply a :cause for the cross-scope invalidation :tags
:rf.error/resource-cross-scope-scope-conflict :error diagnostic A :cross-scope? true :rf.resource/invalidate-tags dispatch ALSO carried a :scope — cross-scope is scope-AGNOSTIC (it matches the tags in every scope), so a :scope alongside it is a contradiction, rejected loudly rather than resolve-then-ignore (rf2-oo8cv7 closed union). Thrown by re-frame.resources.events. Per 016 §The cross-scope lattice — three precise rungs :fix-scope — drop the :scope for a cross-scope sweep, or drop :cross-scope? true to invalidate one resolved scope :tags, :scope
:rf.error/resource-invalidate-scope-required :error diagnostic A scoped (default) :rf.resource/invalidate-tags dispatch lacked an explicit :scope — a missing scope would silently match nothing or the wrong nil-scope set; cross-scope is the only scope-agnostic path. Thrown by re-frame.resources.events. Per 016 §Invalidation :fix-scope — supply an explicit :scope (or pass :cross-scope? true with a :cause) :tags
:rf.error/invalid-route-pattern :error diagnostic A route :path pattern violated the Spec 012 grammar — a missing leading /, an empty segment, an invalid param/splat name, a reserved char not percent-encoded, a malformed optional group, or multiple splats. Thrown by re-frame.routing.match at reg-route on the first violation. Per 012 §Path-pattern grammar (canonical) :no-recovery — fix the route pattern to satisfy the grammar :route-id, :pattern, :index
:rf.error/cookie-invalid-attribute :error diagnostic An SSR cookie attribute value contains a forbidden char. For the attributes concatenated VERBATIM into the Set-Cookie line (:path/:domain/:max-age/:same-site/:expires) the rejected set is CR/LF/NUL (RFC 7230 §3.2.4 header-splitting) PLUS the raw ; cookie-attribute delimiter (RFC 6265 §4.1.1) — a ; inside such a value escapes its assigned attribute and fabricates extra ones (SameSite=None, Secure, …). Cookie :value is delimiter-tolerant: it is percent-encoded downstream (;%3B), so it is gated on CR/LF/NUL ONLY. The offending attribute rides the :attribute payload slot (the injection class is one fact; which attribute carried the char is data). Thrown at BOTH boundaries from one shared grammar (re-frame.ssr.http-validation): the re-frame.ssr.response fx boundary (gating every serialised cookie attribute — a CR/LF/NUL-bearing :value, and a CR/LF/NUL- or ;-bearing :path/:domain/:max-age/:same-site/:expires) and the re-frame.ssr.ring.cookie serialiser at wire-write time. Per 011 §CRLF fail-fast on header values :remove-injection-chars-from-cookie-attr — strip CR/LF/NUL (and, for a verbatim-serialised attribute, ;) from the attribute value :attribute, :value
:rf.error/cookie-invalid-expires :error diagnostic An SSR cookie :expires is not an epoch-millis long (a string-shaped epoch or a java.util.Date was supplied). Thrown by re-frame.ssr.ring.cookie's serialiser. Per 011 §Payload scope (canonical boundary) :supply-epoch-millis-long — pass :expires as a long count of epoch milliseconds :expires, :cookie
:rf.error/cookie-invalid-name :error diagnostic An SSR cookie :name violates the RFC 6265 §4.1.1 token grammar (no CTLs, whitespace, or separators). Thrown by re-frame.ssr.ring.cookie and the re-frame.ssr.response fx boundary (one shared predicate). Per 011 §Payload scope (canonical boundary) :use-a-token-grammar-cookie-name — use a token-grammar cookie name :name
:rf.error/cookie-missing-name :error diagnostic An SSR cookie map carries no :name key (required). Thrown by re-frame.ssr.ring.cookie's serialiser. Per 011 §Payload scope (canonical boundary) :supply-a-cookie-name — add a :name key to the cookie map :cookie
:rf.error/header-invalid-name :error diagnostic An SSR response header :name violates the RFC 7230 §3.2.6 token grammar (no CTLs, whitespace, or separators). Thrown by re-frame.ssr.response's fx boundary. Per 011 §Payload scope (canonical boundary) :use-a-token-grammar-header-name — use a token-grammar header name :header
:rf.error/header-invalid-value :error diagnostic An SSR response header value contains CR/LF/NUL — forbidden by RFC 7230 §3.2.4 (header-splitting injection). Thrown by re-frame.ssr.response's fx boundary. Per 011 §Payload scope (canonical boundary) :remove-injection-chars-from-header-value — strip CR/LF/NUL from the header value :header, :value
:rf.error/server-fx-args-invalid :error diagnostic An argument to one of the seven reserved :rf.server/* response fx violates its published TYPE contract (011 §Standard fx) — a :status that is not an integer in 100–599, a non-string header :name / :value, a cookie attribute of the wrong type or a missing required :value, a non-string redirect :location, a non-boolean :relative-only?, a non-sequential :allow, or an args value that is not a map at all. This gate is UNCONDITIONAL — it runs in a -Dre-frame.debug=false build too. The general Spec 010 §Validation order step-5 fx-args boundary is dev-posture (validate-fx! is (if interop/debug-enabled? … true), read once at load time), which is correct for USER fx under trust-the-programmer but left the framework's OWN wire-adjacent contract unguarded: a malformed reserved fx ran and its args landed on the per-request response accumulator that ssr/get-response publishes to every host adapter. So the closed reserved family guards its own args in every build (rf2-dtpfv ruling (b)), and ssr/get-response never yields a malformed :rf/response shape whatever the posture. The SHAPE half of the same ownership pattern the sibling wire-grammar guards in this file already carry (:rf.error/header-invalid-name / -value, :rf.error/cookie-invalid-name / -attribute, :rf.error/redirect-invalid-location). ONE category for the whole shape surface, with the offending :key carried as data — the same decision :rf.error/cookie-invalid-attribute records for the injection surface. Thrown ex-info from re-frame.ssr.response's fx boundary before the first swap-response!; the registered-fx containment in re-frame.fx then skips the fx, runs its siblings, and fans an always-on :rf.error/fx-handler-exception that SSR projects to a sanitised 500. On CLJS the fx are no-op'd by :rf.fx/skipped-on-platform (server-only), so this is a JVM-only gate. Per 011 §Standard fx :supply-a-well-formed-fx-argument — fix the args map at the dispatch site; the message names the key, the expected type, and the shape received :rf.error/id, :where (rf.ssr/response), :reason, :recovery, :fx-id (which reserved fx), :key (which argument), :expected (the published type, as prose), :value (a re-frame.error/diag-value-summary — the EP-0015-safe SHAPE of what arrived, never the value: a cookie :value is a session token. That summary is content-free BY CONSTRUCTION since rf2-210uq — a closed-vocabulary :type plus an integer :count, nothing else — which is what makes it safe to interpolate into the thrown MESSAGE as well as the ex-data. It was not so when this row landed: the then-current :head leg reproduced a token of 24 characters or fewer whole, and a longer one's raw prefix. WHICH argument failed rides the separate trusted :key slot, never a guess made from the value)
:rf.error/invalid-json-ld-key :error diagnostic A JSON-LD object key is nil — JSON object keys must be strings and nil has no key representation. Thrown by re-frame.ssr.head.emit. Per 011 §XSS at output boundaries :supply-a-non-nil-key — give the JSON-LD object a non-nil key (none)
:rf.error/invalid-json-ld-number :error diagnostic A JSON-LD number is non-finite (##Inf / ##-Inf / ##NaN) — JSON has no representation for these. Thrown by re-frame.ssr.head.emit. Per 011 §XSS at output boundaries :supply-a-finite-number — use a finite number :value
:rf.error/invalid-initial-events :error diagnostic An SSR-ring :initial-events is neither an event vector nor a (fn [request] event-vector) (the fn form must return an event vector). Thrown by re-frame.ssr.ring.lifecycle. Per 011 §Server flow (per request) :return-an-initial-events-vector-from-the-fn — make :initial-events an event vector or a request→event fn :returned
:rf.error/invalid-root-view :error diagnostic An SSR-ring :root-view is neither a hiccup vector nor a 0-arity fn returning hiccup. Thrown by re-frame.ssr.ring.lifecycle. Per 011 §Server flow (per request) :supply-a-hiccup-vector-or-0-arity-fn — pass a hiccup vector or 0-arity fn :received
:rf.error/invalid-hiccup-head :error diagnostic A hiccup vector's head has no HTML interpretation. TWO fail-loud ARMS, distinguished by message and :recovery. (a) MALFORMED HEAD — the head is neither a keyword (DOM tag / :<> / :> / :rf/suspense-boundary) nor a callable component (a fn or Var): a string / nil / number / boolean / collection head, whose raw EDN form emitted would bypass output escaping (an XSS-class leak: the prior (str el) fallthrough shipped a malformed-head vector's attacker-controlled child strings raw and unescaped, per rf2-y1jbaq). Fail loud rather than stringify the malformed form to the wire, mirroring :rf.error/invalid-tag-name. (b) UNRECOGNISED RESERVED HEAD (rf2-j81hs) — the head is a keyword in the framework-reserved :rf/* scheme (bare rf namespace or a dotted rf.<area> segment) that is not a head the emitter implements; the recognised reserved heads are :<>, :> and :rf/suspense-boundary. This arm exists because rf2-j81hs made every keyword head a DOM/custom element: without it a misspelt :rf/suspense-boundry passes the [A-Za-z][A-Za-z0-9-]* tag grammar and paints a phantom <suspense-boundry> silently — the exact silent-mis-render that bead removes, displaced by one keystroke. The :rf/* root is framework-owned (Conventions §Reserved namespaces), so no legitimate author element is rejected. Both arms thrown by re-frame.ssr.emit (shared by the sync emitter and the streaming shell walker) during server render. Arm (b) is ALSO thrown CLIENT-side by reagent2.impl.template/parse-tag (rf2-01zvu) — reagent-slim reaches it only on a parse-tag cache MISS, so steady-state rendering pays nothing, and reagent2.dom.server shares the one guard. Being a correctness reject on a runtime DATA branch it carries no goog.DEBUG gate and SURVIVES :advanced + goog.DEBUG=false (pinned by reagent2.impl.template-reserved-head-elision-prod-test under the :browser-test-prod-elision build, which asserts no phantom element is painted — not merely that something threw). The OWNED substrates are the whole client story: stock Reagent is an external dependency whose element dispatch is not ours to extend, and rf2-j81hs accepts that diagnostic asymmetry. re-frame.ui needs no runtime arm — it classifies heads at COMPILE time, where a reserved head is rejected by re-frame.ui.compiler.analyze/parse-tag as the compile-tier :rf.ui.compile/bad-tag (no catalogue row; this axis is the RUNTIME one). Per 011 §XSS at output boundaries and 011 §The head grammar is not Spec 011's to extend arm (a) :use-a-keyword-or-callable-hiccup-head — produce a keyword or callable hiccup head; arm (b) :use-a-recognised-reserved-head-or-an-unreserved-keyword — fix the spelling, or use an unreserved keyword for a custom element :head, :element (both arms)
:rf.error/invalid-tag-name :error diagnostic A hiccup head tag-name does not match the HTML5/SVG/MathML element-name grammar. Thrown by re-frame.ssr.emit during server render. Per 011 §XSS at output boundaries :use-a-valid-element-name — use a valid element name :tag-name, :source
:rf.error/no-such-head :error diagnostic No head was registered under the given id; register it with reg-head before rendering. Thrown by re-frame.ssr.head.registry. Per 011 §Detailed design :register-the-head-id — register the head id with reg-head :head-id
:rf.error/ssr-edn-script-breakout :error diagnostic An EDN script body carries a </ or <! HTML breakout precursor in a non-string token position, which has no readable EDN escape. Thrown by re-frame.ssr.html-helpers. Per 011 §XSS at output boundaries :restructure-the-offending-app-db-value — restructure the offending value so it carries no breakout precursor (none)
:rf.error/ssr-invalid-attribute-name :error diagnostic An attribute name violates the HTML5 attribute-name grammar. Thrown by re-frame.ssr.html-helpers. Per 011 §XSS at output boundaries :rename-the-attribute-key — rename the attribute to a valid name :attribute
:rf.error/ssr-malformed-payload-allowlist :error diagnostic An SSR hydration-payload allowlist is not a non-empty VECTOR of KEYWORD top-level app-db keys (a non-keyword entry is invalid). Thrown by re-frame.ssr.payload-policy. Per 011 §Payload scope (canonical boundary) :declare-payload-policy — supply a vector of keyword top-level keys :got, :bad-entries
:rf.error/ssr-missing-payload-policy :error diagnostic An ssr-handler was created with no explicit hydration-payload policy. Thrown by re-frame.ssr.payload-policy. Per 011 §Payload scope (canonical boundary) :declare-payload-policy — pass :payload [keys] (allowlist) or :payload :rf.ssr.payload/whole-app-db :got
:rf.error/ssr-nonrenderable-component :error diagnostic A callable hiccup component (a fn / Var head) resolved to a fn even after the single Form-2 unwrap (outer fn → inner render fn → still a fn). SSR cannot render a bare fn; stringifying its .toString would leak the fn text (user$…fn__…@…) as visible page content plus a guaranteed hydration mismatch (the prior bare (apply head args) fallthrough, per rf2-dtza9a). A Form-2 component's inner render fn must return hiccup. Thrown by re-frame.ssr.emit during server render. Per 011 §JVM-runnable view rendering :return-hiccup-from-the-component-render-fn — return hiccup from the component's render fn :component
:rf.error/ssr-reagent-native-head :error diagnostic A Reagent-native interop head (:>) cannot be rendered server-side — there is no React on the JVM. Thrown by re-frame.ssr.emit. Per 011 §Source-coord annotation under SSR :wrap-in-reg-view-or-render-client-only — wrap the component in a reg-view or render it client-only :element
:rf.error/ssr-ring-import-fn-unresolved :error diagnostic An SSR-ring import-fn cannot resolve its source var (check the fully-qualified symbol and that its namespace is required). Thrown by re-frame.ssr.ring. Per 011 §Detailed design :correct-the-import-fn-source-symbol — fix the fully-qualified symbol and require its namespace :sym
:rf.error/ssr-ring-invalid-error-view :error diagnostic The ssr-handler's :error-view Ring fn threw or returned a malformed response; the materialiser fell back to the default-on-error response. Thrown/recorded by re-frame.ssr.ring.pipeline. Per 011 §Detailed design :fell-back-to-default-on-error — fix the :error-view to return a valid Ring response :exception, :ex-class
:rf.error/ssr-ring-missing-initial-events :error diagnostic An ssr-handler was created with no :initial-events (an event vector is required). Thrown by re-frame.ssr.ring.lifecycle. Per 011 §Server flow (per request) :supply-the-initial-events-opt — supply :initial-events in the handler opts (none)
:rf.error/ssr-ring-missing-root-view :error diagnostic An ssr-handler was created with no :root-view (a hiccup vector or 0-arity fn is required). Thrown by re-frame.ssr.ring.lifecycle. Per 011 §Server flow (per request) :supply-the-root-view-opt — supply :root-view in the handler opts (none)
:rf.error/ssr-suspense-boundary-outside-stream :error diagnostic A :rf/suspense-boundary (a streaming-only marker recognised by stream-handler) was encountered by render-to-string. Thrown by re-frame.ssr.emit. Per 011 §Streaming SSR :render-via-stream-handler — render trees containing suspense boundaries via stream-handler :element
:rf.error/ssr-trusted-shell-opt-invalid :error diagnostic A trusted shell-hook opt (:head / :body-end / :script-src / :app-element-id) is not a string or nil (a map, vector, or symbol was supplied). Thrown by re-frame.ssr.ring.trust. Per 011 §Trusted shell hook contract :supply-string-or-nil — supply a string (or nil) for the shell-hook opt :opt-key, :got, :got-type
:rf.error/ssr-unknown-payload-policy :error diagnostic An ssr-handler :payload keyword is not :rf.ssr.payload/whole-app-db (the only recognised keyword policy; otherwise pass a vector allowlist). Thrown by re-frame.ssr.payload-policy. Per 011 §Payload scope (canonical boundary) :declare-payload-policy — pass :rf.ssr.payload/whole-app-db or a vector allowlist :got, :recognised
:rf.error/suspense-boundary-invalid-attrs :error diagnostic A :rf/suspense-boundary lacks an attrs map carrying both :id and :fallback as its second element. Thrown by re-frame.ssr.streaming. Per 011 §Streaming SSR :supply-id-and-fallback-attrs — supply an attrs map with :id and :fallback :got, :element
:rf.error/conformance-unknown-before-op :error diagnostic A conformance-corpus :before DSL form carried an unknown op (not :assoc-in-request / :dispatch / :noop). Thrown by re-frame.conformance; a dev-only corpus-DSL validator (the handler-body DSL ops, spec/conformance/README.md). :no-recovery — use a recognised :before op :op, :allowed
:rf.error/conformance-unknown-dsl-op :error diagnostic A conformance-corpus DSL form carried an unknown step op. Thrown by re-frame.conformance; a dev-only corpus-DSL validator (the handler-body DSL ops, spec/conformance/README.md). :no-recovery — use a recognised DSL op :op
:rf.error/conformance-unknown-fn-builtin :error diagnostic A conformance-corpus DSL :fn builtin key is unknown. Thrown by re-frame.conformance; a dev-only corpus-DSL validator (the handler-body DSL ops, spec/conformance/README.md). :no-recovery — use a recognised :fn builtin :builtin
:rf.error/path-removed :error diagnostic rf/path was referenced — REMOVED (no public path-value constructor). A hard error naming the framework-registered factory ref [:rf.interceptor/path <path-vector>] in a handler's :interceptors chain as the replacement. Thrown by re-frame.std-interceptors; it does NOT fan out on the always-on error-emit channel (the loud throw IS the migration alarm — diagnostic-channel, unlike :rf.error/inject-cofx-removed). Catalogued for consistency with the other removed-stub categories. Per 002 §Interceptor references :no-recovery — reference [:rf.interceptor/path <path-vector>] in the handler's :interceptors chain :got
:rf.error/unwrap-removed :error diagnostic rf/unwrap-interceptor was referenced — REMOVED (no framework-standard unwrap value). A hard error naming handler-arglist payload destructuring (or a project-registered :app/unwrap interceptor) as the replacement. Thrown by re-frame.std-interceptors; it does NOT fan out on the always-on error-emit channel (diagnostic-channel, unlike :rf.error/inject-cofx-removed). Catalogued for consistency with the other removed-stub categories. Per 002 §Interceptor references :no-recovery — destructure the [<id> <payload-map>] payload in the handler arglist, or register a project :app/unwrap interceptor (none)
:rf.error/machine-bad-target :error diagnostic A transition :target on a state-node is malformed — not a sibling keyword, a non-empty absolute vector path, or the :same-state sentinel (e.g. {:target 42} or an empty vector). Rejected by the machine-definition target validator before the generic keyword/vector resolve branch, so tools/conformance consumers classify it correctly. Thrown by re-frame.machines.lifecycle-fx.validation. Per 005 §Transitions + Spec-Schemas §TransitionTarget :fix-registration — use a sibling keyword, a non-empty absolute path, or :same-state :state, :slot, :target
:rf.error/machine-unresolved-target :error diagnostic A transition :target is well-shaped but resolves to no declared state — a keyword naming no sibling, or an absolute vector path naming no node. Thrown by re-frame.machines.lifecycle-fx.validation. Per 005 §Transitions + Spec-Schemas §TransitionTarget :fix-registration — target a declared state (or a :type :history pseudo-state) :state, :slot, :target
:rf.error/machine-bad-after-delay :error diagnostic A state-node's :after DELAY KEY is invalid — an :after delay must be a positive integer (literal ms), a non-empty [sub-id & args] subscription vector, or a fn. Distinct from :rf.error/machine-bad-after-spec (the transition VALUE). Thrown by re-frame.machines.lifecycle-fx.validation. Per Spec-Schemas §:rf/state-node :fix-registration — supply a valid :after delay key :state, :slot (:after), :delay-key
:rf.error/machine-bad-after-spec :error diagnostic An :after transition VALUE is not a recognised transition form (target keyword, vector path, candidate map, candidate vector, or nil). Raised by the shared :on/:after/:always value normaliser when it walks the malformed :after value. Thrown by re-frame.machines.transition. Per 005 §Delayed :after transitions :fix-registration — fix the offending :after clause :value, :slot
:rf.error/machine-bad-on-done-clause :error diagnostic An :on-done clause value is not a recognised transition form (the same shared value grammar :on / :after use). Thrown by re-frame.machines.transition. Per 005 §Final states :fix-registration — fix the offending :on-done clause :value, :slot
:rf.error/machine-parallel-bad-shape :error diagnostic A :type :parallel machine violates the parallel-root shape — a missing/empty :regions map, a non-keyword region name, an empty region body, or :initial/:states at the parallel root (mutually exclusive with :regions). Thrown by re-frame.machines.lifecycle-fx.validation. Per 005 §Parallel regions :fix-registration — give the parallel root a non-empty :regions map of keyword → state-node :region (the offending region, when applicable)
:rf.error/machine-parallel-on-done-target :error diagnostic A parallel root's :on-done declares an in-machine :target — a root-only parallel machine has no flat sibling to land on (an accepted target would silently STALL in the all-final configuration). Thrown by re-frame.machines.lifecycle-fx.validation. Per 005 §Final states :fix-registration — drop the :target; express "then continue" as an :action / :fx :on-done
:rf.error/machine-parallel-root-on-bad-target :error diagnostic A root parallel :on / :after transition :target is not region-qualified — each target's head must be a declared region ([<region> & <path>], or a vector of such). A parallel root has no flat sibling to land a bare-keyword / non-region target on. Thrown by re-frame.machines.lifecycle-fx.validation. Per 005 §Transitions :fix-registration — region-qualify the target :target, :regions
:rf.error/invalid-resource-scope-spec :error diagnostic A reg-resource-scope input source descriptor is malformed — not a 2-vector [:db <rf-path>], an unknown source head, or a non-concrete :db path. Thrown by re-frame.resources.scope-registry. Per 016 §SSR and hydration :fix-registration — supply [:db <rf-path>] with a concrete :rf/path :scope-id, :input, :descriptor / :source
:rf.error/resource-scope-source-reserved :error diagnostic A reg-resource-scope input declares the RESERVED :runtime source — named in the input vocabulary but not shipped in this slice. Thrown by re-frame.resources.scope-registry. Per 016 §Restore and replay :fix-registration — use a [:db <rf-path>] source (db-derived identity) :scope-id, :input, :source
:rf.error/resource-scope-not-registered :error diagnostic A {:from-db <scope-id>} reference names a resource-scope resolver that is not registered — fail-closed. Thrown by re-frame.resources.scope-registry. Per 016 §Restore and replay :fix-registration — call reg-resource-scope before referencing the scope :scope-id
:rf.error/resource-scope-unresolved-reference :error diagnostic A scope-requiring operation's {:from-db <scope-id>} named-scope reference resolved NIL against the current db — fail-closed (a scope-requiring site — event ensure / refetch, the direct :rf.resource/invalidate-tags, or a supplied :rf.mutation/execute :scope — never silently falls through to global). Thrown by re-frame.resources.registry / re-frame.resources.events / re-frame.resources.mutation-registry (rf2-oo8cv7, rf2-l11670). Per 016 §Restore and replay :fix-scope — ensure the resolver yields a non-nil scope (fix the referenced db state) :from-db, :where, :resource-id / :mutation-id (optional)
:rf.error/infinite-missing-next-page-param :error diagnostic An :infinite true resource declares no valid :next-page-param:infinite makes :next-page-param a REQUIRED pure fn (last-page → next-param | nil). The R8 registration gate. Thrown by re-frame.resources.registry. Per 016 §Registration :fix-registration — supply a :next-page-param fn :resource-id, :infinite
:rf.error/route-decimal-unsupported :error diagnostic A reg-route :path declares a :double / decimal-typed param/query key — a float has no canonical EDN identity, so it cannot round-trip through match-url / route-url and diverges across the JVM/CLJS hosts. Thrown by re-frame.routing.registry at reg-route. Per 012 §Path-pattern grammar (canonical) :no-recovery — encode the value as a string (parse in a handler) or use an :int-typed key :route-id, :slot, :param, :type-form
:rf.error/route-keyword-unbounded-unsupported :error diagnostic A reg-route :params / :query [:map …] schema declares a BARE / optioned (unbounded) :keyword-typed key — an unbounded :keyword slot cannot round-trip through match-url / route-url: route-url host-stringifies the keyword value (:asc%3Aasc) but match-url keeps the URL segment a string (an unbounded :keyword slot must not intern arbitrary URL input — the rf2-3k3o7 keyword-interning guard), so the URL route-url built fails the SAME route's re-match. A bounded [:enum :a :b …] keyword slot is admitted (its declared choices intern + round-trip via the enum prism). The :double un-round-trippable sibling is :rf.error/route-decimal-unsupported (row above). Thrown by re-frame.routing.registry at reg-route (rf2-qot6ii); registration-time / dev+prod (a caller bug, not user input). Per 012 §Query strings and fragments (the bare-:keyword rejection rule) and 012 §Keyword-interning cap on query keys + values :no-recovery — the registration throws; use [:enum …] for a bounded keyword slot or :string for a free-form value (parse it in a handler) :route-id, :slot (:params / :query), :param (the offending key), :type-form
:rf.error/route-url-validation :error diagnostic The address handed to route-url cannot produce a round-trippable URL (a caller bug; not user input). Four of the arms carry a :reason discriminator. Address SHAPE — the address is not a map (:not-a-map), names no :to (:missing-to), or carries a key outside the address-only set :to / :params / :query / :fragment (:bad-address-keys, naming the rejected :keys in total canonical order). Address CONTENTS — a :params key the route's PATTERN has no :name / *name segment for (:uncaptured-params, naming the offending :keys): such a key cannot reach the URL and match-url cannot read it back, so route-url rejects it rather than dropping it in silence — the same closure the address-only rule applies, one level down inside :params (rf2-0iuh3). The reserved :rf.route/not-found route is the ONE exemption, because its slice :params are the framework's record of the miss rather than path captures. The two remaining arms carry no :reason and are told apart by their payload: a :params / :query schema failure (:slot + :error) and the sequential-optional-group prefix chain — a later optional group supplied while an earlier one elided, which match-url cannot recover (:group). Thrown by re-frame.routing.registry and surfaced through :rf.route/navigate. Per 012 §Bidirectional URL ↔ params and 012 §Path-pattern grammar (canonical) :no-recovery — supply an address the route accepts: fix the shape, satisfy the :params / :query schema, or — for an uncaptured key — drop it, move it to :query, or add the segment to the route's pattern This row is thrown, so the column names FLAT ex-data slots, not :tags keys (per §Reading the two right-hand columns). :route-id and :slot (:params / :query) on every arm that has resolved a route; :reason on the four discriminated arms; :keys on :bad-address-keys and :uncaptured-params; :value on :not-a-map, on both schema arms, and on the prefix chain; :error (the schema explanation) on the schema arms; :group on the prefix chain. The uncaptured-param arm reports STRUCTURE only — keys and slot, never a value — so a secret in an uncaptured key cannot ride the error surface
:rf.warning/malformed-url :warning diagnostic A URL handed to the routing egress (handle-url-change / navigate) could not be matched — a match-url throw or an otherwise malformed URL. Advisory: the navigation plan emits it and continues to the not-found path. Emitted by re-frame.routing.plan. Per 012 §Route-not-found :logged-and-skipped — the URL is treated as unmatched; the not-found route (if any) handles it :url, :reason (when a match-url throw), :frame
:rf.error/flow-cycle-extract-invariant :error diagnostic The flow topological-sort cycle-path extractor reached a dead end — a Kahn-stuck node found no stuck dependency to follow. An IMPOSSIBLE-by-construction internal-invariant violation (a framework bug), distinct from the caller-fixable :rf.error/flow-cycle registration rejection. Thrown by re-frame.flows.topo. Per 013 §Failure semantics :no-recovery — a framework bug (not caller-fixable); report with the :node / :stack / :seen / :remaining payload :node, :stack, :seen, :remaining
:rf.error/handler-throw :error diagnostic The event handler threw while the transducer-router reference SCAFFOLD (re-frame.router-transducer) ran a pipeline step — carried as the step-result :error :operation. NON-NORMATIVE: the scaffold is the exercisable design for spec/Design-TransducerRouter.md, NOT wired into the live runtime (the live-runtime handler throw is the always-on :rf.error/handler-exception). :no-recovery — the step records the throw; the reducing function halts the cascade :operation, :event, :ex
:rf.error/ui-tree-malformed :error diagnostic The compiled-view substrate (re-frame.ui, rf2-vxgfnd S1) met a value the closed JVM-tree node set cannot carry — a dynamic child producing a raw vector/seq/keyword (hiccup is compiled, not interpreted; the fix is a child view, ui/raw, or (for …) with :key), a literal-collection attr value outside :class/:style, a non-map dynamic :style, an unclassifiable dynamic handler value, a non-string (ui/html x), or a list row that lost its :key. The id is contract-named by the jvm-tree-and-conversion-contract draft (§node discrimination — shared by every tree consumer; the SSR seam's version-gate sibling :rf.error/ssr-ui-tree-version-unsupported has its own row (below), landed with the S5 serialiser at rf2-3omxp). Thrown ex-info (canonical builder), not a trace. The structural tree and re-frame.ui.semantic version-gate/node-discrimination arms are compiler/Tier-1-only invariants. Emitted by re-frame.freehand.tree / re-frame.freehand.react (the Freehand INTERPRETED walk's fail-loud arms, per 004B §The interpreted walk — an unrenderable child, an attribute value outside the closed value grammar, a non-map :style, an unclassifiable handler value, a doubly-spelled id, children under a void element, a vector head the closed node set has no variant for, and — on the React walk only — the three v/defhost CROSSING refusals, which are STRUCTURAL rather than call-ABI breaches and therefore land here and not on :rf.error/view-bad-props: a declared host that registered no React component (its descriptor was built by a Clojure load of the declaring namespace, and there is no React on the JVM), a :map-props adapter that answered something other than a map, and a :map-props adapter that returned a reserved Freehand fact — a declared callback position, :key, or :children — which it can neither supply nor replace, because the ORDINARY plane is all it owns (per 004 §Qualified host leaves); the compiled tier raises its own :rf.ui.compile/* findings at the declaration instead), by re-frame.freehand.to-react (the OUTWARD v/->react React-export bridge's argument and props guards — an export target that is not a declared view, an options value that is not a map, an option outside the closed {:map-props f} roster, a non-fn :map-props, a React ref reaching a view that has no ref protocol to give it, and a :map-props return that is not a map or that carries the reserved :frame key), by re-frame.freehand / re-frame.freehand.node / re-frame.freehand.events / re-frame.freehand.rules / re-frame.freehand.top-layer (the door's remaining interpreted fail-loud arms, rostered by SEAM rather than by raw throw-site — the same structural refusal reached through different authoring doors: the v/presence / v/client-only boundary-form option rosters, the shared node builder every walk constructs elements through, the v/slot render-fn seam and the v/spread / v/spread-safe forwarding deny law on BOTH hosts, and the top-layer overlay render guard), by re-frame.freehand.root (the v/mount / v/hydrate-root opts-key and root-id-derivation guards) and by re-frame.freehand.test (the structural TEST surface — render / find / find-all / attrs / text fail loud on a malformed form or node, the Freehand sibling of the re-frame.ui.test consumer arm below), by re-frame.ui.tree / re-frame.ui.rules / re-frame.ui.runtime / re-frame.ui.test (the S1d tree consumers — find/find-all/attrs/text fail loud on malformed nodes, non-node children, and text content passed where a node is required), by re-frame.ui / re-frame.ui.react / re-frame.ui.hooks / re-frame.ui.events / re-frame.ui.reactive (the compiled substrate's LOWERING-AND-CAPTURE discipline, previously unrostered — a ui/sub / ui/local / ui/slot / ui/event / react/use-* lexical form CALLED directly instead of being lowered by the compiler, an event site running outside its owning render boundary, a local mutation during the render pass, and a site read escaping its capture's owning thread) and by re-frame.ui.semantic/normalize (the parity/fingerprint boundary N, rf2-vxgfnd.20/.60), which reuses this id in two fail-loud ARMS: (a) a ROOT VERSION GATE — a missing / non-integer / unsupported :rf.ui/tree-version fails BEFORE any traversal carrying {:got <received-version> :supported #{1}} (no :path; the S5 serialiser's own :rf.error/ssr-ui-tree-version-unsupported is the SSR-seam sibling); and (b) a NODE-DISCRIMINATION arm — a map carrying two-plus discriminators, or none, fails carrying the offending {:value <node> :got <discriminator-set-or-[]>} plus a deterministic root-relative :path ([] at the root, [:children i :children j …] get-in-shaped into the INPUT tree using document child order only — never map key order) that LOCATES the node and disambiguates equal-looking malformed nodes at different positions. :no-recovery — fix the template or the runtime value; the escapes are named in the message. Arms that pass no opts at all take that builder default. EVERY v/->react bridge arm names its own instead — one disposition per refusal, so none of them falls back: :export-a-declared-view-or-write-a-wrapper (the export target is not a declared view), :supply-an-options-map (the opts value is not a map), :use-map-props-or-write-a-wrapper (an option outside the closed roster), :supply-a-one-argument-function (a non-fn :map-props), :write-a-wrapper-or-use-a-behavior (a React ref reached a view with no ref protocol to give it), :return-one-props-map (:map-props answered a non-map) and :rename-the-prop (the props map carries the reserved :frame) This row is thrown, so the column names FLAT ex-data slots, not :tags keys (per §Reading the two right-hand columns). Every arm carries the four required slots — :rf.error/id, :where, :recovery, :reason — plus a per-arm payload, and :value is the near-universal one (a re-frame.error/diag-value-summary at most sites, the raw offending node at the test and parity surfaces). By the same emitter groups the Trigger cell rosters: interpreted walk + door:value / :attr / :row per branch, the v/defhost crossing arms add :host (the declared host id), the adapter's reserved-key arm adds :reserved (the reserved keys it returned, sorted); v/->react bridge:value, :unknown (the rejected option keys), and :view-id on the ref refusal and on both props-map-law arms; root guards:value, :unknown (the rejected root-opt keys), plus :root-id and :disambiguator on the identity arms; structural test surfaces (v/… and re-frame.ui.test) — :value alone on every arm; S1d tree consumers:value throughout, plus :attr (a collection attr value), :caller / :key (the ui/spread / ui/spread-safe deny arms), :row (a keyless list row), :expected + :actual (the host-symmetric slot-arity twin carried identically by re-frame.ui.tree and re-frame.ui.runtime), and :kind (an unmarked callback :ref); lowering-and-capture guards:reason (an out-of-boundary event site, a render-phase local mutation), :query (a directly-called ui/sub), :unknown-keys / :unsupported-dynamic-options (dynamic-handler opts), :site-id + :query + :owner-thread + :current-thread (the capture-ownership arms), and NO payload beyond the four required slots on the re-frame.ui / re-frame.ui.react lowering-only guards; parity boundary — the re-frame.ui.semantic version arm adds :got + :supported, its node-discrimination arm adds :value + :got + :path
:rf.error/ssr-ui-tree-version-unsupported :error diagnostic The S5 SSR serialisation seam (re-frame.ssr/emit-ui-tree, per Spec 004B §The SSR consumption boundary) met an already-rendered structural tree whose root :rf.ui/tree-version is MISSING, NON-INTEGER, or UNSUPPORTED — validated FIRST, before any emission, so a future-version or corrupt tree never emits plausible markup. An OPERATIONAL condition (deploy skew: the server is too old for the tree it was handed), deliberately DISTINCT from the code-bug :rf.error/ui-tree-malformed (row above): that shared id's re-frame.ui.semantic-N root-version-gate arm carries the SAME {:got :supported} ex-data at the Tier-1 parity boundary, so it is the machine discriminator :rf.error/id — not ex-data sniffing — that separates operational version-skew (this id) from a view-code bug (the shared id). A malformed NODE past this version gate still throws the shared :rf.error/ui-tree-malformed, so the two ids partition one seam's failures by class (version vs structure). Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.ssr.ui-tree (the fn re-frame.ssr/emit-ui-tree), the S5 tree->HTML seam (rf2-3omxp, rf2-vxgfnd.97). :no-recovery — align the deployed producer/consumer versions (roll the server forward / fix deploy skew) or fall back to client render; nothing is wrong with the view code :got, :supported
:rf.warning/unregistered-event-id :warning diagnostic A keyword event id for which the frame that resolves this dispatch has no registered handler. Emitted by re-frame.ui.events/warn-unregistered! in TWO timings: at RENDER for compiler-proven literal data handlers (data-handler) and runtime-classified vector/options handlers (dynamic-handler), keyed to the RENDER-captured frame; and at INVOCATION for a committed ui/event handler whose synchronous RESULT is an event vector (invoke-site!), keyed to the COMMITTED frame. Existence is resolved through THAT frame's OWN sealed image generation — the exact re-frame.live-frame/call-with-frame-resolution seam dispatch resolves the handler through (a nil / non-live target falls back to the process-global registrar) — so an id registered only in the frame image does not warn, and an id present only process-current but absent from the frame image does. This is an early typo detector, not an ownership or dispatch veto: a lazily loaded module may legitimately register the id after this render but before invocation. Dev-only (interop/debug-enabled?-gated and DCE'd from production); the site renders and its stable committed callback remains live. Per 004D §Handlers :warned-and-continued — the site stays live and a later invocation dispatches normally; register the handler (or correct the event id). A lazy module that owns the id may register it before invocation :event-id, :view-id, :site-id, :source-coord, :occurrence-path, :reason
:rf.warning/placeholder-in-dynamic-vector :warning diagnostic A top-level placeholder keyword appeared inside a DYNAMIC (runtime-classified) handler-position event vector — including the :event vector of a runtime-classified options map, OR the event vector a committed ui/event handler RETURNS at invocation. Placeholders are COMPILED, recognized in LITERAL vectors only (Spec 004 §Handlers); the first matching top-level placeholder is reported, and the unchanged runtime vector dispatches that placeholder as ordinary keyword data rather than filling it from the DOM event. Emitted as a structured trace by re-frame.ui.events/warn-dynamic-placeholder! in TWO timings: at RENDER through dynamic-handler, and at INVOCATION through invoke-site! for a committed ui/event result vector. Dev-only (interop/debug-enabled?-gated and DCE'd from production). Per 004D §Handlers :warned-and-continued — rendering continues; build the literal vector at the DOM site (or use the appropriate explicit event form) to request placeholder projection :event (the raw dynamic vector), :placeholder, :reason, :view-id, :site-id, :source-coord, :occurrence-path
:rf.warning/cross-frame-carried-op :warning diagnostic A CARRIED (frame) operation bundle's :subscribe ran beneath a DIFFERENT ambient frame than the one it was captured from — the (frame) hold's honesty rule: a bundle captured under frame A can be carried across a frame boundary and invoked under a foreign frame B, so the frames-are-isolated doctrine (a subscription MUST NOT reach across a frame boundary) is held by THIS diagnostic + the absence of any cross-frame read spelling, never by a false impossibility claim. QUIET when the ambient chain names the SAME frame (the ordinary in-scope read) or NO frame at all (an async hop / top-of-stack caller — nothing foreign to compare against); NARROW to :subscribe (a carried dispatch / dispatch-sync drives its locked frame and does not warn). Emitted on EACH cross-frame invocation — no -once suffix, an advisory surface rather than a warn-once nag. Dev-only (interop/debug-enabled?-gated wholesale — the ambient read, the comparison, the reason string, and the emit all DCE under :advanced + goog.DEBUG=false). Emitted by re-frame.ui.frames/maybe-warn-cross-frame-carried-subscribe! (the (frame) operation-bundle :subscribe seam). Per 004D §Roots and mounting and 002 §Frame target resolution :warned-and-continued — the read proceeds against the CAPTURED (origin) frame (never the ambient one); advisory only. Restructure with [frame-provider {:frame …} …] subtree scoping, or pass VALUES across the boundary, not the ops bundle :origin-frame (the captured frame), :ambient-frame (the foreign ambient frame), :rf.sub/query-v (the attempted query vector), :reason. The category rides the top-level :operation (a :warning synthesizes no :tags :category) and :recovery :warned-and-continued hoists to the trace-event envelope top-level — neither is a :tags key (canonical Spec-Schemas §CrossFrameCarriedOpEvent / CrossFrameCarriedOpTags)
:rf.warning/root-ensured-frame-destroyed-under-live-roots :warning diagnostic A frame the Freehand interpreted-mount surface (re-frame.freehand.root) ENSURED (a :frame {:id …} plan — the root owns its lifetime) was destroyed EXTERNALLY while N live roots still reference it — the one lifecycle event a programmer would want to hear at the moment it happens, since it means an owned frame vanished out from under the roots rendering it. Emitted from the :freehand/on-frame-destroyed! destroy hook (Spec 002 destroy recipe step 7) when the DYING incarnation's token is identical? to a ledger row's :ownership :handle token AND that row still carries live :refs after the hook tombstones it (drops the handle, stamps :destroyed-at). NOT the ownership proof — Layer 2 is diagnostics/freshness only; re-frame.freehand.root/frame-standing's live-token join stays the sole authority. A same-id successor row (token mismatch) is untouched, so the diagnostic never mis-fires on a reseat. Dev-only (interop/debug-enabled?-gated via trace/emit!; DCE'd from :advanced production). Per 004C §7.1 Root-attempt evidence and 002 §Destroy recipe :observed-external-destroy-of-a-root-ensured-frame — advisory; the destroy stands and the row is tombstoned. Own the frame's whole lifetime through v/unmount!, or SCOPE it config-less if something else owns its destruction :frame-id, :live-roots (the still-referencing root-ids), :where, :recovery
:rf.warning/root-owned-frame-leaked-at-release :warning diagnostic A Freehand root's FINAL v/unmount! reached an OWNED ledger row that carries a :plan-author but no live :handle — a pre-#6818 legacy row a defonce ledger carried across a reload without a handle — so the root-owned frame it named cannot be destroyed exactly and LEAKS. Loud rather than the old silent nil-handle no-op, matching v/mount's fail-loud posture on the same unprovable-provenance shape (Finding 1: loud on mount, silent on release was the inconsistency). A row the destroy hook already TOMBSTONED (it carries :destroyed-at — its incarnation's death was announced when it happened) is NOT re-alarmed. Emitted from re-frame.freehand.root/release-frame! when the verdict is :legacy-live and no :destroyed-at is present. Safe direction (an exact destroy could never reach a successor anyway), so it is a diagnostic, not a throw — v/unmount! must complete its React teardown. Dev-only (interop/debug-enabled?-gated via trace/emit!). Per 004C §7.1 Root-attempt evidence :own-the-lifetime-or-scope-config-less — advisory; the leak is surfaced, not repaired (a legacy row cannot prove which incarnation to destroy). Re-mount owning the whole lifetime, or scope config-less so something else owns the destruction :frame-id, :plan-author, :where, :recovery
:rf.error/ui-duplicate-key :error diagnostic Two rows of ONE compiled keyed-list site collided under React's key string coercion (key 1 collides with key "1") — diagnosed upstream at the compile-indexed list site: the JVM tree render throws; the CLJS dev build warns per site (goog.DEBUG-stripped; react-dom/server is silent and React's own warning is client-dev-only, per the S1b [S1-CONFIRM] row-11 probe). The SAME collision, one boundary further out, is reported when two children of one (ui/presence …) boundary claim one key — there a key is a retained ownership identity, so the later claimant is dropped rather than aliasing the first's phase and exit timer. Emitted by re-frame.ui.tree/keyed-run and re-frame.ui.presence-runtime :give-each-row-a-distinct-key — keys must be unique per list site (and per presence boundary) after string coercion :key, :collides-with, :recovery
:rf.error/jvm-host-op :error diagnostic A host-bearing feature was invoked in a JVM structural render (Spec 004 rewrite §The JVM structural subset — the spec-named id): a rendered (ui/raw …) child, a foreign React component head (foreign components never appear in the JVM tree), and later-stage host ops (local setters at S3+). Host-bearing features need mounted (Tier-3) tests; ui/client-only fallbacks land S3. Thrown ex-info (canonical builder), lazily — untaken branches never evaluate. Emitted by re-frame.ui.tree/jvm-host-op! :use-a-mounted-test — Tier-1 headless rendering covers structure/props/branches/lists/event intent only; wrap host subtrees in ui/client-only when it lands (S3) :op, :recovery
~~:rf.error/ui-dispatch-unwired~~ n/a (retired) RETIRED (rf2-d89rs). The row described itself as STAGING — the transient S1 seam that failed loudly while a compiled event handler had no installed dispatch hook, "replaced by committed-frame dispatch at S2/S3". S2/S3 landed: the committed event-dispatch spine wires compiled handlers to their frame directly, and re-frame.ui.runtime carries neither dispatch-event! nor set-dispatch-hook!, so there is no unwired seam left to reject. Nothing has emitted the category since, and nothing will: the donor inventory disposes re_frame/ui/runtime.cljs as REPLACE — Freehand ships its own runtime and the donor realization is deliberately not carried across — so the staging hook has no successor to re-raise it.
:rf.error/dispatch-disconnected :error diagnostic A (ui/dispatch-fn) stable committed-frame dispatcher fired while its owning view was in a NON-CONNECTED lifecycle state (:destroyed / :disconnected / speculative) — the leaked-listener detector (rf2-vxgfnd.95.2, S3). The dispatcher's identity is stable across renders and reconnects (attach it ONCE as an external listener) and it reads the COMMITTED frame at call time, retargeting only on commit; in every non-connected state it REJECTS the dispatch rather than silently driving a torn-down frame, so an external listener that outlives its view (registered in an (effect …) and never cleaned up) fails loud instead of leaking. Distinct from :rf.error/frame-destroyed (the recover-and-emit dispatch / subscribe path, always-on): this is the imperative-dispatcher's own non-connected guard, which THROWS. Reachability is dev/test AND production (the owner-lifecycle check is not goog.DEBUG-gated), but it is a pure throw-error! that does NOT fan out on the always-on error-emit listener, so it rides the diagnostic channel for catalogue purposes (the thrown-ex-info-is-diagnostic rule, the same posture as :rf.error/flush-convergence-exceeded). Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.ui.events/fail-dispatch-disconnected! (thrown as re-frame.ui/dispatch-fn). Per 004D §Effects :no-recovery — the dispatcher throws; unregister the external listener in the (effect …) cleanup that created it so it never outlives the view :view-id, :state (the owner's non-connected lifecycle state), :event (the attempted event vector), :where (re-frame.ui/dispatch-fn), :recovery, :reason
:rf.error/ui-spread-outside-template :error diagnostic (ui/spread base overrides) was called directly as a function — it is a TEMPLATE form, legal only in a DOM/custom element's props position, where the compiler wires it through the one conversion rule table (Spec 004 rewrite §Interop — ui/spread). The var exists for resolution + honest direct-call failure, not for calling. Thrown ex-info (canonical builder). Emitted by re-frame.ui/spread :move-spread-into-an-element-props-position[:div.card (ui/spread base overrides)] :recovery
:rf.error/ui-test-tier-mismatch :error diagnostic The ui.test tier split was crossed: a CSS string reached Tier-1 find/find-all; a structural tree, released mounted root, or other non-mounted value reached Tier-3 query; a mounted operation ran on the JVM/no-DOM host; or a live DOM element reached structural attrs/text. Each branch names the other tier. Thrown ex-info (canonical builder); dev/test only. Emitted by re-frame.ui.test :use-the-other-tier — structural trees use find/find-all; browser/jsdom mounts created by with-root use native-CSS query and DOM interop :got, :other-tier (when named), :recovery
~~:rf.error/ui-test-bad-selector~~ n/a (retired) RETIRED (rf2-d89rs). The category rejected a selector outside its tier's closed contract, but the selector grammar it policed no longer exists: the rf2-n7jtp minimisation of re-frame.ui.test to the ratified six-name surface (render / attrs / text / with-root / flush! / flush-presence!) DELETED find, find-all and query, and retired spec/004D-UI-Test-Selectors.md with them. Traversal is now ordinary tree-seq over the projected tree, and Tier 3 is a native .querySelector — neither has a framework selector grammar to violate. Nothing has emitted the category since, and the re-homing is already complete: the donor inventory disposes re_frame/ui/test.cljc as MOVE, marked done, and the moved surface (re-frame.freehand.test) carries no selector category either.
:rf.error/ui-test-overlapping-act :error diagnostic A public CLJS mounted-test operation (with-root or either flush! arity) began while a prior Promise-backed React act operation was still pending — normally a forgotten await. The guard throws synchronously before entering a second act; with-root checks before allocating its container/root. Private owner cleanup is serialized after the pending operation, so the misuse remains loud without stranding either owner. Dev/test only. Emitted by re-frame.ui.test :await-the-prior-operation — compose/await every with-root and flush! Promise before asserting or starting another mounted operation :active-where, :recovery
:rf.error/flush-in-open-epoch :error diagnostic A synchronous registry-flush was forced while a frame's run-to-completion event drain was still open. Rendering there could expose partially settled state — queued events whose update and commit phases have not yet run. The call throws before draining ViewCell notifications, before closing a pending ViewCell window, and before entering React act, so no partial render phase is published. Enforced by ONE implementation and, unlike the convergence bound below, genuinely universally: the SHARED open-drain guard is re-frame.frame/guard-open-drain! in CORE (ruling rf2-vxgfnd.207; moved out of re-frame.ui.reactive by rf2-87ouj, which also gave Freehand its missing call), and EVERY substrate whose synchronous flush can publish a render phase calls it — ui.test/flush! (the test all-roots spelling), the compiled-view substrate's re-frame.ui.substrate/flush-render!, and re-frame.freehand.substrate/flush-render!. One guard is possible HERE, where sharing the convergence bound was not, for a structural reason worth stating: this guard closes over NO cell registry at all — it reads the router-bound re-frame.frame/*run-frame-state-before* and the frame accessors alone — so Freehand reaches it through core and still requires nothing from re-frame.ui, and the law survives any later removal of the donor artefact rather than dying with its old home. Reachability is dev/test AND production: neither substrate's synchronous render-commit is goog.DEBUG-gated, and only the ui.test/flush! call site is test-only. It is a pure throw-error! that does NOT fan out on the always-on error-emit listener, so it rides the diagnostic channel for catalogue purposes (the thrown-ex-info-is-diagnostic rule). The :where slot names which site threw :no-recovery — let the event drain reach quiescence, then flush once :frame, :frame-epoch, :where (rf.ui.test/flush! / re-frame.ui.substrate/flush-render! / re-frame.freehand.substrate/flush-render!), :recovery
:rf.error/flush-convergence-exceeded :error diagnostic A synchronous view flush could not converge — the flushing substrate's cell registry was STILL non-quiescent after that substrate's own flush-convergence-budget re-flush passes, so a commit path is re-dirtying cells every pass (an unstable layout-effect / useSyncExternalStore notification cycle). A synchronous forcing call (either substrate's render-commit; the test all-roots flush) drains the registry then RE-DRAINS to a fixed point, because a commit-triggered re-dirty can enrol a cell AFTER the pass that flushed it; that re-drain must terminate. The single-pass ambient guards (:rf.error/drain-depth-exceeded, React's maximum-update-depth) bound ONE synchronous pass and cannot see a re-enrolment landing across two separate flushSync passes, so before this bound the synchronous forcing call could spin forever (rf2-0faipl). Thrown ex-info (canonical builder), not a trace. Reachability is dev/test AND production (neither substrate's synchronous render-commit is goog.DEBUG-gated), but it is a pure throw-error! that does NOT fan out on the always-on error-emit listener, so it rides the diagnostic channel for catalogue purposes (the thrown-ex-info-is-diagnostic rule). Enforced by TWO INDEPENDENT bounds that share no code — ONE id, two implementations, which is what the :where slot exists to disambiguate (rf2-jew4k). (1) re-frame.ui.reactive/converge-flush! / flush-nonconvergence! bounds the re-frame.ui ViewCell registry at re-frame.ui.reactive/flush-convergence-budget (100) passes, driven by the first-party adapter's re-frame.ui.substrate/flush-render!; the async CLJS ui.test/flush! cycle is a promise-chained twin of that loop resting on the SAME budget and throwing the SAME flush-nonconvergence!, so WITHIN re-frame.ui the diagnostic really is one copy. (2) re-frame.freehand.cell/converge-flush! / its private flush-nonconvergence! bounds Freehand's own pending window at re-frame.freehand.cell/flush-convergence-budget (100) passes, driven by re-frame.freehand.substrate/flush-render! — BOTH Freehand lowering modes (interpreted and compiled) reach it through that one substrate flush. Freehand requires nothing from re-frame.ui, so the two budgets are equal by construction rather than by reference and can drift; the :where slot names which site threw, hence which bound was exhausted — and the two production values differ ONLY in their namespace (re-frame.ui.substrate/flush-render! vs re-frame.freehand.substrate/flush-render!), so read the whole symbol :no-recovery — always a bug; fix the effect/listener that keeps re-marking so the flush can quiesce :passes (the exhausted convergence budget), :pending (residual pending-cell count), :where (re-frame.ui.substrate/flush-render! / re-frame.freehand.substrate/flush-render! / rf.ui.test/flush!), :recovery
:rf.error/ui-test-bad-opts :error diagnostic A ui.test/render opts map violated its CLOSED contract (004C §9 + the 008 §The ui.test contract table): a non-map opts; an unknown key (the opts are CLOSED — :frame / :props / :sub-overrides); {:props …} combined with a literal root form (props live IN the form; {:props …} rides only the bare-view reference); a non-map :props; a malformed :sub-overrides (a map of query VECTOR → value); or {:frame …} on a plan-bearing root form (its top-region frame-root(s) preflight their own fresh test frames — a frame plan and an explicit frame are two ways to say one thing; mint a plan-free form's frame with rf/make-frame + :initial-events). Thrown ex-info (canonical builder); dev/test scope only. Emitted by re-frame.ui.test :fix-the-opts-map — the message names the offending key and the accepted shape :got / :unknown / :props / :frame / :sub-overrides (per branch), :recovery
~~:rf.error/ui-test-frame-collision~~ n/a (retired) RETIRED (rf2-d89rs). The category rejected a plan-bearing ui.test/render whose declared frame-root plan named an already-live frame-id. The rf2-n7jtp minimisation of re-frame.ui.test to the ratified six-name surface DELETED the plan-bearing render route along with render's :frame option, so there is no declared plan to collide: frame scope is driven by rf/with-new-frame / rf/with-frame at the call site, which owns its own isolation. Nothing has emitted the category since, and the re-homing is already complete: the donor inventory disposes re_frame/ui/test.cljc as MOVE, marked done, and the moved surface (re-frame.freehand.test) carries no frame-collision category either.
:rf.error/ui-frame-root-outside-root-form :error diagnostic (ui/frame-root {:id …} children…) was called directly as a function — it is a ROOT-FORM wrapper (the static ENSURE-plan position of the root-identity-and-mount contract, rf2-vxgfnd S1c), legal only in the top region of a root form handed to ui/mount / ui/render! / ui/hydrate-root; the compiler extracts its plan into the Root Descriptor and compiles the wrapper away, so it is never called. (Misuse in TEMPLATE position is the compile error :rf.ui.compile/frame-root-misplaced, not this id.) Thrown ex-info (canonical builder). Emitted by re-frame.ui/frame-root :move-frame-root-into-a-root-form — the ENSURE plan belongs in a root form's top region; inside views, frames are ambient :recovery
:rf.error/ui-frame-provider-outside-template :error diagnostic (ui/frame-provider {:frame f} children…) was called directly as a function — it is a TEMPLATE form (the SCOPE-only position, rf2-vxgfnd S2c), legal in a defview template or root form, where the compiler wires it into a scope element (re-frame.ui.frames/provider-scope-element) that scopes the subtree to an already-live frame through the shared React context; it compiles away and is never called. (:id in TEMPLATE position — the ENSURE key — is the compile error :rf.ui.compile/bad-frame-provider, not this id.) Thrown ex-info (canonical builder). Emitted by re-frame.ui/frame-provider :use-frame-root-to-ensure — to CREATE the frame if absent, use ui/frame-root in a root form's top region; frame-provider only scopes an already-live frame :recovery
:rf.error/ui-platform-incompatible :error diagnostic The CLJS re-frame.ui root/ViewCell ownership substrate was admitted on a JavaScript host without the required standard WeakRef constructor. Weak membership is correctness-critical: a strong fallback would retain ordinarily-unmounted ViewCells for the Root lifetime, while dropping membership would lose Activity-hidden teardown discovery. The capability is probed/captured once before frame preflight, React Root allocation, live-root registration, or attach-root! ownership mutation; the incompatible result is cached, with no polling, strong fallback, or per-render check. FinalizationRegistry is explicitly optional: synchronous WeakRef scans compact collected husks when it is absent. Thrown ex-info (canonical builder). Emitted by re-frame.ui.reactive, including through re-frame.ui.client root admission. Per 006 §JavaScript host capability boundary :use-a-weakref-capable-javascript-runtime — run the client on a modern browser/JavaScript runtime that provides WeakRef :platform (:javascript), :capability (:js/WeakRef), :where, :recovery
:rf.error/duplicate-root-id :error diagnostic Two roots claimed one root-id — root-ids are page-unique identity (root-identity-and-mount contract §7, three-layer detection; rf2-vxgfnd S1c). S1 arms: the BUILD tier (Layer 1 — the compile-side root-site index rejects two root sites in DIFFERENT namespaces resolving to equal root-ids within one build. The participating sites and the throw point are PER DOOR: on the re-frame.ui macro door the sites are mount/create-root/hydrate-root and the cross-namespace law throws at macro expansion; on the re-frame.freehand door — whose client mount/hydrate-root are runtime fns backstopped at Layer 3, so the ONLY Layer-1 site is the v/render-static macro — the law throws at macro expansion off the build pass but at COMPILE-FINISH on a real Shadow build, where the whole-build root-site registry is harvested from the disk-cache-durable analyzer carrier. Both doors raise the SAME canonical :rf.error/duplicate-root-id through the shared error builder, so one public collision has one catalogued identity regardless of build mode) and the CLIENT tier (Layer 3 — the per-document live-root registry rejects registering a root-id already live, BEFORE any render, and also a re-mount of a live root-id onto a different container; the existing root is untouched — failure isolation). A :tearing-down claim still owns its exact id/container/prefix until settlement (including failed-first-mount rollback), so same-id admission is rejected with this SAME id; client :existing carries the owner's :provenance, :site, and :tearing-down? true, while :arriving identifies the rejected claimant. A merely-deferred owner may be waited on to settle; a :cleanup-failure? quarantine (a throwing .unmount) NEVER settles, so :existing additionally carries :cleanup-failure? true and the recovery is :reinit-adapter-or-use-a-fresh-identity (never a wait) — and a same-id retry onto the EXACT poisoned node is reported as :rf.error/root-container-consumed (checked ahead of this arm, rf2-h05lm), so this arm fires only on a same-id retry onto a DIFFERENT fresh node. The Layer-2 server page registry lands S5. When both parties DERIVED their id from the same view, the message names the fix (add :disambiguator or author :root-id). Emitted by re-frame.ui.compiler.root (re-frame.ui build) / re-frame.freehand.compiler.root (re-frame.freehand off-build-pass build) / re-frame.freehand.compiler.build (the re-frame.freehand Shadow compile-finish harvest hook) / re-frame.ui.client (client) :make-root-ids-unique (deferred/live owner) — author :root-id, add :disambiguator where one view mounts twice, or wait for the tearing-down predecessor to settle / use a fresh identity+container; a :cleanup-failure? owner NEVER settles, so its recovery is instead :reinit-adapter-or-use-a-fresh-identity — destroy + re-init the adapter (reclaims the id) or use a distinct :root-id with a fresh node :root-id, :provenance + :sites (build) / :existing (:provenance, :site, optional :cleanup-failure? true, optional :tearing-down? true) + :arriving (client), :recovery
:rf.error/root-container-missing :error diagnostic The container DOM node handed to ui/mount / ui/create-root is nil/absent (a failed lookup) — the client tier of the root-container roster (root-identity-and-mount contract §7; rf2-vxgfnd S1c). The S5 hydration arm — a manifest element-locator resolving to no element (fragment-composition bugs) — lands with server rendering, scoped to that root per the failure-isolation contract. Thrown ex-info (canonical builder), before any React work. Emitted by re-frame.ui.client :supply-a-live-container — pass a live element (check the container lookup) :root-id, :recovery
:rf.error/root-container-in-use :error diagnostic ui/create-root / ui/mount targeted a DOM node already owned by a DIFFERENT live or tearing-down root — one container, one root (root-identity-and-mount contract §7; rf2-vxgfnd S1c). A tearing-down owner retains the node through host settlement, so admission fails closed instead of racing React's pending/failed cleanup; :existing {:tearing-down? true} distinguishes that arm. Thrown BEFORE any render; the owning root is untouched. Thrown ex-info (canonical builder). Emitted by re-frame.ui.client :unmount-the-owning-root-firstunmount! the live owner; for a tearing-down owner, wait for settlement or mount into a fresh node :root-id, :owner-root-id, optional :existing {:tearing-down? true}, :recovery
:rf.error/root-container-consumed :error diagnostic ui/create-root / ui/mount targeted a CONSUMED container — an exact DOM node whose prior Root's host .unmount THREW after React consumed the handle, leaving the node fail-closed (root-identity-and-mount contract §7; rf2-vxgfnd S1c, rf2-sddbc). A throwing/consumed .unmount may have QUEUED late host DOM work (a scheduled replaceChildren) before it threw; that authority is unsettled and unobservable in-process, so clearing the node's DOM + React ownership marker is a SNAPSHOT, never proof the node is free — the exact node can never be proven reusable. This is DISTINCT from :rf.error/root-container-in-use (a live/merely-deferred owner that settles): a consumed node NEVER frees, so the only recovery is a fresh node. It is checked AHEAD OF :rf.error/duplicate-root-id, so a same-id retry onto the exact poisoned node is reported HERE rather than hidden behind duplicate-ID ordering (rf2-h05lm). Two arms: the isolated unmount! quarantine (the poisoning claim is still :tearing-down/:cleanup-failure?; :owner-root-id names it) and the post-adapter-reclaim denylist (the id/prefix are released for a same-id re-mount on a fresh node; the exact node stays fail-closed in consumed-containers). The successor-generation admission fence does NOT globally fence on a consumed quarantine — only its exact node — so unrelated fresh roots admit. Thrown BEFORE any render. Thrown ex-info (canonical builder). Emitted by re-frame.ui.client. Per 006 §Settlement independence :use-a-fresh-container — mount into a fresh container node; the same :root-id re-mounts onto a fresh node once the poisoned claim is reclaimed (a same-generation unmount! quarantine reclaims at adapter destroy) :root-id, optional :owner-root-id, :recovery
:rf.error/duplicate-identifier-prefix :error diagnostic Two DIFFERENT live roots claimed the same effective React identifierPrefix — a shared prefix collides use-id output across the roots (root-identity-and-mount contract §7; rf2-vxgfnd S1c, rf2-ez3fqk). The CLIENT-tier arm of the identifier-prefix uniqueness check (the Layer-2 server page registry asserts the same across a server-rendered page, :rf.error/root-manifest-invalid {:conflict :identifier-prefix}, S5). The DERIVED default prefix "rf2-" + root-id-slug + "-" is injective over root-id (rf2-vxgfnd.17), so this arm backstops AUTHORED :identifier-prefix opts, which can still alias distinct roots. Thrown BEFORE any render; the owning root is untouched (failure isolation). Thrown ex-info (canonical builder). Emitted by re-frame.ui.client :make-identifier-prefixes-unique — give one root a distinct :identifier-prefix, or drop the authored opt to take the unique derived default :root-id, :identifier-prefix, :owner-root-id, :recovery
:rf.error/root-identifier-prefix-immutable :error diagnostic A same-root / same-container re-mount (an HMR edit) authored a DIFFERENT effective React identifierPrefix than the live root was created with (root-identity-and-mount contract §7; rf2-vxgfnd S1c, rf2-vxgfnd.59). A live root's identifierPrefix is fixed at createRoot — React root options are IMMUTABLE for the root's lifetime — so the running root cannot adopt the new value; the mount* same-root fast path fails LOUD rather than silently reusing the old option (the pre-fix behaviour, which kept use-id emitting the old prefix — an undiagnosed drift). Thrown BEFORE preflight; the live root and its last committed render are untouched (no :initial-events drain). Thrown ex-info (canonical builder). Emitted by re-frame.ui.client :unmount-before-changing-identifier-prefixunmount! the root, then mount again to adopt the new identifierPrefix (createRoot builds a fresh React root under it) :root-id, :requested, :existing, :recovery
:rf.error/root-manifest-invalid :error diagnostic S1 arm (rf2-vxgfnd S1c): ui/hydrate-root found no discoverable root manifest — hydrating mounts take root-id + identifier-prefix FROM the manifest the server render emits adjacent to the container, and server rendering, the manifest script-element convention, and hydrate preflight all land S5, so at S1 every hydrate fails loud rather than guessing identity (data {:missing :manifest}). S5 SCHEMA/WIRE arms (rf2-2mq2f, Spec 011 §Root Manifest v1): a value that is not a Root Manifest v1 — not a map, no :rf.root/schema-version, or a version other than 1 ({:invalid :schema-version :got n :expected 1}) — plus an ill-formed extension key at assembly, an unreadable manifest script body ({:invalid :unreadable}), a host-authored root container with no id ({:missing :container-id}, since the emitter never synthesises an id onto host-owned markup), and a render-time prop the EDN wire cannot carry ({:unserialisable-prop :chart-fn} — fail loud, never a silently truncated manifest, because hydration applies the manifest as the server-rendered truth). Root Manifest v1 is the versioned SUPERSET of Root Descriptor v1 — only :rf.root/schema-version is required and every extension key is optional — so an unmodified S1 descriptor NEVER trips this id. S5 HYDRATE-PREFLIGHT arm (rf2-aorfy, Spec 011 §Hydration preflight and idempotent payload install): step 1 of a hydrating root's preflight resolves its manifest — discovered positionally from the root's container, or supplied explicitly — and validates it before any frame-state moves. A container with NO discoverable adjacent manifest fails here with the SAME {:missing :manifest} data as the S1 arm above: a hydrating root takes root-id and identifier-prefix FROM the manifest (004C §3), so a missing one leaves nothing to hydrate AS, and the payload is never claimed. The remaining S5 arm per the root-identity-and-mount contract — the Layer-2 page registry's identifier-prefix conflict — lands with its stage. (Client-side identity opts at a hydrate-root SITE are statically checkable and reject at compile time, :rf.ui.compile/identity-opts-at-hydrate.) Thrown ex-info (canonical builder). Emitted by re-frame.ui.client (client) / re-frame.ssr.manifest (server emit + wire read) / re-frame.ssr.install (hydrate preflight) :use-ui-mount — client-only roots mount with ui/mount; hydration needs the S5 server-emitted manifest. Server-emit arms: :give-the-root-container-an-id, :pass-serialisable-root-props, :re-render-the-root-manifest :missing, :invalid, :got, :expected, :unserialisable-prop, :recovery
:rf.error/root-boot-failed :error always-on One root of a multi-root page failed to boot and was ISOLATED — the page's other roots hydrated and are running without it (rf2-1b0po, 011 §Failed-root isolation). Emitted once per contained root by re-frame.ssr.boot/report-root-boot-failed!, from the hydrate-page! per-root boundary, for a throw at ANY point in that root's boot: preflight (:rf.error/root-manifest-invalid, :rf.error/frame-payload-conflict), the hydrate seed, or the host's own :mount-fn. This is a DISTINCT fact from the cause, not a re-report of it: the cause says what broke, this says this root is not running and the page is live without it — the degradation an operator acts on, and the only signal that a page is quietly serving N-1 roots. always-on for exactly that reason: a root can fail in PRODUCTION, so its containment must reach an off-box shipper under goog.DEBUG=false (the :rf.error/malformed-hydration-payload precedent — an absorbed hydration-boot failure is a boundary event, not a dev teaching diagnostic). :phase discriminates :hydrate (died before its seed committed; the frame holds no server slice, and any claim it made on the payload id was released) from :mount (the seed COMMITTED and the payload stays installed, so a sibling root sharing that frame keeps running against live state). Isolation, not recovery — no retry, no supervision, no fallback render; the throwable also rides back to the caller in that root's outcome map :warned-and-continued — the failed root stays failed; the page continues with its surviving roots :root-id, :frame, :phase, :reason, :exception, :where, :recovery
:rf.error/root-not-live :error diagnostic ui/render! was called on a Root handle whose root-id is no longer the live root in the per-document registry — the root was unmount!ed, is :tearing-down, or was superseded by a newer root claiming the same id (root-identity-and-mount contract §7; rf2-vxgfnd S2c). render! is guarded like unmount! (the same membership check), but fails LOUD rather than no-op: a stale or unmounting root can never commit a render, and running its frame preflight (the :initial-events drain — IRREVERSIBLE fx) + install-record writes against a dead root-id would be side effects with NO committed render. The tearing-down arm carries :existing {:tearing-down? true}. Thrown BEFORE any preflight / render (zero side effects), unlike the pre-.18 path which drained plans and only then failed on .render against the unmounted React root. Thrown ex-info (canonical builder). Emitted by re-frame.ui.client :recreate-the-root — after any predecessor settlement, create-root + render! (or mount) a fresh root :root-id, optional :existing {:tearing-down? true}, :recovery
:rf.error/frame-payload-conflict :error diagnostic Two DIFFERENT roots declare ONE frame-id with DIFFERING config fingerprints (root-identity-and-mount contract §7). BUILD tier (S1c): two static frame plans for one frame-id with differing config fingerprints, rejected at macro expansion (within one root form, or across root sites in different files within one build); thrown by re-frame.ui.compiler.root. RUNTIME preflight tier (S2c, rf2-vxgfnd.9): at ui/mount / ui/render! frame ENSURE, an arriving plan whose fingerprint differs from the installed frame's recorded plan — installed by a DIFFERENT root — fails EXACTLY the arriving root; the installed frame and the roots already using it are untouched (004C §7 failure scoping). A SAME-root re-declaration (an HMR config edit) is a surgical refresh, not a conflict; a matching fingerprint is the idempotent no-op (no re-seed) — admitted, on both substrates, ONLY while the arriving plan can PROVE it still owns the incarnation live under the id (the value its install recorded carries the frame's :rf.frame/incarnation-token, and that token is identically the live frame's now); the incarnation-staleness cases where it cannot prove that are the BOOT-AUTHORITY arm below. RUNTIME BOOT-AUTHORITY arm (rf2-vxgfnd.56): a CONFIG-BEARING plan met a frame this root does not OWN — a live plan-less boot rf/make-frame, or one already recorded as ADOPTED. Adoption is create-if-absent SCOPING, never an ownership transfer, so the plan cannot install or refresh its config over boot authority; it fails loud instead of silently discarding the config, and the boot frame's config, generation, and images are untouched. On the Freehand interpreted-mount surface (re-frame.freehand.root) this arm ALSO covers the two incarnation-staleness cases an equal fingerprint cannot wave through: a same-id SUCCESSOR — the installed incarnation destroyed and re-created under the id, so the recorded token names a torn-down frame — and a token-less LEGACY row a defonce ledger carried across a reload. Each is a live frame the row cannot prove it owns, refused under the SAME recovery even for the ORIGINAL plan-author, because :installed-by / :plan-author is a diagnostic LABEL and never proof of liveness; that refusal and the still-live no-op are the two halves the Freehand root sequences pin. This arm recovers differently — see the Recovery column. One frame, one plan: frame config belongs in one boot/root site. S5 HYDRATE-PREFLIGHT tier (rf2-aorfy, Spec 011 §Hydration preflight and idempotent payload install): a referenced payload id already installed with a DIFFERENT CONTENT DIGEST — the distinct S5 conflict trigger 004C §7 reserved under this same id. A page is N roots referencing M frames, so several roots routinely hydrate one frame off one page-wide __rf_payload; the first installs it, a later root carrying the SAME payload is the ratified idempotent no-op (no re-seed — :replace-frame-state makes a re-seed a silent RESET, not additive corruption), and a later root carrying a DIFFERENT payload for that id fails EXACTLY that root. Payload ids ARE frame ids (004C §6). Typically a page composed from fragments rendered by two different server responses. Thrown BEFORE any install: the live payload, the frame it seeded, and the ledger record are untouched (004C §7 failure scoping). Carries its own content-:digest slot inside :installed / :arriving. Thrown ex-info (canonical builder). Emitted by re-frame.ui.compiler.root (build) / re-frame.ui.frames (runtime preflight, compiled-view substrate — whose separate incarnation-loss id is :rf.error/frame-preflight-lifecycle-loss) / re-frame.freehand.root (the Freehand interpreted-mount preflight ownership arm — the boot-authority and incarnation-staleness refusals above) / re-frame.ssr.install (S5 hydrate preflight) :align-frame-plan-config — keep the frame's config in one root site, or align the configs. Runtime BOOT-AUTHORITY arm: :scope-config-less-or-own-the-lifetime — either boot the frame WITHOUT config and scope it with a config-less [frame-root {:id …}], or drop the boot rf/make-frame and let the frame-root own the lifetime. S5 hydrate arm: :render-the-page-from-one-response :frame-id, :fingerprints + :sites (build arm) / :installed + :arriving (runtime arms) — :installed is the EXTERNAL PROJECTION of the install/adopt record, never the record itself: :config-fingerprint plus :installed-by or :adopted-by + :adopted true, optionally with the attempt-evidence flags :committed / :mount-incomplete / :preflight-attempt-failed; the record's internal :rev is STRIPPED (004C §7.1) / :payload-id + :installed + :arriving, each carrying :digest (S5 hydrate arm), :recovery
:rf.error/custom-element-conflict :error diagnostic Two DIFFERENT sources declared ONE custom-element tag with non-rf=-equal declarations — the RUNTIME arm of the single cross-source declaration law (rf2-vxgfnd.143, delegated ruling 2026-07-15 Option A; placement amendment 2026-07-19). For a given tag the effective declaration is the unique value contributed by every live source across the one JS realm iff every cross-source pair is rf=-equal — the runtime custom-elements registry is keyed by tag alone (realm-wide), so [build-id ns-sym] is DECLARER PROVENANCE (the anchor a contradiction names), never a partition key: two sources with different build-ids that classify one tag differently contradict (the compile-time sibling's per-build registry, by contrast, permits different manifests in different builds — separate builds are separate compilation units, not one realm). rf=-equal duplicates CO-EXIST (several namespaces may legitimately state the same fact, and the live entry records the SET of equal declarers, not one canonical owner) and a source re-declaring its own tag REPLACES its row only while it is the SOLE declarer of that tag (a source never conflicts with itself — that is what a hot reload and a REPL re-eval do; but while any OTHER source still co-declares the live value, a non-rf=-equal re-declaration is a contradiction, not a self-replacement); anything else is a contradiction and fails ATOMICALLY. The declaration is rejected without writing, so the last-known-good manifest — and every consumer's property classification — is exactly what it was; re-frame.ui picks no winner by evaluation, sort, or reload order. Every contradicting side rides the error: :declarations is a deterministic SORTED VECTOR of [build-id ns-sym] anchors — one {:build :ns :properties} entry per contradicting declaration, enough to name and fix the contradiction, never a fixed pair and with no promised exhaustive cardinality (direct admission names every equal incumbent declarer plus the arrival; the staged reconciliation fold stops at the first deterministic contradictory witness) — so the evidence is identical under every permutation of source names, evaluation order and build ids, and the source that happened to load second decides only WHERE the failure is raised. In :advanced production (reload-ledger? false — no ledger, rf2-k9yuy) the law's residence is the direct write-element! barrier: the single custom-elements swap register-custom-element! performs, the one registration path that survives :advanced + goog.DEBUG=false. Reachability is dev/test AND production (that write barrier is not goog.DEBUG-gated — a contradictory declaration is a property-classification error that changes what ui/spread sends to the DOM, it can reach a release build, and a law enforced only under goog.DEBUG is the shape of two shipped defects, rf2-2hkfy, rf2-5pr75), but it is a pure throw-error! that does NOT fan out on the always-on error-emit listener, so it rides the diagnostic channel for catalogue purposes (the thrown-ex-info-is-diagnostic rule, the same posture as :rf.error/dispatch-disconnected and :rf.error/flush-convergence-exceeded). Pinned from inside the release bundle by re-frame.ui.custom-element-reload-elision-prod-test. On dev/JVM hosts (reload-ledger? true) the admission authority is instead ledger-state: a direct registration is admitted against the ledger's own ::sources projection inside one atomic transition (write-through-verdict) and a staged reload is decided on the reconciled value in commit-reload!, each rejecting a contradiction atomically and publishing nothing, with the live custom-elements aggregate a published PROJECTION of the ledger. Both hosts apply literally the same pure admit law — the aggregate re-derivation over ::sources is defence in depth against the barrier, never a second winner rule. The COMPILE-time sibling :rf.ui.compile/custom-element-conflict is the same law at macroexpansion and is catalogue-exempt per the :rf.ui.compile/* reservation in Conventions — compile-time only, never emitted at runtime. Thrown ex-info (canonical builder). Emitted by re-frame.ui.rules :align-custom-element-declarations — delete the duplicate declaration, or make all declaring sources declare an IDENTICAL :properties set (identical declarations may co-exist). One tag has one property manifest :tag, :declarations (a sorted vector of {:build :ns :properties}, one entry per contradicting source), :recovery
:rf.error/frame-preflight-lifecycle-loss :error diagnostic RUNTIME preflight ENSURE (S2c) bound its plan decision to the EXACT frame authority and the authority was LOST mid-preflight, so ENSURE cannot honour it — fail CLOSED rather than mount over an absent frame (root-identity-and-mount contract §7; parent rf2-vxgfnd.191, bounded core rf2-5svfa1). Three arms of one failure class, discriminated by :kind: :ensured-frame-lost — publication was rejected: the exact incarnation this :install / :refresh / :adopt had to leave live was destroyed, closed, or replaced before its plan record could be published (the usual cause is a self-destroying setup: an :initial-events handler destroys the very frame it is seating). Previously the executor SILENTLY skipped the write and returned a normal receipt, so the client mounted a host root scoped to an absent frame — there is no frame-liveness guard between preflight and createRoot. :refresh-target-replaced — a :refresh decided against the incarnation live at decision time, but that incarnation was destroyed or replaced by a different same-id incarnation before the surgical make-frame could apply; applying the root's config to an unrelated replacement would silently overwrite it, so it fails BEFORE mutating. :found-live-authority-lost — a found-live no-op lost either its exact live incarnation or its exact install/adopt record after the phase-1 decision; a stale found-live receipt must not confer settlement rights over an absent or replacement lifetime, so it fails closed and the host re-mounts to re-decide against the current authority. Thrown out of preflight before any createRoot / live-root registration / DOM / committed plan record (Q49); siblings the run already wrote are marked by the executor's abort. EVERY publication rejection is fail-closed under this id — whether the exact incarnation is now absent, still present but CLOSING, or replaced by a same-id successor. The authority-scoped per-id reservation excludes a foreign concurrent destroy, so a rejection can never be a silent successful ENSURE. Thrown ex-info (canonical builder). Emitted by re-frame.ui.frames (runtime preflight) :keep-the-preflight-frame-live — remove the self-destroying setup (an :initial-events handler must not destroy its own frame) or mount after the teardown settles; for a replaced refresh or a lost found-live, re-mount so preflight re-decides against the current frame authority :frame-id, :root-id, :kind (:ensured-frame-lost / :refresh-target-replaced / :found-live-authority-lost), :recovery
:rf.error/frame-preflight-overlap :error diagnostic A runtime frame-plan preflight could not atomically reserve its complete set of planned frame ids because one id was already held by an in-flight core construction/destruction or UI preflight transaction (rf2-vxgfnd.191). The UI translates core's :rf.error/frame-construction-in-progress at this boundary so the caller can distinguish a mount/render preflight loss from a direct frame-construction loss. Admission is fail-fast on both hosts BEFORE phase-1 decision or mutation: same-id nested and cross-thread attempts never wait inside setup, adapter, publication, or teardown callbacks; disjoint ids and plan-free runs remain independent. Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.ui.frames :retry-after-frame-preflight — the arriving preflight throws; retry the mount/render only after the owning frame transaction settles :frame-id (the contended id), :root-id (the arriving root), :reason (the core construction-contention reason), :owner-kind, :recovery
:rf.error/frame-preflight-evidence-mismatch :error always-on A post-preflight host-boundary settlement (finalize after commit or abort after failure) presented a receipt write that no longer has exact settlement authority: its outer receipt root differs from the write root, its installed-plan record is missing, its revision was overtaken, or the current record belongs to a different installed/adopted root (rf2-vxgfnd.191). The rejected entry NEVER mutates the current record; other receipt entries whose revision + root authority still match settle normally. Emitted exactly once per rejected entry after the registry CAS wins, through error-emit/emit-error-both!: the always-on record survives production and the dev trace is its diagnostic twin. Emitted by re-frame.ui.frames :logged-and-skipped — skip the unauthorized settlement entry, preserve the current/missing record state, and continue settling independently authorized siblings; investigate stale or foreign receipt ownership shared: :phase (:finalize / :abort), :reason (:receipt-root-mismatch / :record-missing / :record-revision-mismatch / :record-root-mismatch), :receipt-root-id, :write-root-id; always-on record: :frame, :record-root-id; dev-trace tags: :frame-id
:rf.error/defview-bad-args :error diagnostic A v/defview declaration does not match (v/defview name docstring? opts? [props] body …). SEVEN arms: (a) the shape is not one of the four legal spellings; (b) the parameter vector does not hold exactly one parameter — a view takes one props map and there are no positional view arguments; (c) the declaration carries NO body, which would expand into a view that quietly renders nothing (an intentional no-output view is written with an explicit nil body); (d) an option key is outside the closed roster — including a RESERVED option whose owning slice has not landed (the props-schema options), because an accepted-and-ignored option is a declaration that means something other than it says and a one-character typo would otherwise produce valid code with different semantics; (e) :compiled is neither true nor false; (f) :children-policy is outside the closed #{:none :optional :required} roster; (g) a literal :props schema names a RESERVED call-ABI slot — :key, which is stripped before props are delivered, or :children, which arrives as trailing forms under :children-policy — so the schema would advertise a prop a caller and a view body can never exchange. Raised at MACRO-EXPANSION time, which happens on the JVM for both compilation targets, so a malformed declaration never becomes a boundary that can only fail later at a call site. Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand. Per 004 §The descriptor and v/defview :fix-the-declaration — give the view a name, an optional docstring, an options map drawn from the closed roster, exactly one parameter vector holding the props map, and a body; destructure the props map rather than declaring positional arguments :view (the declared symbol), :params (arm b — the declared count), :unknown-options (arm d — the offending keys, sorted), :compiled (arm e — the offending value), :children-policy (arm f — the offending value), :reserved (arm g — the reserved slots the schema named, in declaration order), :reason, :recovery
:rf.error/view-called-directly :error diagnostic A view declared with v/defview was CALLED as a function — (the-view props) rather than mounted as [the-view props]. A declared view is mounted, never invoked, so the call cannot succeed; the descriptor implements the host call protocol for EXACTLY this reason, so the programmer meets a message naming the three legal recoveries instead of the host's raw cast failure (D002, amended 2026-07-22 — the law is the PROPERTY "a declared view cannot be successfully called", and how a host achieves it is an implementation detail). The consequence a reader must know: (ifn? the-view) is true, and it is not a proxy for mountability — head classification and tooling ask v/view?. The roster covers every arity the host's call protocol declares; past ClojureScript's twenty-argument ceiling the call still cannot succeed, but the message is the host's Invalid arity. A declared HOST is the same law at the other boundary, and raises this same id from v/defhost: v/defhost mints a non-callable descriptor carrying the same complete call protocol, because a map would otherwise answer (the-host {…}) as a LOOKUP — returning nil and rendering nothing, which is the silent failure the boundary exists to remove exactly where a hand arriving from React is most likely to call by habit. The host arm's :recovery is the single :mount-it rather than the view's three-way one: there is nothing to inline, and the shared work a caller might extract IS the registered React component, which the caller may call directly. Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand. Per 004 §A declared view cannot be called and 004 §Qualified host leaves :mount-it-inline-it-or-extract-a-helper — MOUNT it, writing [the-view {…}]; INLINE it, declaring it with a plain defn and keeping the parentheses if the body should run inside the caller with no boundary of its own; or EXTRACT the shared work into a plain defn helper that the view mounts and the caller calls; the v/defhost arm's recovery is :mount-it — MOUNT it, writing [the-host {…}], or call the React component you registered rather than the Freehand descriptor that declares it :view-id (the declared view, or the declared host on the v/defhost arm), :reason, :recovery
:rf.error/view-bad-head :error diagnostic A vector head is none of the three legal Freehand forms — a view declared with v/defview (an internal boundary), a keyword (a DOM or custom element), or a declared host descriptor (a foreign boundary). Vector-head classification is TOTAL: there is no fourth case, no bare-function head, no string head, and no duck-typed component detection, so a head outside the roster is a loud reject rather than a silent mis-render. The message names all three legal forms, and for a CALLABLE head additionally names the plain-helper recovery — the mistake this arm catches in the field is a defn used as a vector head. The offending head rides as a bounded SHAPE summary, never as the value itself (per 015 §Data-Classification). Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand. Per 004 §Vector-head classification :use-a-declared-view-an-element-keyword-or-a-host-descriptor — mount a declared view as [the-view props], write an element as [:div …], or cross to a foreign component through a declared host descriptor; for a plain function, declare it with v/defview or call it with parentheses as an inline helper :legal-heads (the closed roster), :head (a shape summary), :reason, :recovery
:rf.error/view-bad-props :error diagnostic An internal boundary call's props slot violates the props contract its view declared. THREE arms, distinguished by message and :recovery. (a) MISSING OR NON-MAP — the call carries no props map, or something other than a map, in the slot; the grammar is [the-view {…} & children] and {} is the spelling for a view that needs nothing. (b) CALLER-AUTHORED :children — the props map carries the reserved :children key, which is how trailing children arrive; permitting both would give children two paths, one silently shadowing the other. (c) UNDECLARED PROP — the view declares a :props schema, which closes its props map, and the call supplies a key the schema does not name. DEV-ONLY: a schema is a compile-time and tooling fact, so this arm is elided from production builds, and the compiled tier reports the same breach at a literal call site at BUILD time under :rf.ui.compile/undeclared-prop instead — the two axes differ in when a violation surfaces, never in which props are legal. A declared HOST (v/defhost) raises this same id, from v/defhost, at the foreign boundary. Arms (a) and (b) hold there UNCHANGED — they are the boundary CALL ABI, not a per-kind rule, so the props slot and the reserved :children key read identically at either boundary and only the message's spelling changes to [the-host {…}]. A host adds THREE arms of its own — two from the three disjoint planes a host call splits into, and one from the fact that a host prop is not an attribute. (d) WRONG CARRIER AT A DECLARED CALLBACK POSITION — a position the declaration named in :callbacks takes the carrier its role calls for, so a value that is not a carrier is refused (a bare event vector INCLUDED: Freehand does not silently convert one at a foreign position, because the host may itself want a vector there and guessing would confuse a host value with re-frame intent), and so is a carrier of the other role, since a v/event names one event vector while a v/handler is imperative work whose return is ignored. nil is a legal empty position — a declaration names the positions a host HAS, not the ones a call must fill. (e) A CARRIER OR FUNCTION IN AN ORDINARY SLOT — an ordinary prop is DATA, and a callback reaches a host only at a position the declaration named, which is what keeps the site finite, checkable, and able to carry D008's committed identity. (f) AN ELEMENT FORWARDING FORM AT THE HEAD — v/spread and v/spread-safe fold onto an element and a host head is not one (004 §Props forwarding), so a forwarded map is refused rather than run through the attribute grammar. That grammar is wrong in both directions at once here: it ADMITS what this boundary refuses, because an alias of a slot-owning key is rewritten on the way through — :className, the name a React library actually reads, arrives as :class, a prop the component does not read, and the string "class" arrives laundered past the exactness law — and it REFUSES what this boundary accepts, because :class-name, a mixed-case data-* and :key name nothing on a foreign prop ABI the attribute table is entitled to judge. Both forms answer a plain map, so the refusal reads a MARK the forms leave on their own result rather than inspecting keys, and it runs BEFORE the exactness law, because a spread has already rewritten the very names that law reads. Arm (c) stays a v/defview arm: :props evidence on a host declaration is optional and inert, so a host closes no props map. Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand. Per 004 §Props, children, and :key, 004 §Qualified host leaves and 004D §Props schemas arm (a) :supply-one-props-map — pass exactly one map, {} when the view needs nothing; arm (b) :pass-children-as-trailing-forms — write the children after the props map, [the-view {…} child-1 child-2]; arm (c) :match-the-declared-props-schema — add the prop to the schema, correct the spelling, or declare the view open with [:map {:closed false} …] if it really does forward arbitrary props; arm (d) :supply-the-declared-callback-carrier — wrap the intent in the carrier the position declares, (v/event [& args] the-vector) or (v/handler [& args] …); arm (e) :declare-the-callback-position — add the prop to the host's :callbacks map with the role it plays; arm (f) :forward-to-a-host-with-an-ordinary-mapmerge, the caller's remainder as the base and the props you own second, so the host's own naming law judges every key that arrives :view-id (the declared view, or the declared host on the v/defhost arms), :props (arm a — a shape summary), :undeclared + :declared (arm c), :prop (arms d–e — the offending position), :role (arm d — the role the position declares), :supplied (arm d — the role the carrier actually plays), :value (arms d–e — a shape summary), :form (arm f — which forwarding form was used, v/spread or v/spread-safe), :reason, :recovery
:rf.error/view-control-address-missing :error diagnostic A WRITABLE semantic controller was rendered with no :control address. A controller record is keyed by the pair (controller kind, caller-supplied address), and the address half is mandatory: there is no default and no synthesised value, because every controller that skipped one would share a single record keyed by nil — a collision that presents as one field editing another and has no local explanation at either site. Deliberately NOT derived from renderer occurrence identity (D004): a derived anchor turns a sort, a view rename, a parent extraction, an isolated story render, or a virtualized remount into a silent state migration, whereas a domain address survives all of them. Raised where the record key is formed, which is what makes a controller writable at all — a props-only view never reaches it. Two occurrences sharing ONE address is deliberate sharing and is NOT diagnosed. The props map rides as a bounded SHAPE summary, never as the value itself (per 015 §Data-Classification). Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand.control. Per 004 §Controller identity :supply-a-control-address — pass the domain identity that owns the state, :control [:invoice invoice-id :amount]; or, when the control has no state of its own, drop the controller and take the value and the intent as ordinary props :kind (the controller kind), :props (a shape summary), :reason, :recovery
:rf.error/view-control-address-nil :error diagnostic A WRITABLE semantic controller was rendered with :control nil — the prop is PRESENT and its value is nil. A SEPARATE category from :rf.error/view-control-address-missing, because it is a different mistake with a different fix: an absent prop is a call site that forgot the address, while an explicit nil is a call site that supplied one from an expression that answered nothing — a route parameter not yet resolved, a subscription that has not landed, a lookup on a key that moved — so the repair is UPSTREAM of the render rather than at the call, and a diagnostic saying the prop was absent points at the one place the mistake is not. nil is not in the value domain of an address: every controller passed one would share a single record keyed by nil, which is precisely the collision the mandatory address exists to prevent. PRESENCE decides, never truthiness — false, 0 and "" are ordinary addresses and pass unremarked. The props map rides as a bounded SHAPE summary, never as the value itself (per 015 §Data-Classification). Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand.control. Per 004 §Controller identity :fix-what-produced-the-nil-control-address — repair the expression that answered nil, or render the control only once its domain identity exists; if the control has no state of its own, drop the controller and take the value and the intent as ordinary props :kind (the controller kind), :props (a shape summary), :reason, :recovery
:rf.error/view-control-reset-revision-missing :error diagnostic A BUFFERED semantic controller — one holding a draft the user edits and commits later — was rendered with no :reset-key. The reset generation is the caller's own revision, and it is REQUIRED rather than optional (D016): a caller rejects a draft by establishing a new baseline decision, and rejection is frequently spelled by reasserting the value the caller already had, so nothing derived from the value can observe it and a control watching the value keeps the refused draft on screen. Optional would be the worse design and not the friendlier one — a control with no generation buffers correctly right up to the first rejection and then loses it, a defect that reaches production because development never rejects anything. There is no inferred, defaulted or value-derived generation. A caller that genuinely never resets passes a stable literal, which states that rather than leaving it silent. Raised where the generation is taken, so a controller that holds no draft never reaches it. The props map rides as a bounded SHAPE summary, never as the value itself (per 015 §Data-Classification). Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand.control. Per 004 §The buffered controller and the reset generation :supply-a-reset-key — pass the revision the caller advances when it establishes a new baseline, :reset-key (v/sub [:invoice/amount-revision invoice-id]); or, when the control never needs an external reset, pass a stable literal such as 0 :kind (the controller kind), :props (a shape summary), :reason, :recovery
:rf.error/view-control-reset-revision-nil :error diagnostic A BUFFERED semantic controller was rendered with :reset-key nil — the prop is PRESENT and its value is nil. The sibling of :rf.error/view-control-address-nil, and a SEPARATE category from :rf.error/view-control-reset-revision-missing for the same reason: an absent prop was forgotten, whereas an explicit nil came from an expression that answered nothing — an uninitialised counter read before its baseline exists is the ordinary source, and the repair is upstream. nil is not in the value domain of a generation: the fence answers NOT CURRENT for an unstamped record, so a nil generation would leave every draft in the control permanently invisible while the control went on accepting keystrokes — the buffered defect the fence exists to prevent, wearing the fence's own clothes. 0 is the literal that says "this control never externally resets"; nil says nothing. The props map rides as a bounded SHAPE summary, never as the value itself (per 015 §Data-Classification). Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand.control. Per 004 §The buffered controller and the reset generation :seed-the-callers-reset-revision — seed the revision the caller advances when it establishes a new baseline, or pass a stable literal such as 0 when the control never needs an external reset :kind (the controller kind), :props (a shape summary), :reason, :recovery
:rf.error/view-children-policy :error diagnostic A boundary call violates the children policy its view declared. TWO arms: children supplied to a :children-policy :none view, and no children supplied to a :children-policy :required view. The policy is descriptor metadata (outside the props schema), and it is enforced at the call — an unenforced policy would be documentation rather than a contract. The same two arms hold at a declared HOST, and raise this same id from v/defhost, against the policy the declaration states under its REQUIRED :children key — the same closed #{:none :optional :required} roster, spelled :children and carrying no default, because Freehand never executes the registered component on the JVM and a default would be the substrate choosing a server behaviour silently. Only the message differs: a host's children become ordinary React children in the registered component's own tree, so a host declaring :none has nowhere to put them, and undeclared child crossing is a refusal rather than an opaque accident. Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand. Per 004 §Props, children, and :key and 004 §Qualified host leaves :match-the-declared-children-policy — pass the content as a prop for a :none view or host, supply children as trailing forms for a :required one, or relax the declared policy :view-id (the declared view, or the declared host on the v/defhost arm), :children-policy (the declared policy — a host's :children value), :children-count, :reason, :recovery
:rf.error/view-lowering-unavailable :error diagnostic A declared view was mounted through an emitter that cannot run the lowering it carries. ONE arm today: a {:compiled true} declaration reached a browser carrying only its host-neutral STRUCTURAL lowering. A compiled declaration acquires its React lowering when the ClojureScript compiler expands it, so a descriptor built by a JVM expansion has none — the two lowerings are exclusive and neither substitutes for the other (per 004D §Selecting the compiled tier). This is a LOUD refusal rather than a walk of nil, because a compiled view quietly rendering nothing in a browser is the worst way to learn a lowering is missing. Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand.react. :render-structurally-or-drop-the-compiled-marker — render the view through the structural tier, or drop {:compiled true} to mount it interpreted; the declaration is the only thing that changes either way :view-id, :reason, :recovery
:rf.error/view-bad-event :error diagnostic A Freehand event position, or the value an event site produced, is outside the closed event grammar. THE ARMS. (a) NOT ONE OF THE DECLARED FORMS — an event position takes an event vector, an options map carrying :event, one of the four declared callback forms (v/event, v/handler, v/render-fn, v/raw-fn), a plain function, or nil; classification is TOTAL, so anything else is a loud reject naming the roster rather than a callback whose invoker, phase and identity contract are guessed. (b) BAD OPTIONS MAP — the map carries a key outside the closed listener-option roster (:event, :prevent-default, :stop-propagation, :once, :passive, :capture), its :event is not a vector, or it pairs :passive true with :prevent-default true: both are roster members and they contradict each other, because a passive listener promises the browser it will never call preventDefault, so one of the two would silently do nothing — a failure membership alone cannot see, refused by the canonical options plan the interpreted and structural tiers both ask and by the compiled tier as :rf.ui.compile/contradictory-handler-options (per 004D §Template grammar), so the three tiers reach one verdict. An option that silently does nothing is an event site that looks correct and is not. (c) NOT ONE EVENT VECTOR OR nil — the site yielded something that is neither, INCLUDING a vector of event vectors: one user action is one semantic event whose re-frame handler returns the effects the step needs, which keeps one inspectable causal unit instead of a miniature dispatcher in the view. (d) A PROJECTION AT POSITION ZERO — position zero is the event id, and a projection — a named marker or a ::v/read door — fills an ARGUMENT position. (e) A NARROWED CONTROL PROP — v/route-link's :on-click is the imperative pre-navigation seam, whose accepted grammar is narrower than a general event position: a plain function, a v/handler, or nothing. The route click already produces the ONE routing intent, so an event vector, an event options map or a v/event there would be a second intent site on the same click and is refused at render on both hosts. (f) A MALFORMED CALLBACK DECLARATION — v/event / v/handler / v/render-fn are spelled (v/… [args …] body …), so a declaration whose parameter slot is not a vector is refused where it is written; and a v/render-fn parameter vector may not be variadic, because v/slot invokes a render-fn at a FIXED arity and the declaration must say exactly how many arguments the slot supplies (the compiled analyzer reaches the same verdict at its own tier, so an authoring form a compiled build cannot compile is not one the interpreted door accepts). (g) AN INVOKED ROSTER CARRIER — a declared callback is a VALUE Freehand materializes at an event position it walks, not the function it wraps, so a direct call cannot SUCCEED. A carrier implements its host's call protocol solely in order to throw here, naming the roster form, the position and the recovery where a raw cb.call is not a function names none of them; reaching a caller at all means the carrier was authored where Freehand does not walk, and in practice that is one position — the raw #js props of a foreign createElement, which are the library's own ABI. The diagnostic rides exactly as far as that protocol reaches, so a native JavaScript props.onPing(…) still meets the host's own TypeError instead. Per 004 §Callback roles and identity, which states the law and requires the diagnostic to name a recovery; the keyword it must name is this row's. (h) A MALFORMED PROJECTION READ — a general read door is [::v/read <path>] whose path is ONE property keyword or a non-empty vector of keywords read as a chain from the event, and the read must resolve to a SHALLOW SCALAR; a door of any other shape, or a read landing on a host object or a collection, is not intent data and belongs in a v/event body. Arms (a)–(b) and (e) raise at render, arms (c)–(d) and (h) at firing, arm (f) at macroexpansion where the declaration is written, and arm (g) at the call it refuses; nothing is dispatched on any of them. The offending value rides as a bounded SHAPE summary, never as the value itself (per 015 §Data-Classification). Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand.events (every arm but (e)) and re-frame.freehand.route-link-seam (arm (e), the owned :on-click position). Per 004 §Event intent and the payload materializer Every arm names its own disposition; none takes the builder's :no-recovery default. Arm (a) :use-one-of-the-declared-event-forms; arm (b) :use-the-closed-listener-options / :supply-a-vector-event / :drop-passive-or-prevent-default; arm (c) :yield-one-event-vector-or-nil — name the intent once and let its handler return the effects; arm (d) :name-the-event-id-at-position-zero; arm (e) :use-a-plain-fn-or-v-handler — imperative pre-navigation work is v/handler's role, and an application reaction belongs behind the routing event; arm (f) :fix-the-callback-declaration / :fix-the-render-fn-parameter-vector; arm (g) :close-over-capture-frame-dispatch — write a plain closure over (rf/capture-frame)'s :dispatch, captured DURING the render because the render scope has unwound by the time the library calls back, or reach the node through a v/defbehavior when the listener's identity or its retirement is what matters; arm (h) :write-a-read-door-as-a-keyword-or-keyword-path / :read-a-shallow-scalar-or-use-v-event :legal-forms (arms a and e — the closed roster legal at that position), :value (arms a, e and h — a shape summary), :unknown-keys + :legal-keys (arm b), :contradiction (arm b — the mutually exclusive pair), :event (arms b–c — a shape summary), :projection (arms d and h), :prop (arm e — the owned control prop), :params (arm f — a shape summary), :role (arm g — the carrier's roster role), :marker (arm h — a shape summary of the malformed door), :reason, :recovery
:rf.error/view-missing-payload :error diagnostic A Freehand event site asked for a reserved scalar projection (::v/value, ::v/checked, ::v/key, ::v/scroll-top, ::v/new-state) that the callback which fired it does not supply — asking a click for a key, or a non-input target for a value. Raised at FIRING time by the one pure materializer, and NOTHING is dispatched: a malformed event vector reaching a handler is worse than no event, and a silently nil argument is worse still. The marker and the payload key are the same keyword, so availability is one lookup; the diagnostic names what the callback DID offer so the fix is visible without a debugger. Distinct from :rf.error/view-bad-event, which is a grammar reject — this site is well-formed and simply asks its host for something that host has not got. Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand. Per 004 §Event intent and the payload materializer :ask-for-a-projection-the-callback-supplies — request a projection this site's own callback carries, or convert the argument explicitly with v/event :projection (the requested marker), :available (the payload keys the callback offered), :event-id, :event (a shape summary), :reason, :recovery
:rf.warning/view-retired-callback :warning diagnostic A Freehand callback fired after its event site was RETIRED — the view unmounted, the node was replaced or re-keyed, or the render that published the callback was never selected for commit. The proxy stays callable, because a foreign listener may already hold it, and is INERT: it dispatches nothing rather than firing into whatever owns that node now, which is the silent cross-owner dispatch this evidence exists to make visible. Emitted once per invocation of a retired site, carrying the owner's lifecycle so an abandoned-render leak (:new) reads differently from a post-unmount listener leak (:retired). Dev-only (interop/debug-enabled?-gated and DCE'd from production). Emitted by re-frame.freehand.events. Per 004 §Callback roles and identity :warned-and-continued — the callback is inert and dispatched nothing; unregister the foreign listener in the boundary that created it so it cannot outlive its view :view-id, :site-id, :state (the owner's lifecycle), :reason, :recovery
:rf.warning/view-top-layer-unreconciled :warning diagnostic A Freehand element declares a CONTROLLED top-layer desired state (::web/popover-open? / ::web/modal-open?) with no handler for the browser's own dismissal. Escape, a light dismiss and a dialog's own close button all close the node WITHOUT asking the application, and the substrate writes no application state on their behalf — so the desired state still says open and the next commit re-opens it. Published from the COMMITTED callback ref, never from the construction of props: a render the host may restart or abandon declares nothing, and evidence drawn from one would accuse an author of a mistake the page never made. Dev-only (interop/debug-enabled?-gated and DCE'd from production). Emitted by re-frame.freehand.top-layer. Per 004 §Browser dismissal, and what the substrate never does :warned-and-continued — the declaration stands and the node still opens; handle one of the listed positions with ordinary event intent and move the state that drives the property :tag, :mechanism (:popover / :modal), :handlers (the event positions that reconcile it), :reason, :recovery
:rf.warning/view-top-layer-refused :warning diagnostic The browser REFUSED a Freehand top-layer host call — showPopover() / hidePopover() / showModal() / close() — because the node is not in the document, or the element is already open through the other mechanism (an already-open non-modal dialog cannot be promoted with showModal()). The call is mechanical and the failure is an authoring mistake the next render can fix, so the refusal is neither thrown (that would take the page down) nor swallowed (that would be silence), and the commit's remaining operations still run. Published from the host call itself — a selected occurrence by construction. Dev-only (interop/debug-enabled?-gated and DCE'd from production). Emitted by re-frame.freehand.top-layer. Per 004 §Browser dismissal, and what the substrate never does :warned-and-continued — the refused operation did not happen and the rest of the batch did; render the node before asking for it to be open, and keep one mechanism per element :host-call, :tag, :element-id, :exception (the browser's own reason), :reason, :recovery
:rf.error/view-read-outside-render :error diagnostic A Freehand reactive read happened with NO active declared render. A view records the reads its OWN render makes so the selected commit can own exactly them; a read with no render to belong to has no owner, so nothing would ever release it — an ownership leak that presents later as a subscription nobody can account for. Raised BEFORE the target is resolved or probed, so the refused read performs no observation work at all. The common causes are a read from a timer, a promise callback, a foreign listener, or a REPL — all of which want the frame-explicit one-shot read instead, which resolves, probes, returns and releases without installing a view dependency. A STRUCTURAL TEST is the one caller that wants neither: it renders the view as written inside the re-frame.freehand.test bracket (t/with-render), which opens a discardable render and publishes nothing. The query rides as a bounded SHAPE summary, never as the value itself (per 015 §Data-Classification). Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand.cell. Per 006 §Same-render-thread capture :read-inside-a-declared-render-or-use-a-one-shot-read — read state inside a v/defview body, render inside t/with-render in a structural test, or take the frame-explicit one-shot read outside both :query (a shape summary), :reason, :recovery
:rf.error/static-render-requires-runtime :error diagnostic A v/render-static render produced a structural tree still carrying a LIVE capability the inert HTML fold would drop. ONE arm today: a committed event handler reached from an INTERPRETED view body. The static-page path emits no hydration payload and nothing adopts its output, so a folded-away handler is a control that will never do anything. It is the same no-silent-elision law the compiled tier proves at BUILD time (:rf.ui.compile/static-root-requires-runtime), proved at RENDER for the interpreted tier — which has no finite grammar, no analysis and no manifest, so there is nothing a build could read. The audit runs over the built tree, so the offender is attributed to the nearest enclosing view BOUNDARY — the declaration an author edits — alongside the element tag and the handler slots. Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand.tree. Per 011 §The server render on the Freehand paved path :mount-in-the-browser-or-move-the-live-subtree-behind-client-only — mount the tree in the browser with v/mount / v/hydrate-root, or move the live subtree behind a v/client-only whose fallback is capability-free :view-id, :tag, :capability (:handler), :handlers (the slot keys), :reason, :recovery
:rf.error/view-forked-capture :error diagnostic A Freehand reactive read reached a CHILD thread of the render that opened the capture — JVM only, because Clojure CONVEYS dynamic bindings into future / pmap / bound-fn and a single-threaded host has no such route. A render capture is single-threaded: forked reads would race one non-thread-safe capture, silently losing sites, and a capture with sites missing reaches commit as MISSING OWNERSHIP. Losing dependencies quietly is strictly worse than refusing an unsupported parallel render body, so the contract is enforced rather than engineered around. The owning thread travels INSIDE the captured value — a check held in a dynamic var or a thread-local would itself be conveyed and agree with itself — and the refusal happens BEFORE the probe, so a forked read commits no partial ownership. Thread NAMES ride the payload; the query rides as a bounded SHAPE summary (per 015 §Data-Classification). Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand.cell. Per 006 §Same-render-thread capture :read-the-sites-on-the-render-thread — read the sites on the render thread and fork only work that performs no reads; a genuinely parallel branch belongs behind its own keyed child view :owner-thread, :current-thread, :query (a shape summary), :reason, :recovery
:rf.error/view-render-failed :error always-on A Freehand v/error-boundary CONTAINED a render-class failure below it — a child body throwing, Hiccup normalization or common prop/event validation throwing, or (in the browser) a descendant foreign component throwing where React boundaries apply. The candidate that threw published nothing (the atomic shell's law); the boundary shows its :fallback, and at most ONE record per failure GENERATION is promoted onto this always-on axis (surface #4) via error-emit/dispatch-error-record! (a NON-EVENT union record, the frame-teardown-report sibling), so an off-box shipper (Sentry, Datadog) sees a contained render failure under :advanced + goog.DEBUG=false. Always-on: a render failure is production-reachable, a contained failure the user silently gets a fallback for is a correctness fact the next operation cannot see locally, and it compounds with process lifetime — all three legs of §The promotion criterion hold. The record carries the safe public summary (a stable diagnostic id :re-frame.freehand/render-failed, the failing view id, phase, fingerprint and D020 evidence), the OPAQUE exception, and a capped host/component stack an off-box shipper needs; it carries NO automatic app-db or event-history capture (EP-0036 / D019 reject option E — that is opt-in application telemetry, obtained through the author's own :on-error handler and an allow-list it owns, never the boundary default). The safe summary ALSO rides the author's optional :on-error intent — a re-frame event dispatched exactly once per generation after the fallback commits (the diagnostic-channel companion of this always-on record). Emitted by re-frame.freehand.errors (via error-emit/dispatch-error-record!). Per 004 §Error boundaries and error egress :contained — the boundary showed its fallback and tore down the failed child subtree; recovery is the caller's :reset-key, not an app-steerable policy ALWAYS-ON record (NON-EVENT): :summary (the safe public envelope), :exception (the opaque host exception), :component-stack (the capped host stack, or nil)
:rf.error/error-boundary-bad-args :error diagnostic A [v/error-boundary {…} child] call is outside the closed grammar. FOUR arms: (a) an UNKNOWN option — the roster is closed to :fallback / :reset-key / :on-error, and a one-character typo would otherwise produce a boundary that quietly means something other than it says; (b) a MISSING :fallback — a boundary with no fallback would render nothing at all on a caught failure, the one outcome worse than the failure itself; (c) an :on-error that is NOT an event-prefix vector [:domain/event …]; (d) more than ONE guarded child — the boundary guards one region, so a second declared child is refused rather than kept and discarded, which would leave a subtree off the page with nothing to say so and make the two modes disagree about one declaration. Raised at mount by the interpreted tier; the compiled tier raises its own :rf.ui.compile/bad-error-boundary at build time. Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand.errors. Per 004 §Error boundaries and error egress arm (a) :use-the-closed-error-boundary-options; arm (b) :supply-a-fallback; arm (c) :supply-an-event-prefix-vector; arm (d) :guard-exactly-one-child :unknown-options (arm a), :on-error (arm c — a shape summary), :children-count (arm d), :reason, :recovery
:rf.error/behavior-bad-args :error diagnostic A registered-behavior DECLARATION or ATTACHMENT is outside its closed grammar (Freehand's one sanctioned imperative boundary, D013). TWO planes, seven arms. DECLARATION (v/defbehavior / registration): (a) the definition is not a map, or the declaration is not (v/defbehavior name docstring? {…}); (b) a definition key is outside the CLOSED roster :timing / :opaque / :connect / :update / :disconnect / :commands — refused rather than ignored, because a behavior that silently never runs is the worst outcome available and a one-character typo would otherwise produce exactly it; (c) :timing is outside the CLOSED pair :passive / :layout — the set of moments at which host state may move is part of the contract, so there is no third value and no arbitrary schedule; (d) a lifecycle or command entry is not a function, or a command operation is not named by a keyword; (e) the declaration carries no lifecycle and no commands at all, so attaching it could do nothing. ATTACHMENT ([v/behavior {…} node]): (f) an option outside the CLOSED roster :use / :target / :config, a missing :use, an id no declaration registered, a non-map :config, or a :config carrying a NON-DATA value at any depth — a callback, node, ref or preconstructed host instance in the configuration would be a use site the structural tree cannot record and a test cannot read, so it is refused on BOTH hosts rather than only where the tree happens to be built; (g) the boundary's child is not exactly ONE element (a second child, a declared view, a fragment, a presence boundary or text), or an {:opaque true} behavior's node carries Freehand children the host would silently overwrite. Raised at registration (ns load) for the DECLARATION arms and at render for the ATTACHMENT arms, identically on the JVM and in the browser — EXCEPT that a COMPILED attachment proves its statically-decidable arms at BUILD time under the compile-tier :rf.ui.compile/bad-behavior instead (a non-literal opts map, an option outside the closed roster, a missing :use, and the one-element child law of arm (g)), exactly as v/error-boundary splits across :rf.ui.compile/bad-error-boundary. The runtime id retains every arm that turns on a runtime VALUE or the REGISTERED definition — the arm (f) :use/:config value checks and the arm (g) opaque-child law (an {:opaque true} behavior's node carrying children, provable only once the definition resolves) — which the compiled browser path reaches through the same re-frame.freehand.behaviors runtime as the interpreted twin, so the two agree by construction. A v/defbehavior DECLARATION is not compiled, so its arms stay here at registration regardless of tier; the compile-tier id carries no catalogue row of its own (the compile-time-only :rf.ui.compile/* reservation — §The compile checker report). The offending value rides as a bounded SHAPE summary, never as the value itself (per 015 §Data-Classification). Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand.behaviors. Per 004 §Registered behaviors and commands declaration arms :fix-the-declaration; attachment arms :use-the-closed-behavior-options / :name-a-registered-behavior / :require-the-declaring-namespace / :supply-a-config-map / :keep-the-config-data / :decorate-exactly-one-element / :leave-an-opaque-behaviors-node-empty :behavior (the id or declared symbol), :unknown-keys / :unknown-options, :timing / :entry / :op (declaration arms), :use / :registered / :config / :path / :child / :children-count (attachment arms), :value (a shape summary), :reason, :recovery
:rf.error/behavior-command-refused :error diagnostic A :re-frame.freehand.host/command effect could not be delivered, and is therefore REFUSED — never queued, never replayed. The frame is half a command's address, so every judgement below is FRAME-LOCAL: a connection is committed under the frame its view was mounted in, a command resolves in the frame the originating event ran in, and a connection a sibling frame committed is another frame's to command. FIVE arms: (a) the command is not a map, carries no :target, or names no keyword :op; (b) NO live behavior connection IN THE EFFECT'S OWN FRAME claims the target — a target live only in a sibling frame is as absent as one nothing ever mounted, and a future mount is driven by state and config or by a fresh event, since a retained imperative request would arrive at a node the application has since changed its mind about; (c) TWO OR MORE live connections IN ONE FRAME claim one target — uniqueness is a claim about a single frame, so two frames legitimately mounting the same declaration are two addresses rather than one ambiguity, while within one frame the address is ambiguous and the command is refused rather than delivered to whichever mounted last; (d) the behavior registers no such operation — the command roster is finite and declared with the behavior; (e) the host is the JVM structural renderer, which owns no live host object at all, so a structural test asserts the command's DATA and the mounted tier proves the host action. Every arm performs NO host work. Targets are caller-authored semantic ids and are never derived from render position, a key path or a DOM query, so a refusal is a fact about the application's own addressing rather than about the renderer. Thrown ex-info (canonical builder), not a trace. Emitted by re-frame.freehand.behaviors. Per 004 §The bounded command channel arm (a) :supply-a-command-map / :name-the-use-sites-target / :name-a-registered-operation; arm (b) :connect-the-target-or-drive-the-host-through-config; arm (c) :give-each-occurrence-its-own-target; arm (d) :name-a-registered-operation; arm (e) :assert-the-command-data-on-the-structural-host :frame (EVERY arm — the frame the originating event ran in, which is the frame the refusal was judged in), :target, :op, :behavior, :operations (arm d — the registered roster), :live (arm b — the ids claimed by live connections IN THAT FRAME, never process-wide: an id only a sibling frame claims was never reachable, so offering it would name an alternative the command could not have taken), :claims (arm c — how many live connections IN THAT FRAME claim the target), :args (a shape summary), :reason, :recovery
Schema-validation-failure per-:where recovery

:rf.error/schema-validation-failure is a single category spanning several validation boundaries, and its recovery is NOT uniform — the catalogue row's Default :recovery cell points here rather than carrying one blanket value, because a single :no-recovery would misdescribe the :fx-args and :sub-return boundaries (which recover-and-continue), and :app-db / :machine-data (which reject the candidate frame transition). The category vocabulary is stable — an existing category cannot be renamed or split (per Spec-ulation and §Error event catalogue) — so the per-boundary variance rides the :where tag and this table, not a forked category. This is the normative surface-facing mirror of Spec 010 §Per-step recovery (the emit-site of record); it is the description, not a second source of truth — if the two ever disagree, 010 wins.

Every boundary below is dev-only save one: the dev-time arms are production-elided per Spec 000 §Contract C-000.35, and the boundary interceptor :rf.schema/at-boundary is the one production-reachable exception, riding the :where :event recovery unchanged, per 010 §Production builds. Both its rejection and its record survive into production — the record structurally, on the always-on axis; only the payload-bearing dev trace elides (see the §Error event catalogue paragraph on the category).

:where Boundary — what is validated Recovery
:event The dispatched event vector, before the handler's interceptor chain runs (per 010 §Per-step recovery row 1). Skip the handler — the handler is not invoked; no :db install, no :fx. :recovery :no-recovery for this dispatch, but the run-to-completion drain continues: downstream queued events still drain.
:app-db The flow-augmented CANDIDATE :db value at a registered schema-bound path, validated BEFORE the single deferred install (rf2-uhk9ko; 010 §Per-step recovery row 4). Reject the candidate transition:rollback? true (the stable public transaction-REJECTED vocabulary), :recovery :no-recovery. The candidate is NEVER installed (app-db keeps its pre-event value; no observer — trace listener, container watch, substrate subscriber — can see the candidate), NO :rf.event/db-changed / db-noop / frame-state-changed fires, and :fx does not walk for this dispatch. Downstream queued events still drain.
:fx-args A registered fx's args map, before that fx handler runs (010 §Per-step recovery row 5). Skip only the offending fx:recovery :skipped (mirroring :rf.fx/skipped-on-platform). Sibling fx in the same :fx vector still run; the run does not halt; downstream events still drain.
:sub-return A schema'd subscription's computed value, on recompute or compute-sub resolution (010 §Per-step recovery row 6). Surface nil and render on:recovery :replaced-with-default: the sub returns nil to its consumer; views see no value. (A strict mode re-raises.)
:sub-override A dev-tool :sub-overrides pin's value against the overridden sub's own output :schema, at the subscribe deref seam (010 §Sub override). Mirrors :sub-return:recovery :replaced-with-default: the violating pinned value is replaced with nil (not surfaced), and the failure is reported.
:flow-output A reg-flow :schema'd computed :output value, after each recompute during the flow walk (010 §Flow output). Observational — write and proceed:recovery :no-recovery. The value is written (a flow output is materialised state downstream may already have read; the prior-writes-preserved contract in 013 §Failure semantics forbids unwinding it mid-run) and the cascade proceeds; the failure is reported.
:machine-data A machine snapshot's :data slot against its :data-schema — in the CANDIDATE runtime-db at macrostep / bootstrap, or at spawn / update-snapshot (010 §Per-step recovery row 7). Reject the candidate frame transition (macrostep / bootstrap: :rollback? true — the whole event transaction is rejected before install, rf2-uhk9ko), or skip the single local write (spawn / update-snapshot: :rollback? false — nothing committed, the actor never enters the runtime / the :data patch never installs). :recovery :no-recovery.
:machine-output A finishing machine's completion :output ([:schemas :output]) payload at finalize time, :phase :completion (EP-0029 A8; 005 §Completion-output validation). Best-effort — complete and proceed — the machine has ALREADY reached its final state, so there is nothing to roll back: :rollback? false, :recovery :no-recovery. The completion still flows (:on-done payload and :rf.machine/done trace unchanged); the failure is surfaced loudly so a schema typo cannot deadlock a machine.

The retired :cofx boundary. A recordable-coeffect value that violates its reg-cofx :schema is NOT a :where :cofx case of this category — that injection-time validation path was retired (per EP-0017 and 010 §Per-step recovery row 2). It is the separate production hard error :rf.error/cofx-value-invalid — emitted and thrown, halting the run before the handler — because folding an out-of-contract value into the durable causal ledger is corrupt state that must fail in production too. :cofx is therefore absent from this category's :where enum.

The :op-type column is the universal severity discriminator: :error halts or recovers a specific operation; :warning is an advisory the runtime emitted alongside continuing default behaviour; :info and :fx are non-failure success-path / lifecycle traces that share the trace envelope; :frame belongs to the :frame/* lifecycle family. Consumers branch on :op-type for severity routing and on :operation for category-specific handling.

:rf.fx/skipped-on-platform and :rf.cofx/skipped-on-platform are technically warnings not errors, but they ride the same envelope and route through the same listener path; consumers can branch on :op-type (:warning vs :error) if they want to distinguish.

History-error tag layering

The four registration-time history-grammar errors — :rf.error/machine-history-misplaced, -extra-keys, -bad-default-target, -duplicate — are raised by a pure validator (validate-machine! in the v1 CLJS reference) that runs at handler-construction time, before the machine-id is bound (make-machine-handler takes only the machine spec; the id is the separate reg-machine / reg-machine* argument). That pure layer therefore stamps only definition-relative tags: :state (the offending node's key — or :rf/root for a root-level history machine, :region for a parallel-region body), :feature :history (names the grammar family — read by Xray's error widget to route the diagnostic into the history lane), plus the per-error extra (:offending-keys / :default-target / :history-keys).

The :machine-id is the responsibility of the registrar wrapper (the reg-machine / reg-machine* surface), which is the layer that knows the registration-site id; it is NOT a tag the pure validator can supply. Unlike the runtime-fallback guard/action-ref checks (:rf.error/machine-unresolved-guard / -action, which DO surface a :machine-id when they re-fire at transition time), the history-grammar errors fire only at construction time, so the :machine-id is the registration-site id the caller already holds — the same id passed to the failing reg-machine call. Consumers correlating a history-grammar rejection to a machine therefore read the id from the call site (or the surrounding :rf.machine.lifecycle/created lifecycle trace's :machine-id), not from the validator ex-data.

Adapter-internal and test-harness categories

The catalogue's terminal tier records :rf.error/* ids emitted by a substrate adapter or the test harness rather than the re-frame2 runtime proper. They are catalogued for TOTALITY — every emitted id carries a row — but kept terse (id, one-line meaning, recovery, payload keys), and all ride the diagnostic channel (dev/test-only; never the always-on production error-emit axis). Grouped under two tier headings.

Adapter-emitted (reagent-slim)

reagent-slim reimplements Reagent (hiccup → React) inside the bundle-isolated slim adapter; these are its render-time hiccup / component shape rejections (a thrown ex-info carrying :rf.error/id).

:operation :op-type Channel Trigger / meaning Default :recovery :tags
:rf.error/as-element-fn-unregistered :error diagnostic The as-element render seam was not registered before render — require reagent2.impl.template (or reagent2.core) so its ns-load wires the seam. :no-recovery — require the ns that installs the seam :where, :reason, :hiccup/summary
:rf.error/create-class-key-unsupported :error diagnostic A create-class spec carried lifecycle keys the slim adapter does not support. :no-recovery — migrate to the supported keys (or the bridge adapter) :where, :reason, :keys, :supported-keys
:rf.error/create-class-missing-render :error diagnostic A create-class spec is missing the required :reagent-render fn. :no-recovery — add a :reagent-render fn :where, :reason, :spec/summary
:rf.error/static-markup-empty-vector :error diagnostic render-to-static-markup met an empty hiccup vector (no head tag). :supply-a-head-tag :where, :reason
:rf.error/static-markup-bad-tag :error diagnostic render-to-static-markup met a hiccup head that is not a keyword / component / fn. :supply-a-valid-hiccup-head :where, :reason, :tag/summary, :argv/summary
:rf.error/static-markup-bad-element :error diagnostic render-to-static-markup met an unrenderable hiccup child (not a string / number / keyword / symbol / vector / seq). :supply-a-renderable-child :where, :reason, :got/summary
:rf.error/template-empty-vector :error diagnostic The live-render as-element template met an empty hiccup vector (no head tag). :supply-a-head-tag :where, :reason
:rf.error/template-bad-tag :error diagnostic The live-render as-element template met a hiccup head that is not a keyword / component / fn. :supply-a-valid-hiccup-head :where, :reason, :tag/summary, :argv/summary
Test-harness

The test-react substrate is a test-only React double; the conformance-corpus DSL evaluator runs the cross-port conformance fixtures. The siblings :rf.error/conformance-unknown-before-op / -unknown-dsl-op / -unknown-fn-builtin are catalogued in the main table above.

:operation :op-type Channel Trigger / meaning Default :recovery :tags
:rf.error/mount-child-outside-render :error diagnostic mount-child! was called outside an :rf/component render body (*rendering-mount* was nil). :call-from-inside-a-render-body :where, :recovery
:rf.error/sync-unmount-during-render :error diagnostic A root was synchronously unmounted while a render was in flight (React 18+ raises the equivalent). :defer-the-unmount-until-render-settles :where, :recovery
:rf.error/test-react-not-installed :error diagnostic test-react/mount! was called without the Test-React adapter installed via init!. :install-the-test-react-adapter :where, :recovery
:rf.error/update-after-unmount :error diagnostic trigger-update! was called on a mount that was already unmounted. :trigger-only-on-a-mounted-root :where, :recovery, :mount-id
:rf.error/conformance-cannot-count-nil :error diagnostic A conformance-corpus :count builtin was applied to nil. :no-recovery — count a countable value :where, :recovery
:rf.error/conformance-cannot-count-string :error diagnostic A conformance-corpus :count builtin was applied to a string / char. :no-recovery — count a countable value :where, :recovery
:rf.error/conformance-throw-step :error diagnostic A conformance-corpus :throw DSL step raised its fixture-supplied message (the corpus's deliberate-throw op). :no-recovery — expected for a :throw fixture :where, :recovery, :from-fixture?

Schemas

Each category's :tags shape is registered as a Malli schema so consumers can validate without ad-hoc parsing. The full set of per-category :tags schemas is canonicalised in Spec-Schemas §Per-category :tags schemas — one schema per category enumerated in the §Error event catalogue above. Two examples (the rest follow the same shape):

;; Conceptual; the actual registration mechanism is implementation-specific.

;; :frame is a :tags key, not a top-level field — per §Frame identity on the raw
;; event above, `re-frame.trace/build-event`'s `stamp-frame` supplies it under
;; :tags for every in-run emit the site did not already tag there. The two
;; schemas below are ABRIDGED illustrations of the shape; the canonical, complete
;; per-category schemas (which do declare :frame) live in Spec-Schemas.

(def HandlerExceptionTags
  [:map
   [:category          [:= :rf.error/handler-exception]]
   [:failing-id        :keyword]
   [:reason            :string]
   [:event             [:vector :any]]
   [:handler-id        :keyword]
   [:exception-message :string]])

(def SchemaValidationFailureTags
  [:map
   [:category        [:= :rf.error/schema-validation-failure]]
   [:failing-id      :keyword]
   [:reason          :string]
   [:where           [:enum :event :sub-return :app-db :fx-args :cofx :flow-output :machine-data :machine-output :sub-override]] ;; :machine-data is the `reg-machine` [:schemas :data] boundary; :machine-output is the [:schemas :output] completion-payload boundary (EP-0029 A8). Agrees with the canonical SchemaValidationFailureTags in Spec-Schemas.
   [:path            [:vector :any]]
   [:value           :any]
   [:explain         :any]                          ;; Malli explanation shape
   [:registered-path {:optional true} [:vector :any]]]) ;; (:where :app-db only) registration root; :path is the failing leaf — see Spec/010

;; ... and so on for each category — see Spec-Schemas for the full set.

Pattern-level: every implementation registers an equivalent set of schemas. The category vocabulary is stable and additive — new categories can be added but existing ones cannot be renamed or removed.

Server error projection — public boundary

For SSR specifically, the structured trace event is the internal record (rich, full detail, monitor-bound) and a separate public projection is written to the HTTP response (sanitised, client-safe). The internal trace event is never serialised to the client. The projection mechanism is owned by 011 §Server error projection; the trace stream is unchanged by it. Dev tools that want full error detail subscribe via register-listener! as usual; the response carries only the locked :rf/public-error shape. the production-reachable SSR error categories (:rf.error/ssr-render-failed, :rf.error/ssr-streaming-writer-failed, :rf.error/malformed-hydration-payload, :rf.error/ssr-head-resolution-failed, :rf.error/sanitised-on-projection, :rf.error/ssr-ring-error-view-failed) ALSO deliver their structured off-box record to the :errors stream of register-listener! consumers (Sentry / Datadog) on the always-on axis (surface #4), so a -Dre-frame.debug=false JVM SSR host still ships them where the dev trace surface is elided — the off-box record, not the wire response, is the telemetry.

The runtime emits :rf.error/sanitised-on-projection (above) when the projector itself fails, so monitor dashboards see when the public boundary fell back to the generic-500 shape. this category is always-on — it rides the production-survivable error-emit axis (surface #4) alongside the dev trace, so an off-box shipper on a -Dre-frame.debug=false JVM SSR host sees the fallback. The always-on emit is one-shot and NON-PROJECTING (the projection listener skips this category), so surfacing it cannot re-enter the projector it reports on.

Recovery contract

The :recovery field on the trace event tells consumers (dev panels, error-monitor integrations, tooling) what the runtime did:

  • :no-recovery — the error propagated; the event was not handled.
  • :replaced-with-default — the runtime used a default value (e.g., :no-such-handler falling through to a no-op).
  • :retried — the runtime retried (with an upper bound) and surfaces the result.
  • :skipped — the runtime declined to act (:rf.fx/skipped-on-platform, :rf.cofx/skipped-on-platform).
  • :warned-and-replaced — the runtime emitted the warning and did its default action anyway (e.g., :rf.ssr/hydration-mismatch warn-and-replace mode).
  • :logged-and-skipped — the runtime emitted the trace and dropped the offending input; sibling inputs still apply (e.g., :rf.error/effect-map-shape drops the offending top-level effect-map key while the legal closed-set keys :db / :rf.db/runtime / :fx still apply).
  • :no-frame-context — the corrupted-context reader (:rf.error/frame-context-corrupted) emitted the diagnostic and returned nil rather than synthesising a :rf/default frame (per the carried invariant). The downstream public frame-scoped op turns that nil into the loud :rf.error/no-frame-context (whose own recovery is :supply-frame).

The list above is illustrative, not exhaustive — several per-category rows carry a recovery value documented inline in their catalogue cell (e.g. :supply-frame, :blocked-navigation, :event-dropped). The runtime routes every error category to the trace stream and proceeds with the documented per-category recovery — the typed, framework-owned defaults (frame-destroyed recovers + emits, sub-exception returns nil, handler-exception fails loud without crashing the app). There is no app-steering recovery policy: recovery is not a framework app-policy concern. For production observation, register a corpus-wide error listener via the :errors stream of register-listener! (the always-on surface, survives goog.DEBUG=false); for cross-frame dev observation, the :trace stream of register-listener! filtering on :op-type :error (or on the :rf.error/* :operation namespace) sees every error event without modifying behaviour. (The v1 process-wide reg-event-error-handler surface is dropped — see MIGRATION.md §M-26. The per-frame :on-error recovery policy — earlier drafts' {:swallow | :replacement | :default} return contract — was REMOVED: errors are not generically recoverable by an app policy, the policy's return value was never read or applied, and observability is already provided by the always-on listener. Genuine recovery is local-at-source — managed-HTTP :retry, optional-read fallback — or the framework's typed per-category default.)

Error observability (the always-on error listener)

Production error observability is the corpus-wide :errors stream of register-listener! (surface #4): one tight record per catalogued promoted runtime :rf.error/* event, fanned out to registered off-box shippers (Sentry / Honeybadger / Rollbar). It is always-on — NOT gated by re-frame.interop/debug-enabled? — so it survives :advanced + goog.DEBUG=false. Per-frame observation is the listener filtered by the record's :frame tag; no per-frame mechanism is needed. Dev-side enrichments (:dispatch-id, :rf.trace/trigger-handler) ride the trace surface and elide with it.

;; Error-monitoring libraries (Sentry, Honeybadger, …) wire through the
;; always-on listener. Forward the tight record to the monitor; recovery
;; is the runtime's typed per-category default — the listener observes, it
;; does not steer.
(rf/register-listener! :errors
  :my-app/sentry
  (fn [record]                                  ;; {:error :event :event-id :frame :time :exception :elapsed-ms}
    (sentry/capture-event (sentry-shape record))))

There is no retry surface. The framework never re-runs a failing handler, fx, or any other operation. An app that wants a failed event to fire again dispatches a fresh event; genuine recovery for expected failures is handled at the source (managed-HTTP :retry, optional-read fallback).

Style rubric for :reason strings (non-normative)

The structured fields of an error trace event (:operation, :failing-id, :frame, category-specific :tags) are the contract — tools branch on those. The :reason string is the one-sentence human-facing accompaniment that error-monitor dashboards, dev panels, and tooling surface to a reader. The voice matters. The goal is wording that helps the reader fix the problem in one read.

A good :reason string:

  1. Names the failing thing — the registered id, in backticks. 'Event handler:cart/add-itemthrew an exception.' not 'A handler threw.'
  2. Names the broken contract — what was expected. '... expected to return an effects-map; got a vector.' not '... bad return value.'
  3. Suggests the fix in one clause when the fix is unambiguous from the structured payload. '... did you mean to wrap it in{:fx [...]}?' not 'See docs.'
  4. Stays under ~20 words. The structured :tags payload carries the detail; :reason is the headline.
  5. Is mechanically composable from the :tags payload. Implementations build :reason from a category-specific template plus :tags substitutions; nothing in :reason is information not also present in structured form.

Example pairs (acceptable → preferred):

:rf.error/handler-exception
  acceptable: "Handler threw."
  preferred:  "Event handler `:cart/add-item` threw: TypeError: Cannot read property 'price' of undefined."

:rf.error/no-such-sub
  acceptable: "Subscription input not found."
  preferred:  "Subscription `:cart/total` depends on `:cart/items` which is not registered. Did you forget to require the cart namespace?"

:rf.error/schema-validation-failure
  acceptable: "Schema validation failed."
  preferred:  "Event vector for `:cart/add-item` failed schema at path [1 :id]: expected :uuid, got \"abc\"."

:rf.error/drain-depth-exceeded
  acceptable: "Drain depth exceeded."
  preferred:  "Drain depth limit (100) exceeded — likely a dispatch loop. Last event in queue: `[:cart/recompute]`."

:rf.error/effect-map-shape
  acceptable: "Effect-map returned a disallowed top-level key."
  preferred:  "Effect-map for `:cart/save` returned top-level key `:dispatch`; only the closed set `#{:db :rf.db/runtime :fx}` is allowed at the top level (app handlers use `:db` / `:fx`) — wrap as `:fx [[:dispatch event]]`."

Implementations that omit a :reason (returning the empty string) are conformant — the structured payload is the contract — but the rubric is the recommended voice for the reference implementation and for ports.

Composition with libraries (Sentry, Honeybadger, etc.)

Error-monitoring libraries integrate by registering a corpus-wide error listener that forwards the tight record to the monitoring service. The listener observes — it does not steer recovery (the runtime applies its typed per-category default):

(rf/register-listener! :errors
  :my-app/sentry
  (fn forward [record]
    (sentry/capture-event (sentry-shape record))))

Multiple monitoring concerns compose in user code (one listener that fans out to several services, or several registered listeners). For cross-frame dev observation, register-listener! filtered on :op-type :error sees every error event on the trace surface.

Notes

Why this is its own Spec

Tracing is the connective tissue between the runtime and every tool that observes it. Splitting it into its own Spec:

  • Locks the data shape independently of any specific tool.
  • Documents the forward-compat commitments tools depend on.
  • Separates "framework emits events" (002 territory) from "framework provides a tap surface" (this Spec).
  • Documents the prod-side Performance API instrumentation channel (gated on re-frame.performance/enabled?, default-off) alongside the dev-side trace stream — two compile-time-elidable surfaces with distinct gates and distinct consumers (see §Performance instrumentation).

Open questions

SA-4 classification. Per SPEC-AUTHORING §SA-4: the only item that previously lived here ("Trace allocation cost in dev when no listeners") classifies as :resolved — the (rf/configure! {:trace-buffer {:events-retained 0}}) escape hatch IS the answer. Migrated to ## Resolved decisions below.

Resolved decisions

Listener ordering

Multiple listeners may register concurrently. Listener-invocation order is not contract — tools must not depend on the order in which sibling listeners receive a given event. Each listener receives the same event independently; nothing about the order in which the runtime walks the listener registry is guaranteed across builds, hosts, or registry implementations. The same rule applies to register-listener! (per §Subscription / consumption and §Listener invocation rules) and register-epoch-listener! (per register-epoch-listener! §Invocation rules).

Trace allocation cost in dev when no listeners

In dev, interop/debug-enabled? is true, so the emit body runs even when no listeners are registered: the runtime allocates the event map, routes it into the in-flight frame's event slot in the per-frame ring (or skips the ring entirely for frameless emits per the B3 ruling), and walks the (empty) listener registry. The per-frame ring's per-event append is the floor cost when in-run. Tools that want maximum dev-loop throughput can (rf/configure! {:trace-buffer {:events-retained 0}}) to disable the ring; the synchronous-delivery path still works and the user-listener fan-out remains zero-cost when no listeners are attached.

Trace correlation across the run

Two run-wide channels ride on every trace event emitted inside a run — neither is scoped to errors:

  1. :rf.trace/dispatch-id under :tags (per §Dispatch correlation). Grouping raw trace events by run is a single-key filter — (filter #(= run-id (get-in % [:tags :rf.trace/dispatch-id])) events). Tools that need run trees walk :rf.trace/parent-dispatch-id upward across :rf.event/dispatched events (the inter-run lineage channel).

  2. :rf.trace/trigger-handler at the top level (per §:rf.trace/trigger-handler — naming the in-scope handler). Names the handler whose code produced the event and carries its registration coord — so jump-to-source links work from every trace event in a run, not just errors. Rides on :rf.fx/handled, :rf.machine/transition, :rf.event/db-changed, :rf.fx/do-fx, :rf.sub/run, :rf.view/render, and all :rf.error/* events whenever a handler is in scope at emit time. Omitted outside any handler scope (registration-time emits, outermost-dispatch lookup failures).

Per-run structured projection lives in the assembled :rf/epoch-record (per Spec-Schemas) — the raw :rf.trace/dispatch-id / :rf.trace/trigger-handler channels are the lower-level primitives.

Per-frame trace rings — event-keyed retention

Resolved 2026-05-25. The trace surface is partitioned per-frame and sized by event count, not trace-event count. Five resolved sub-questions:

  1. Cross-frame runs. Each frame retains the traces of runs that executed in it, keyed by their own :rf.trace/dispatch-id. Cross-frame consumers (pair-mcp, monitoring tools, multi-frame story sessions) merge by :dispatch-id across rings; the framework does not maintain a process-global cross-frame index.
  2. Frameless trace events. Original ruling B (frameless events → :rf/default + nil-id cluster) was overturned the same day by hot-reload memory-leak analysis. Amended ruling: B3 + B4 combined. B3 — frameless trace events skip rings entirely; they stream live to listeners only, never retained anywhere. B4 — hot-reload re-emits are deduplicated by shape at the emit site (the registrar tracks last-emitted shape per (kind, id) pair and suppresses unchanged re-emits). Together: rings hold events exclusively; the live stream filters reload-noise; the registry is the source of truth for "what's registered right now". Hot-reload is a non-event for the trace bus.
  3. Off-box streaming wire format. Event bundles (not raw trace events). Matches the storage unit; off-box consumers (re-frame2-pair-mcp trace-window / watch-epochs, monitoring tools) receive one bundle per stream tick rather than reconstructing runs from individual events.
  4. In-process trace-buffer API. Per-frame, event bundles by default; :flat true opt-in for callers that want raw trace events. Signature: (rf/trace-buffer frame-id) / (rf/trace-buffer frame-id opts).
  5. Run size bound. No per-run trace cap by design. The operator's tuning lever is the event-count knob (:rf.trace/events-retained, default 50, per-frame override), not trace-volume per run. A run with 50K traces takes one slot like a run with 5 traces takes one slot.

Single retention knob: :rf.trace/events-retained (frame metadata, default 50). When event #N+1 arrives, the oldest event slot (and every trace event ever emitted under its :dispatch-id) is evicted as a unit. No per-trace-type cap, no per-event trace cap, no other knobs. The full surface lives at §Per-frame trace rings (event-keyed, dev-only).

Rejected alternatives: "don't emit sub-skip" (preserves useful signal by routing instead), "filter at consumer level" (too late — real events already evicted), "bigger flat ring 10×" (procrastinates the architectural mismatch), "frameless :rf/default cluster" (hot-reload memory leak; overturned in favour of B3+B4).

Trace event for app-db changes

Partition commits happen at the frame-state commit boundary. The runtime emits :rf.event/db-changed (APP-DB-ONLY) on every dispatch whose handler changed the app-db partition, and :rf.event/frame-state-changed (partition-tagged) whenever either partition changed — so a runtime-only commit (machine snapshot / route slice) emits only the frame-state signal, not :rf.event/db-changed. Tools that want before/after pairs read the :rf/epoch-record's canonical :frame-state-before / :frame-state-after slots (or their :db-before / :db-after app-db projections), which the runtime captures atomically across the event's whole cascade (one epoch per event — per 002 §Drain versus event) rather than per individual change emit.

Privacy / sensitive data in traces

Cross-reference: see Security.md §Privacy / secret handling for the framework-wide pattern-level posture this section grounds — per-slot schema :sensitive? metadata is the canonical privacy marker. (The legacy handler-meta :sensitive? annotation has been removed; sensitive data marking is path-based per the Spec 015 data-classification mechanism (EP-0025).)

Trace events carry dispatched event vectors, handler return values, (under §Trace event for app-db changes) app-db snapshots, and (on :rf.view/rendered) view render args/props (:rf.view/render-args, rpgq8) — any of which may contain user input that should not leave the developer's machine: passwords, auth tokens, payment details, PII captured from form fields. Tools that ship traces off-box (error-monitor forwarders per §Wiring an external error monitor, remote dev dashboards, the Xray-MCP / re-frame2-pair servers per Tool-Pair.md) must not emit that data verbatim. Each such user-data slot is elided at emit time through the single shared re-frame.elision/elide-wire-value walker (via the Spec 015 classification-projection chokepoint) before the event reaches any listener; :rf.view/render-args gets the identical treatment as the :rf.event/db snapshot.

The declaration surface is path-based (EP-0025). Apps classify durable app-db paths from a handler via the four commit-plane effects (:sensitive / :large / :clear-sensitive / :clear-large, applied with the :db write into the per-frame [:rf.runtime/elision …] registry); they classify transient payloads (event args, fx/cofx args, sub output) via :sensitive / :large registration metadata on reg-event / reg-sub / reg-fx / reg-cofx; and a subsystem classifies its own instance data via projection-relative declarations lowered per instance. The handler body always receives the raw :event coeffect; redaction is a trace/egress-emit concern, projected at trust boundaries by project-egress (per 015 §Projection). Schema {:sensitive? true} props no longer classify a durable app-db path (EP-0025 removed the schema→registry bridge); a schema prop drives only validation-failure-trace redaction and the schema-owned transient (:decode / resource) shapes. (The positional redact-interceptor is no longer part of the public façade; re-frame.privacy/redact-interceptor remains internal router plumbing only.)

project-egress reads the per-frame classification registry — PATH-BASED, derived trees included (EP-0025). project-egress is the record-level boundary that dispatches on a record's :kind (the :rf.observe/* kinds — handled-event, error, derived-tree) and delegates the tree-shaped slots to elide-wire-value. The path-based walker reads the per-frame [:rf.runtime/elision …] classification registry, profile-aware — a path classified by a commit-plane :sensitive / :large effect (:source :effect), a subsystem projection-relative declaration (:source :machine / :resource / :route), or a flow output (:source :flow) redacts the same, since the sources union at lookup time. A :rf.observe/derived-tree record (rendered hiccup / DOM, a resolved :effective-args map, a snapshot body) is walked path-based against the same registry too: EP-0025 removed the value-match (taint-by-equality) dual that previously substituted a re-keyed copy of a sensitive value at a non-app-db position. A value re-keyed off its classified app-db path therefore ships raw — the intended fail-open (ruled 1hms84=(a)); to redact a derived secret, classify the app-db PATH it lives at or the destination path it is written to. The off-box tool consumers (Story-MCP, re-frame2-pair) project their derived trees through this one boundary — naming the :rf.egress/profile, not a hand-resolved :rf.size/* floor. Under :rf.egress/local-raw (or explicit :rf.size/include-sensitive? true) the tree passes through verbatim — the trusted-local raw read.

Unified wire-elision surface. :sensitive? (privacy) and :large? (size) are two orthogonal predicates over the same wire-boundary elision walker — both consumed by rf/elide-wire-value (per §Size elision in traces below and API.md §rf/elide-wire-value). The walker emits the :rf/redacted sentinel for sensitive values and the :rf.size/large-elided marker for large values; when both predicates match the sensitive drop wins (the size marker would leak :path / :bytes and is suppressed). Same shape, two flags, one helper.

Carried frame, frameless fails closed (EP-0002). A frame's elision policy may be applied only when that frame is known. rf/elide-wire-value resolves its wire-egress frame from the carried stamp — the explicit :frame opt (override) wins, else the in-effect carried-invariant scope (with-frame / frame-provider). There is no :rf/default floor: an egress that carries no frame can never borrow a default frame's marks. When no frame is carried, the per-frame elision registry is unreachable, so the walker fails closed — the whole value is conservatively redacted to :rf/redacted rather than shipped verbatim under no policy (:rf.size/include-sensitive? true is the deliberate opt-out for a trusted-local caller that has waived sensitive redaction). The trace-projection chokepoint (Spec 015 classification-projection) follows the same rule: a trace event attributes to the frame it carries, never to a synthesised default — the frame-qualified slots (:rf.event/db, :rf.view/render-args, :rf.sub/value) fail closed when the carried frame is absent (a genuinely frameless boot/registration emit, or a malformed event that should have carried a stamp), while the process-scoped per-registration marks (event / fx / cofx / machine, keyed by (kind id)) apply with no frame needed.

Projection-decided sensitivity hoists to the top level. The top-level :sensitive? stamp (see §Trace-event field: :sensitive? at the top level) is normally computed in build-event from handler-scope / schema overlap. But some classification-projection clauses decide sensitivity DURING projection — the projection runs after build-event, so build-event never saw the signal. Examples: a sensitive machine's :rf.error/machine-action-exception :exception-data (Spec 015 project-machine-error-tags) and a registration-classified (or frameless-fail-closed) :rf.sub/run output (project-sub-tags — the sub's own :sensitive registration mark, NOT input→output propagation, which EP-0025 removed). These clauses stamp [:tags :sensitive?]. Because the off-box egress gate (re-frame.mcp-base.sensitive/sensitive-event?, per §Off-box egress contract) reads the top-level :sensitive? only, a tags-level-only stamp would egress as non-sensitive and the fail-closed whole-event drop would never fire. The classification-projection chokepoint therefore hoists any projection-stamped [:tags :sensitive?] to the envelope's top level (and strips it from :tags) before delivery, mirroring build-event's posture. This is uniform across every projection clause that stamps tag-level sensitivity — present and future — so projection-classified-sensitive events are dropped by strip-sensitive with the boot gate off, exactly as scope-classified ones are.

runtime-db is redacted/omitted off-box by default (EP-0001, Mike ruling #14). A frame-state projection has two partitions; off-box egress (error-monitor forwarders, remote dashboards, the Xray-MCP / re-frame2-pair servers per Tool-Pair.md) redacts or omits the runtime-db partition by default — only the app-db partition (subject to its own :sensitive? / :large? elision) and any explicitly allowlisted serializable runtime-db facts cross the wire. Transient runtime side-channel state (host handles, request/response accumulators, trace rings, in-flight HTTP, dirty caches — per 002 §Durable vs transient) is absent by default and available only through an explicit trusted-local diagnostic API. Trusted-local tools (a developer's own Xray panel inspecting their own running app) may request richer runtime-db diagnostics explicitly; off-box / AI / log egress fails closed. The elision declarations themselves live in runtime-db ([:rf.runtime/elision …]) — they are privacy-load-bearing runtime state, so they too revert atomically with frame-state and are never accidentally dropped by an app-db replace (per Conventions §Reserved runtime-db keys).

The :sensitive? registration metadata key

NOTE: The handler-meta :sensitive? registration-metadata annotation has been removed. Sensitive data marking is path-based per the Spec 015 data-classification mechanism (EP-0025) — sensitivity is a property of the data value at a path, not of the handler that touched it. The trace-event :sensitive? top-level stamp (see §Trace-event field: :sensitive? at the top level) is now driven exclusively by the schema-derived overlap (see §Schema-installed redaction).

Previously this section described an optional boolean :sensitive? key on the :rf/registration-metadata map. That annotation no longer participates in the privacy machinery. The two always-on substrate boundaries (event-emit, error-emit) no longer drop / redact based on handler-meta sensitivity — they rely on the per-path elision wire-walker populated from app-schema :sensitive? slot meta. Schema-installed redaction (below) and registration-owned :sensitive payload classification (per EP-0015 §7) are the supported declaration sites. (The positional redact-interceptor was removed from the public façade — EP-0015 §7.)

Schema-installed redaction

For handlers scoped with the standard :rf.interceptor/path interceptor (EP-0022 — referenced as [:rf.interceptor/path <path-vector>], never an inline value), the router compares the path interceptor's app-db focus with the frame's schema-derived sensitive declarations. When a sensitive schema path is under the handler's db focus, the router installs an internal redaction interceptor for the corresponding event-payload path.

(rf/reg-app-schema [:auth]
  [:map
   [:username :string]
   [:password {:sensitive? true} :string]])

(rf/reg-event :auth/login
  {:interceptors [[:rf.interceptor/path [:auth]]]}
  (fn [{:keys [db]} [_ payload]]
    ;; `db` is the focused :auth slice (the path interceptor); the handler receives the raw payload.
    {:db (assoc db :last-login payload)}))

;; Trace/error emissions for [:auth/login {:username "ada" :password "shh"}]
;; carry [:auth/login {:username "ada" :password :rf/redacted}].

Behaviour:

  • Canonical declaration. {:sensitive? true} on app-schema slot metadata is the canonical per-path privacy declaration. It hydrates [:rf.runtime/elision :sensitive-declarations] for the active frame.
  • Registration-owned classification. :sensitive payload classification on reg-event / reg-sub / reg-flow (per EP-0015 §7) scrubs the classified payload keys before the trace surface sees them; complementary to schema-marked paths. (This replaces the removed positional redact-interceptor — EP-0015 §7.)
  • Trace-only redaction. The internal redaction interceptor writes the redacted event to framework trace/error emission slots. The regular :event coeffect stays raw so handlers can perform the requested work.
  • Sentinel keyword. Redacted values are replaced with the framework-reserved :rf/redacted sentinel. Apps MUST NOT use it as a legitimate payload value.

Trace-event field: :sensitive? at the top level

The :rf/trace-event schema (per Spec-Schemas §:rf/trace-event) gains an optional top-level :sensitive? boolean. Tools branch on it directly:

(rf/register-listener! :trace
  :my-app/remote-shipper
  (fn [trace-event]
    (when-not (:sensitive? trace-event)              ;; default off-box-ship policy
      (ship-to-remote-dashboard! trace-event))))

Filter-shape integration: (rf/trace-buffer :rf/default {:sensitive? false :flat true}) returns only the non-sensitive events from the default frame's ring. The filter vocabulary at §Filter vocabulary gains one row:

Key Type Semantics
:sensitive? boolean Match the top-level :sensitive? field. Pass false to exclude sensitive events; pass true to select only sensitive events. Absent ⇒ no constraint.

Listener filtering semantics

Listeners installed via register-listener! and register-epoch-listener! (per §The listener API) receive every trace event regardless of :sensitive? — the flag is a payload axis the listener inspects, not a delivery gate. Two reasons: (1) on-box developer tooling (10x, the trace panel, the in-process ring buffer) needs to see sensitive traces during local dev; (2) routing the filter into the runtime would force every consumer to opt in to seeing sensitive data and complicate the elision contract. Filtering lives in the listener body, not in the framework's dispatch path.

Framework-published listener integrations MUST default to suppressing :sensitive? true events:

  • The Sentry / Honeybadger forwarder samples at §Wiring an external error monitor wrap their register-listener! body in (when-not (:sensitive? trace-event) ...) by default. Apps that want the events shipped (rare; only when the monitor is itself the trust boundary, e.g. a self-hosted Sentry inside the same VPN) opt in by removing the guard.
  • The re-frame2-pair server (per Tool-Pair.md §How AI tools attach) MUST drop or redact :sensitive? true events before forwarding to the AI surface. The default policy is drop; apps that want sensitive runs visible to the pair tool configure the policy explicitly.
  • The Xray-MCP server (per Tool-Pair.md) MUST default-drop :sensitive? true events from the run graph it materialises.

User-side listeners (in-app recorders, dev panels, custom forwarders) have no framework-imposed policy — they receive every event and decide on a per-app basis. The recommended discipline is identical: gate any off-box egress on (when-not (:sensitive? trace-event) …).

The user-controllable config knob each consumer exposes for the default-suppress policy follows a fixed verb convention per Conventions §Privacy config-knob naming: on-box devtools UI consumers use the show-sensitive? verb under the :trace/* ns (e.g. :trace/show-sensitive? — UI visibility), while off-box wire-egress consumers (the MCP triplet, the re-frame2-pair preload) use the unqualified include-sensitive? verb (e.g. {:rf.size/include-sensitive? false} on the elision policy map — wire egress). Both default to suppress; the verb choice tells the reader which trust boundary the knob governs without re-deriving from context.

Retroactive-scrub on set-show-sensitive! false

The on-box show-sensitive? knob is not a one-way trapdoor. Each consumer's (set-show-sensitive! v) is gated at ingest time only — it decides whether the next emit lands in the consumer's downstream buffer (or in the framework's per-frame ring), not whether buffer reads see existing payloads. Without an explicit retroactive-scrub rule the toggle has a privacy hole:

1. show-sensitive? = true     (engineer flips on to debug redaction policy)
2. sensitive run emitted       (auth/login event lands in every consumer's buffer)
3. show-sensitive? = false    (engineer flips back off, expecting privacy restored)
4. panels keep showing the buffered :sensitive? payloads forever

The normative rule: every on-box :trace/show-sensitive? consumer (Xray's trace-bus, Story's per-variant ui.trace buffer, future devtools that hold a buffer downstream of the on-box flag) MUST clear its trace buffer on the true → false transition. false → false, false → true, and true → true MUST NOT clear (no buffered sensitive risk exists for those transitions, and clearing would discard legitimate non-sensitive history without cause).

The clear MUST be whole-buffer, not selective. Non-sensitive history buffered alongside the sensitive run is intentionally lost. Selective scrubbing is unsafe because a single sensitive event can have caused later non-sensitive runs — sub recomputes, render args, dispatched-from-fx events — whose payloads structurally reveal the redacted value via the shape of what they consumed. Clearing the whole buffer is the simplest correct semantic; any "smarter" filter risks reintroducing the leak through a derived event.

The clear MUST also reset the per-consumer [● REDACTED N] suppressed-events counter so the indicator drops in lockstep with the buffer (the counter is conceptually "since last clear", not "since process start"). Per Xray's trace-bus/clear-buffer! and Story's ui.trace/clear-buffer! — both already cascade through to the suppressed-counter reset.

Implementation note (non-normative): the reference implementation uses a callback-registry pattern (config/register-toggle-off-callback!) so the config layer can invoke the consumer's clear-buffer fn without taking a require dependency on it (the consumer requires the config; not vice versa). Callbacks run on every true → false transition; one callback's exception MUST NOT block the others (privacy is the load-bearing concern, and a partial clear is strictly better than no clear). Off-box wire-egress consumers (include-sensitive? knobs on the MCP triplet, re-frame2-pair preload) are out of scope for this rule — their flag governs wire emission, not a persistent buffer, so the transitions are stateless.

Production-elision behaviour

The :sensitive? mechanism is dev-time only — both pieces of it ride the trace surface and elide with it:

  • The trace surface's :advanced + goog.DEBUG=false build elides emit! entirely (per §Production builds). No trace event is allocated, no listener body runs, no :sensitive? stamp is built. The privacy mechanism is moot because there is no trace to privacy-protect.
  • Schema-installed redaction is internal router machinery. In production builds that retain always-on event/error substrates, the same redacted event shape is used at those boundaries; dev-only trace allocation still DCEs when the trace surface is disabled.
  • The elision-probe verifier (per §Production-elision verification) treats ":rf/redacted" as a framework sentinel that may survive only where a production boundary explicitly uses schema redaction.

No registration-time privacy warning exists. Schema metadata is the canonical redaction declaration; registration-owned :sensitive payload classification (per EP-0015 §7) is the declarative site for ad-hoc payload scrubs (replacing the removed positional redact-interceptor, EP-0015 §7). The handler-meta :sensitive? annotation has been removed.

Error event catalogue (single source of truth)

Earlier drafts of this Spec carried the error vocabulary across three places: a ### Error categories (initial set) table that listed :operation + meaning + :tags, a separate #### Default behaviour by category table that listed :operation + default :recovery, and inline category rows declared within feature subsections.

Consolidated into a single normative §Error event catalogue — one row per category, five columns (:operation · :op-type · trigger / meaning · default :recovery · :tags). Each row's emit-site cross-link names the owning Spec section. Per-feature Specs (002, 005, 006, 010, 011, 012, 013, 014, Tool-Pair) reference the catalogue rather than reproducing fragments. The per-category Malli :tags schemas remain canonicalised in Spec-Schemas §Per-category :tags schemas — one schema per catalogue row. Consumer cost: a single anchor (#error-event-catalogue) instead of three; consumers using API.md §Error contract get a pointer to the catalogue rather than a partial duplicate.

:on-error recovery policy — REMOVED

Earlier drafts shipped a per-frame :on-error recovery policy: a frame-config fn that received a structured error event and returned a {:recovery … :replacement … :notes …} map steering the runtime's recovery ({:swallow | :replacement | :default}), with two catalogue rows (:rf.error/bad-on-error-return, :rf.error/on-error-policy-exception) guarding the contract. The policy and its entire contract were REMOVED (Mike-ruled 2026-06-09):

  • The return value was never read or applied — the runtime fell back to the original error's per-category default regardless of what the policy returned. The recovery contract was documented-but-fictional.
  • Errors are not generically recoverable by an app policy. :swallow masks a bug; :replacement fabricates a result a thrown handler could not produce. Genuine recovery is local-at-source (managed-HTTP :retry, optional-read fallback) or the framework's typed per-category default.
  • Observability was already provided by the always-on error-emit surface (the :errors stream of register-listener!, #4); the policy was a redundant observation hook plus an unwanted steering knob.

This supersedes the shipped 2-axis catalogue's axis-2 (the recovery-policy-eligible axis). The catalogue's axis-1 (the always-on listener) and the per-category typed defaults survive intact; the recovery-policy-eligible column collapses, leaving the catalogue's two axes as always-on-listener? + typed-default-per-category. The removed surface: the :on-error frame-config slot, the {:swallow | :replacement | :default} return vocabulary, and the never-applied catalogue rows :rf.error/bad-on-error-return and :rf.error/on-error-policy-exception. The v1 process-wide reg-event-error-handler remains dropped per MIGRATION §M-13 / §M-26.

Size elision in traces

Trace events and pair-tool snapshot slices carry tree-shaped values (app-db snapshots under §Trace event for app-db changes, epoch-record :db-before / :db-after slots per Tool-Pair §Time-travel, sub-cache reads, get-path returns) that can individually blow the 5K-token wire cap (tools/re-frame2-pair-mcp/spec/Principles.md §Wire-cap). A 5 MB base64-encoded PDF preview under [:user :uploaded-pdf] is 290× the cap on its own — and a :path [:user] drill-down returns it verbatim, bypassing the :rf.mcp/summary lazy-summary mechanism (which shapes the top-level response, not per-value descendants).

The contract is structurally parallel to §Privacy / sensitive data in traces: a per-path declarative flag (:large?) that the wire-boundary walker routes on, and a single normative wire marker (:rf.size/large-elided) the walker substitutes in place of the elided value. Apps that nominate a large path get every wire emit eliding it; consumers re-fetch on demand via the marker's :handle slot through the existing re-frame2-pair-mcp get-path tool — no new tool is needed.

Privacy and size are two orthogonal predicates over the same elision walker: rf/elide-wire-value (per API.md §rf/elide-wire-value) consumes both :sensitive? and :large? and emits the appropriate placeholder. Same shape, two flags, one helper — when both predicates match the sensitive drop wins, because emitting the size marker would leak :path / :bytes / :digest (each of which can carry structural information about the redacted slot).

DESIGN-RATIONALE — why the two markers stay separate rather than unifying behind a single elision shape. A unified marker is structurally tempting (one walker, one wire shape, one consumer code-path), but three properties of the privacy axis make a single shape strictly worse than the two-flag arrangement above. (1) Path-leak risk is structurally worse with a unified marker. Hoisting :path to its own field on a privacy-driven elision advertises "this slot is worth redacting" — a stronger breadcrumb than today's per-key :rf/redacted sentinel, which lives inside the value's parent and reveals only that some child got redacted, not which one. The sensitive cascade arm (::redact-or-drop above) deliberately keeps its evidence local to the parent map; the size arm (::elide-with-marker) deliberately exposes the path so consumers can re-fetch. The two shapes encode opposite policies about what's safe to advertise. (2) The fetch-handle is redundant for sensitive. When :include-sensitive? true the value rides inline (no marker, no handle needed); when false the event vanishes from the wire entirely (nothing to fetch from). There is no useful in-between state where a sensitive value should be both elided AND re-fetchable — that combination is the leak. The handle is asymmetrically valuable: size genuinely needs it (a 5 MB value can't ride the wire even when wanted), sensitive has no analogous size pressure (a redacted string fits inline). (3) Defense-in-depth would collapse. Today's two-marker arrangement is two independent boundaries: the trace stream is read-only metadata (one boundary; sensitive values never reach it), and get-path auth-gates retrieval (second boundary; a handle alone isn't authority). A handle-bearing sensitive marker would collapse these into one policy decision — sensitive enforcement would rely on get-path alone, losing the read-only-metadata boundary as a check. The composition rule (sensitive wins) is the load-bearing wire-boundary invariant that the separate-markers design protects; it's normative here and re-stated where the marker shape is reserved at Conventions §Reserved namespaces (:rf.size/large-elided).

Nomination — schema metadata only

Implementations MUST support schema-driven nomination. The schema walker (reading app-db schema slots) populates one runtime-db registry ([:rf.runtime/elision :declarations]); the wire walker consults that registry at every emit. (The shape lives in Spec-Schemas §:rf/elision-registry; the runtime-db slot is reserved per Conventions §Reserved runtime-db keys.)

{:large? true} on a Malli slot in :rf/app-schema (per Spec-Schemas §:rf/app-schema-meta) is the canonical AI-discoverable entry: schemas are the AI-first surface for app shape, so an agent reading the schema sees the elision claim alongside the type. The runtime walks every registered app-schema at boot and on hot-reload and writes {:large? true :source :schema} entries into the registry under the path the schema slot occupies.

The walker does not auto-elide unschema'd values. In dev, when it observes a large string at an undeclared path, it emits :rf.warning/large-value-unschema'd once per (frame, path) to nudge authors toward schema metadata.

Wire marker — :rf.size/large-elided

The walker substitutes large values with a single normative marker shape:

{:rf.size/large-elided
  {:path   [:user :uploaded-pdf]               ;; absolute path inside the slice's root
   :bytes  5242880                             ;; pr-str byte count, exact when known
   :type   :string                             ;; one of :map :vector :set :scalar :string
   :digest "sha256:abc123..."                  ;; hex digest, optional (gated on :include-digests?)
   :reason :effect                             ;; declaration provenance — :effect (commit-plane :large effect) / :machine / :resource / :route (subsystem decl) / :flow (flow output)
   :hint   "Upload preview blob"               ;; copied verbatim from the declaration's :hint slot
   :handle [:rf.elision/at [:user :uploaded-pdf]]}}  ;; EDN form passable to get-path

The shape is captured normatively at Spec-Schemas §:rf/elision-marker. Per-field MUST-level requirements:

  • :path — REQUIRED. The absolute path inside the snapshot slice (NOT relative to the elision site). An agent that asked for :path [:user] and got a marker back at the :uploaded-pdf slot sees :path [:user :uploaded-pdf]. The handle is copy-pasteable without rebasing.
  • :bytes — REQUIRED. The pr-str byte count of the elided value. Lets an agent decide "fetch anyway" (small enough for this turn) vs "skip" (over the per-turn budget).
  • :type — REQUIRED. One of :map, :vector, :set, :scalar, :string. Tells the agent which access pattern to use — a :vector is paginatable via get-path with an index range; a :string of 5MB is not.
  • :reason — REQUIRED. The declaration provenance — the :source of the elision-registry declaration that fired the marker (normative at Spec-Schemas §:rf/elision-marker). Per EP-0025 the durable large declaration is the commit-plane :large effect — a handler returning {:large [[…]]} with its :db write installs it under :source :effect (re-frame.elision), so a commit-plane-classified slot emits :reason :effect. A subsystem projection-relative declaration emits its subsystem source (:machine / :resource / :route); a flow output declaration emits :reason :flow. The pre-EP-0025 routes are retired: a frame :large {:app-db …} annotation, an imperative add-marks mark, and a reg-app-schema {:large? true} slot prop are no longer routes into the elision registry for durable app-db classification.
  • :hint — REQUIRED (may be nil). A free-form short string copied verbatim from the Malli slot's {:hint "..."} metadata.
  • :handle — REQUIRED. An EDN vector of shape [:rf.elision/at <path>] (or [:rf.elision/at <path> :as-of-epoch <epoch-id>] when the marker rides inside a past-epoch payload — see §Composition below). The handle is a normal EDN vector, not a tagged literal — agents pattern-match on the leading :rf.elision/at keyword without needing a reader hook. The path inside the handle is the same as the marker's :path field. Passing the handle to the existing re-frame2-pair-mcp get-path tool fetches the literal elided value, subject to that tool's own cap check (a :rf.mcp/overflow is the failure mode if the literal is over-cap).
  • :digest — OPTIONAL. A sha256:<hex> content digest, computed only when :rf.size/include-digests? is true on the call. Default off because the digest forces a full walk of the elided value, which negates the elision's cost-saving. When enabled (debug builds, integrity-check workflows), callers compare digests across turns to detect change-without-fetch.

The marker is the sixth wire elision mechanism alongside the five precedents catalogued in Tool-Pair.md (:rf.mcp/summary, :rf.mcp/overflow, :rf.mcp/diff-from, :rf.mcp/dedup-table, :rf.mcp/cache-hit). The five pre-existing mechanisms shape the top-level response; :rf.size/large-elided substitutes per-value inside any tree-typed payload (:app-db, :sub-cache, every :rf/epoch-record :db-before / :db-after slot, every get-path return).

Consumer suppression — the elision policy

The walker accepts a per-call elision policy map. The vocabulary lives under the reserved :rf.size/* namespace (per Conventions §Reserved namespaces) and rides into every tool that emits wire data:

{:rf.size/elision-policy
  {:rf.size/include-large?    false   ;; default false — large values elide to markers
   :rf.size/include-digests?  false}} ;; default false — :digest slot is omitted from markers

Consumer-side defaults (MUST-level):

  • Framework-published off-box listener integrations (the Sentry / Honeybadger forwarders per §Wiring an external error monitor, the re-frame2-pair-mcp / Xray-MCP / story-mcp servers per Tool-Pair.md) MUST default :rf.size/include-large? to false and :rf.size/include-digests? to false. Tools that ship large-payload-aware integrations (e.g. dedicated artefact-streaming) opt in per-call; the conservative default protects apps that opt into a published integration without reading its source.
  • On-box listener integrations (Xray panel, Story panels per Tool-Pair.md) MUST default :rf.size/include-large? to false (the dev-tools UI shows a [● ELIDED N]-style indicator the user clicks to opt in for a single fetch). Production-trust on-box consumers MAY default to true; the rationale must be documented per-consumer.
  • Indicator field on tool responses. Tools that return structured response maps (every MCP server per Tool-Pair.md) MUST carry an :elided-large count alongside the existing :dropped-sensitive count (per §Privacy / sensitive data in traces) — one MUST-level row per consumer-facing tool that walks a tree-typed payload.

The :elided-large slot reports the count of :rf.size/large-elided markers ENCOUNTERED in the tool's response payload. Tools do not invoke elide-wire-value themselves — markers ride through from upstream (the event-emit substrate, the error-emit substrate, schema-slot meta). The slot is omitted when the count is zero (per Conventions.md:elided-large row).

The walker MUST NOT widen the policy transitively into the underlying registry — the policy is per-call; the registry of declared paths is per-frame state.

Composition

With the five other wire mechanisms catalogued in Tool-Pair.md, composition is the wire-boundary contract:

  • × :sensitive? (privacy). Sensitive drops before size elides. A value matching both predicates produces a :sensitive? true trace event with the value already redacted; no :rf.size/large-elided marker is emitted (the marker itself would leak :path / :bytes / :digest). The walker's predicate cascade is:
(cond
  (and sensitive? large?)  ::drop                  ; no marker; emit :sensitive? true
  sensitive?               ::redact-or-drop        ; today's :rf/redacted sentinel
  large?                   ::elide-with-marker     ; :rf.size/large-elided
  :else                    ::pass-through)
  • × :rf.mcp/diff-from (epoch diff-encoding). When a diff patch points at a large value, the walker substitutes the marker inside the patch's :assoc slot. The patch itself stays small (path + marker). The :handle carries :as-of-epoch <epoch-id> when the marker rides a past-epoch payload — get-path resolves against the existing epoch-record's :db-after snapshot so the agent sees that-epoch's value, not now's.
  • × :rf.mcp/dedup-table. Marker shapes are small (~150 bytes) — a 5 MB blob referenced from N epoch records produces N markers (~150N bytes) rather than one dedup-table entry plus N references. The marker IS the dedup for large values; no extra dedup work needed. If the agent opts in (:rf.size/include-large? true), the underlying values ride the wire and the dedup table picks them up at the slice boundary — the two mechanisms compose cleanly because they operate at different pipeline points.
  • × :rf.mcp/summary (lazy summary). Independent: summary shapes the top level of the response; large-elision substitutes per-value descendants. A :path [:user] drill-down may return a :rf.mcp/summary at the top (the slice shape) AND embed :rf.size/large-elided markers at any large descendant.
  • × :rf.mcp/overflow (cap backstop). Elision runs before the cap check. After elision the slice is much smaller; the cap usually doesn't fire. When it does — the marker volume plus residual small values still exceeds 5K tokens — the cap fires with its overflow marker and the agent narrows further.

Production-elision behaviour

The size-elision mechanism is dev-time only at the wire boundary, but the registry itself ships in production:

  • The [:rf.runtime/elision :declarations] slot survives production builds — it lives in the runtime-db partition, and schema-derived declarations ship as data. Production tools that consume frame-state (diagnostic dumps, off-box snapshot exports) MAY consult the registry to decide elision policy (subject to the off-box runtime-db redaction default — per §Privacy).
  • The rf/elide-wire-value walker itself ships in production. Consumer-facing surfaces that call it (every tool consuming the Spec 009 instrumentation API per Tool-Pair.md) elide with the trace surface (per §Production builds: zero overhead, zero code). Production builds that wire the walker into non-tool surfaces (off-box error-monitor forwarders, Sentry-style serialisers) get the same elision contract.
  • The :rf.warning/large-value-unschema'd warning is dev-only — it rides the trace surface and elides with it.
  • The elision-probe verifier (per §Production-elision verification) gains one sentinel: the string fragment ":rf.size/large-elided" (the marker keyword) MUST survive in production bundles only when the app explicitly wires the walker into a production surface — production builds that consume the walker only from dev-only tooling have the literal DCE'd along with the trace surface.

The single shared walker is the only place these markers get emitted; per-tool reimplementation is prohibited. Tools consume the walker through the public rf/elide-wire-value surface (per API.md); the walker is the natural home for short-circuits (once a sub-tree is elided, don't descend into it further — a large subtree elides its children with it; recursing into a 5 MB JSON blob to find more 5 MB blobs is pure cost).

A dedicated warning category accompanies the contract: :rf.warning/large-value-unschema'd, catalogued in §Error event catalogue, so authors notice large values that still need schema metadata.