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-definefor production elision viare-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; listeners receive each event synchronously, one at a time.
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 for error-shaped events
:source is hoisted to the top level of every event whose tags carry it; :recovery is hoisted on the error path. 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-idis 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 perdispatchcall, so a UIdispatch, each:fx [[:dispatch …]]child, and each frame-creation:initial-eventssetup step each receive their own — and rides on every trace event emitted inside that one event's pipeline run —:rf.event/dispatcheditself,: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-idpoints back at the parent — see below). The:rf.trace/dispatch-idis 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:raisesub-events and:alwaysmicrosteps 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-idand rides its epoch; they do not allocate a new one. Consumers (Storygroup-by-event, Xray's causality graph, re-frame2-pair'scascade-of, schema-timeline correlation) group raw trace events by:rf.trace/dispatch-iddirectly — no inference from sequence required. The runtime carries the in-flight run's id through the:rf.trace/dispatch-idslot of the handler-scope record (re-frame.trace/*handler-scope*, per §Handler-scope), bound byrouter.cljcaround each event's processing;emit!reads the slot and merges it into the event's:tagswhen 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-idis scoped to:rf.event/dispatchedonly. 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 anfxhandler running inside the do-fx phase of dispatch D₁ invokes(rf/dispatch ...), the runtime records the new dispatch's:rf.trace/parent-dispatch-idas D₁'s:rf.trace/dispatch-idon the new dispatch's:rf.event/dispatchedevent. 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-idis absent from:rf.event/dispatched. Non-:rf.event/dispatchedtrace 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/dispatchedevent with no:rf.trace/parent-dispatch-idis a root of a run tree. Pair-shaped tools draw run trees by walking:rf.trace/parent-dispatch-idupward across:rf.event/dispatchedevents; 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-ofspan field),:rf.trace/dispatch-idis the only intra-run correlation channel and:rf.trace/parent-dispatch-idis 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-recordprojection. Tools that prefer structured per-run slices read the assembled:rf/epoch-record(per Tool-Pair §Time-travel) — the raw:rf.trace/dispatch-idchannel 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 theinterop/debug-enabled?gate inemit!, 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 (routing-error emit, :route/link click handler) |
URL events, route-link clicks, on-match-error cascades |
: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 spawn-and-join lifecycle. child-completed is a NON-DECISIVE child's fold terminal; stale-completion is a pre-resolution exact-authority suppression (:reason classifies it). |
: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 / :rf.event |
Fragment-only URL change emission / pending-nav blockage (navigation-blocked rides :rf.event). |
: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. |
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-:dbsnapshot 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:dbthe handler returned under:tags :rf.event/db. Fires whenever the handler returned a:dbslot; 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:dbvalue under:tags :rf.event/db— same payload-slot posture as:rf.event/fxon:rf.fx/do-fxper Mike's ruling: full reference, no diff, no DEBUG gate; PDS structural sharing makes the cost pointer-sized and theday8/de-dupewire layer collapses repeated subtrees on egress. The:rf.event/dbslot is redacted at the classification chokepoint (re-frame.classification/project-db-tags, whichre-frame.trace/build-eventruns 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 walkerre-frame.elision/elide-wire-valueagainst the FRAME's app-db elision registry — the SAME normative site the epoch off-boxprojected-recorduses for:db-before/:db-after— so schema-:sensitive?slots egress as:rf/redactedand:large?slots get the:rf.size/large-elidedmarker 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'scascade-of) read t1 to render the handler's returned:dbvalue 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 ridesinterop/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-changedstays 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-changedis 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-changedwith:rf.event/partitions #{:runtime-db}and does not emit:rf.event/db-changed; an app-only commit emits both (:rf.event/db-changedplus: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-changedalone. Both rideinterop/debug-enabled?so production CLJS bundles DCE them.:rf.event/db-noop— the commit-level app-db no-op signal. Op-type:rf.event, APP-DB-ONLY. Fires when a:dbeffect 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: theidentical?-noop short-circuit incommit-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:dbeffect 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. Ridesinterop/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).createdfires when a machine handler is registered;spawnedfires when a spawned actor's snapshot lands in the registrar (the registrar-substrate partner of the fx-substrate:rf.machine.spawn/spawned);destroyedfires 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/destroyedinstead (see §the channel/reason matrix below).:rf.machine.lifecycle/spawnedcarries: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 bymachines/lifecycle_fx/spawn.cljcimmediately after the actor's snapshot is installed). The three machine-identity facts are distinct::machine-idis the registered TYPE (xor an inline:definition),:spawned-idis the live actor instance address, and:invoke-idis the declarative spawn invocation path (the absolute prefix-path of the:spawn-bearing parent state — was the overloaded:spawn-id).:rf.machine/started— the machine's BIRTH signal. Emitted at the single creation site —maybe-bootrunning 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:causeenum{:explicit :lazy :spawned}records HOW it came to life —:explicit= singleton, nil snapshot, trigger was the:rf.machine/startmarker;: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-:entryshort-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/startedtrace). 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. (-donefires when the machine enters a:final?state, immediately before the auto-destroy synchronously tears the actor down.):rf.machine/transitionis the macrostep-level rollup; its:tagscarry{: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/doneaddress a running actor.:rf.machine/snapshot-updatedcarries the same:actor-id.:cascadeis 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:alwaysmicrostep. 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:microstepstep 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/:entryaction are still recorded with:action nil+ empty:data-delta);:kindis STRUCTURAL and orthogonal to the per-action:rf.machine/action-ran:phase(driver) dimension;:data-deltais the minimal per-step:datacontribution (changed keys only — never the whole:datamap, so no large-payload leak); parallel machines carry per-:regionsteps concatenated in region declaration order;:alwaysmicrosteps ride as:microstepsteps so eventless cascades are explainable alongside the headline transition (composing with the per-microstep:rf.machine.microstep/transitionstream below, which stays the per-microstep marker). This removes the need for app-level:data :trailworkarounds. The snapshot-shaped:before/:afterslots of:rf.machine/transitionare redacted per the machine's[:schemas :data]per-slot marks (see 005 §Privacy);:data-deltacarries changed keys only — never the whole:datamap. 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:alwaysmicrosteps — 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/transitionat 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 == afterself-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/transitionnormally.) An internal self-transition carries an:actionstep, so it too emits its transition normally. Consumer: Xray's epoch panel renders the cascade per Xray Machine Inspector.:rf.machine.event/unhandled-no-op— the 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:onfallback, 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:errorand NOT:warning. This is xstate-v5 parity: xstate v5 removed the v4strictflag, 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 ownxstate.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/transitionfor: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-idis reserved for the registered TYPE) — consistent with the headline:rf.machine/transitionit 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:cascadefield rather than duplicating it.:rf.machine.spawn/spawned/:rf.machine/destroyed— machine instance spawn/destroy events emitted byfx.cljcon 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/disappear?" can subscribe to the:rf.machine.lifecycle/*channel; tools building causal graphs subscribe to both axes and disambiguate by the naming axis (:rf.machine.spawn/*/:rf.machine/*= fx-substrate;:rf.machine.lifecycle/*= registrar-substrate). Payload:tagscarry: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-idaddress 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/destroyedevent carries the reaped actor's live INSTANCE address under:actor-id(:machine-idreserved for the registered TYPE) and is enriched with a:reasontag — one of:rf.machine/finished(the actor entered a:final?state and auto-destroyed; see:rf.machine/donebelow),:rf.machine/join-reaped(a:spawn-alljoin reaped an ALREADY-TERMINAL:completed/:failedchild at resolution — a non-cancellation post-completion cleanup, see 005 §Spawn-and-join via:spawn-all),:explicit(a parent state-exit cascade, a:spawn-allcancel-on-decision, an imperative[:rf.machine/destroy <id>], or any other runtime-initiated teardown), or:parent-unmount-cascade(reserved — declared by 005 D6 for discriminating a parent-cascade child teardown from a direct destroy; the reference implementation stamps:explicitfor those today — see §the channel/reason matrix below). These four are the fx-substrate's complete:reasonvocabulary; the frame-exit cause:parent-frame-destroyednever rides this channel — it is registrar-substrate only (:rf.machine.lifecycle/destroyed, below). Observers that filter on:tagssee:reasonadditively. An:explicitdestroy 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. A:rf.machine/finisheddestroy is NOT a cancellation — the actor already closed its attempt through the:rf.machine/donereply — so it carries NONE of these cancelled facts. A:rf.machine/join-reapeddestroy is likewise NOT a cancellation — the reaped child already closed its attempt as a:spawn-alljoin-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-idis reserved for the registered TYPE.:outputis the child's:dataslot named by the final state's:output-key(ornilwhen the final state has no:output-key).:parent-idisnilfor singleton machines that reached:final?(per the singleton-symmetry rule D7 — see 005 §Final states). Pairs with the immediately-following:rf.machine/destroyedevent whose:tags :reasonis:rf.machine/finished.:rf.machine/system-id-bound/:rf.machine/system-id-released—:system-idreverse-index lifecycle (per 005 §Named addressing via:system-id).-boundfires on every:system-id-bound spawn (including the rebound case that also emits the:rf.error/system-id-collisionwarning);-releasedfires 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-idis 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:aftertimer lifecycle (per 005 §Trace events and /).:scheduledfires on initial entry-time scheduling and on every subscription-driven re-resolution; its:tagscarry:delay-source <:literal | :sub | :fn>to discriminate the three delay forms (per 005 §Value shape and 005 §Dynamic delay re-resolution).:firedcarries:fired? <bool>(false ⇒ guard suppressed the transition; sibling timers continue).:cancelledfires on every cancellation path;:reasondiscriminates 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:cancelledrow 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-afterrows 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:reasonset). The:firedand:stale-afterrows additionally carry the firing dispatch's causal completion timestamp under:rf.reply/completed-atwhen the synthetic:after-elapsed dispatch supplied a:rf/time-mstoken. (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-atduplicates were dropped from the reply-envelope row; the bare:work/idremains 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:firedand: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-suppressedbelow.:skipped-on-serverfires 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-allspawn-and-join lifecycle (per 005 §Spawn-and-join via:spawn-alland).*/startedfires after all N children have been spawned on entry to the:spawn-all-bearing state.*/all-completedfires when:join :allresolves;*/some-completedfires when:join :anyresolves on the success-side;*/any-failedfires when:on-any-failedresolves.: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-idand 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(:okfor the*/all-completed/*/some-completedsuccess-side resolutions,:errorfor*/any-failed) /:rf.reply/work-status, plus the causal:rf.reply/completed-atwhen present — so the join-resolving child completion classifies the same way the single-:spawnpath does. The companion:rf.machine.spawn-all/late-completiontrace (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 :suppressedreply 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/:failedrecord, and triggers no re-resolution / cancellation; the:resolved?latch stays true.:rf.machine.spawn-all/child-completed— the canonical work terminal for a NON-DECISIVE:spawn-allchild fold (rf2-ir4t5v). Fires once per accepted fold that did NOT resolve the join — in an:alljoin, 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:okfor a:donefold /:errorfor a:failedfold,:rf.reply/work-status, and the causal:rf.reply/completed-atwhen 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-alljoin-child completion.:rf.machine.spawn-all/stale-completion— the pre-resolution exact-authority suppression (rf2-nvxehu). Before ANY fold, a completion carrier must prove it belongs to THIS join attempt: the:rf/join-authstamp the member child's own handler boundary applied must match the current join's parent/invoke identity, logical child id, exact current actor id, AND the exact:rf/attempttoken minted for this seed. A carrier that fails the gate folds NOTHING — a zero-mutation fail-closed drop — and this trace fires once carrying the:status :stale/:rf.reply/work-status :suppressedreply facts with a precise:rf.reply/stale-reason::rf.machine.spawn-all/attempt-unverified(no runtime authentication stamp — a hand-crafted dispatch that never flowed through the member child's boundary),:rf.machine.spawn-all/attempt-superseded(a carrier bound to a PRIOR attempt / wrong actor after parent re-entry or child respawn — including a:fixed-actor-idrespawn where the actor id alone cannot discriminate attempts), or:rf.machine.spawn-all/duplicate-completion(an exact re-completion of an already-folded child).: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-atwhen present). This op covers the PRE-resolution classes; the POST-resolution:resolved?-latched straggler keeps its own:rf.machine.spawn-all/late-completiontrace (below,:rf.reply/stale-reason :rf.machine.spawn-all/join-resolved). Per 005 §:spawn-alljoin-child completion.:rf.machine.spawn/cancelled-on-join-resolution— fires once per surviving sibling unconditionally cancelled when a:spawn-alljoin 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-idand the invocation path under:invoke-id;:spawned-idis 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-failedinstead. 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-msslot on:spawn/:spawn-allis 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/firedon the:spawn-bearing state's:afterentry — same semantic, uniform substrate. Per 005 §Wall-clock timeouts on:spawn— use parent state's:afterand MIGRATION §M-44. :rf.route.nav-token/allocated/:rf.route.nav-token/stale-suppressed— navigation-token lifecycle (per 012 §Navigation tokens).*-allocatedfires when a navigation cascade begins;*-stale-suppressedfires when an async result arrives carrying a now-superseded token. Same epoch idiom as the machine-:aftertimer events.*-allocatedcarries:tags {:route-id <id> :nav-token <token> :frame <navigating-frame>}— the:frameis 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-changedto 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-changedfires only on the fragment-only branch (the route-id / params / query did not change); the trace's:tagscarry:prev-fragmentand:next-fragmentand never coincide with a:rf.route.nav-token/allocatedevent for the same drain — see therouting/fragment-changeconformance fixture.:rf.route/navigation-blockedcarries:tags {:requested-url <url> :rejecting-route <id> :rejecting-guard <sub-id> :frame <navigating-frame>}— the:framelets a multi-frame app filter a block to the frame that caused it (and admits the trace into epoch capture / past the frame trace-disable gate).:rf.route/registered/:rf.route/cleared/:rf.route/activated/:rf.route/deactivated— route lifecycle (per 012 §Trace events and).:rf.route/registeredfires on first-timereg-route; re-registration rides:rf.registry/handler-replaced.:rf.route/clearedfires on explicitclear-route.:rf.route/activated/:rf.route/deactivatedfire on every cross-route navigation commit in that order; same-id navigation emits neither. Both carry:tags {:route-id <id> :frame <navigating-frame>}— the:frameis 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:-registeredfor a fresh id,-clearedfor an explicit removal,-replacedwhen 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 filterop-type :flowto subscribe to the whole flow stream.:rf.sub/create— emitted byre-frame.subsat registration time — fired byreg-sub/reg-runtime-sub/reg-frame-state-subimmediately 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/disposefor 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:tagsare{:frame <id> :rf.sub/id <query-id> :rf.sub/query-v <vec>}. The purecompute-subform (the snapshot-against-a-supplied-db form per Spec 008 §Testing) emits the same base shape fromre-frame.subsbut 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:tagscarry, additively:tag shape meaning privacy :rf.sub/value-changed?bool(not= prev-value computed)—trueon the first recompute (no prior value to compare). Not wire-sensitive.plain :rf.sub/first-run?booltrueon the run that created this sub's cache slot (the memo wrapper'sprev-valuewas the::unsetsentinel — no prior cached value existed);falseon 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? trueand:rf.sub/prev-value nil, but the former tells a "from → to" story (consumers render an inline← was Xannotation) while the latter tells a "this sub is now alive" story (consumers render:addedchrome with no "was"). Without this flag a consumer cannot distinguish "the prior value really wasnil" 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-valueany the prior computed value; nilon the first recompute.redacted at the classification chokepoint :rf.sub/valueany the freshly-computed value. redacted at the classification chokepoint :rf.sub/cascade?booltruewhen an upstream sub drove the recompute (a layer-2+ sub);falsefor a layer-1 sub (driven by an app-db path change, not a sub).plain :rf.sub/cause-sub[query-id args]|nilfor a cascade, the upstream :<-query-vector whose value changed;nilfor 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:staticsub, the(input-fn query-v)result for a:parametricsub,[]for a layer-1 reader (per Spec 006 §Subscription input producers). The runtime counterpart to the staticsub-topology's:inputs :parametricsentinel: 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; only computed-value slots are redacted at the classification chokepoint.plain :rf.sub/cause-event-idevent-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 optionalre-frame.epochartefact is not on the classpath. Sourced from the in-flight run buffer via the:epoch/run-causelate-bind hook — same source:rf.view/cause-event-iduses (per:rf.view/renderedabove). 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-msnumberwall-clock duration of the sub body recompute for THIS run, in fractional milliseconds (the memo wrapper brackets the body with interop/now-msinside thedebug-enabled?gate). The per-op DURATION the Trace panel's column reads — mirrors:rf.view/elapsed-msfor views. Present on the reactive recompute path in dev builds; absent on the purecompute-subform (no timing bracket there) and DCE'd in production.plain :rf.sub/prev-valueand:rf.sub/valueare wire-value-sensitive app data and are redacted by the existing per-:rf.sub/runclassification chokepoint (re-frame.classification/project-sub-tags, whichre-frame.trace/build-eventalready runs for every:rf.sub/runemit per Spec 015 §Registration-owned transient classification) — a sub whose output carries a registration-declared:sensitivemark egresses BOTH slots as:rf/redacted, and a:large-declared sub gets the:rf.size/large-elidedmarker. (A sub does not inherit its inputs' classification; classify the sub's own output path.):rf.sub/value-changed?stays a plain boolean and is always observable. Why notelide-wire-valueat 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-freedb → layer-1 → layer-2layering (the sub would recompute on any app-db change). The classification chokepoint resolves sensitivity from the sub's process-scoped registration:sensitive/:largemarks — never a reactive read, never input→output propagation — so it is reaction-safe. The whole attribution branch (the enriched tag map) sits inside the sharedinterop/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/valueto populate "SUBS WHOSE VALUE CHANGED" and:rf.sub/cascade?/:rf.sub/cause-subfor "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/runentries. 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/reasonslot is a closed enum discriminating the eviction path::rf.sub/reasonEviction path Site :no-more-derefersThe 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-reloadA :subre-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-clearAn 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-destroyre-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 fromdestroy-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/disposefor 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-idrides via*handler-scope*for any eviction that fires inside a run's drain. Eviction fires that land OUTSIDE a run (an explicitclear-sub-cache!from a test) carry no:rf.trace/dispatch-id— the per-frame ring still anchors the event by:frameand consumers fall back to wall-clock ordering. The whole emit sits insideinterop/debug-enabled?so production CLJS bundles DCE it.Consumers: the Xray Epoch panel's SUBSCRIPTIONS section consumes
:rf.sub/disposeto surface the "disposed subs" branch alongside the per-epoch:rf.sub/create/:rf.sub/run/:rf.sub/skipevents — the full sub-lifecycle answer to "what happened to my subs this run?". Pair / Story consumers use:rf.sub/reasonto distinguish "last view unmounted" from "hot-reload" from "test teardown" without inferring from sequence. -:rf.cofx/run— emitted byre-frame.cofxon 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 retiredinject-cofx-interceptor framing is gone; recordable facts are delivered verbatim from the token and emit no run op). The emit rides inside the supplier'swith-handler-scopebinding, so its source-coord /:rf.trace/trigger-handler/:rf.trace/call-siteride 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/valueis 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/:largemarks (mirroring:rf.fx/handled's:rf.fx/argsredaction): the redaction is wired to the value that egresses, so a declared-sensitive produced value never surfaces in trace.:rf.cofx/argis 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/valueand the arg under:rf.cofx/arg, so the marks redaction — which targets:rf.cofx/value— acts on the produced value.):rf.cofx/elapsed-msis the dev-only wall-clock of the supplier invoke. The whole emit (tag-map +emit!) sits inside theinterop/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/idcarries the generated fact's name + supplier id (the cofx id is both),:rf.cofx/valuethe PRODUCED value (redacted at the classification chokepoint against the cofx's declared marks, exactly like:rf.cofx/run),:rf.cofx/argthe 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 insideinterop/debug-enabled?; the produced value rides the durable:rf.cofxrecord, 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 theviews.cljsframe-aware-view wrapper at the START of every registered-view render (per Spec 004 §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 / Helix 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/idkeyword the registered view id. :framekeyword the frame the render landed in. :rf.view/mount?booltrueon the instance's FIRST render,falseon 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.cljsframe-aware-view wrapper, so it rides Reagent / UIx / Helix uniformly). Absent on a no-arg render (additive — pre-existing:rf.view/renderedconsumers 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-byquery-idthe 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/runset). 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-msnumberwall-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-idevent-id (when in-run) the dispatching run's :rf.event/run-startevent-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-subschanged value this run (intersect:rf.view/deref-subswith the run's value-changed:rf.sub/runset) → 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-byis absent → label it← parent re-render, UNNAMED (no component-tree / props-diff capture exists; the structural parent is never named).:rf.view/deref-subsis what makes the reason PER-VIEW rather than run-wide.:rf.view/render-argsprivacy (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 ridesinterop/debug-enabled?(the wrapper passesnilin 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 byre-frame.trace/build-eventon every emit) routes EACH positional arg throughre-frame.elision/elide-wire-valueagainst the frame's app-db elision registry — paths classified:sensitive(the commit-plane:sensitiveeffect / 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/renderedper run with a one-shot:rf.view/rendered-cap-reachedmarker (: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-subssink, and the:rf.view/render-argscapture — sits insideinterop/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 theunmountaction. The whole surface sits insideinterop/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 oner/with-let'sfinallyarm uses): theviews.cljswrapper 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 / Helix). Those substrates run theviews.cljswrapper inside a function component with no tracked render reaction (they don't publish:adapter/make-reaction, so the reaction-dispose arm above no-ops), so the shared React-hook spine'swrap-viewseam (re-frame.substrate.spine/make-wrap-view) arms aReact.useEffectempty-deps cleanup that fires the emit on unmount — one-shot teardown matching the Reagent path. The instance-token is minted into auseRefso the:rf.view/render-keytuple is stable across re-renders; the:frametag is captured in-render so the cleanup (which runs outside the React render) reports the frame the instance rendered under. The emit reachesre-frame.views/emit-view-unmounted!through the:views/emit-view-unmounted!late-bind hook so the spine carries no static views dependency.See Spec 004 §Render-tree primitives. -
:rf.cascade/captured— focused-event-only per-epoch cascade-DAG aggregator emitted byre-frame.trace.cascadeat end-of-epoch (after the cascade buffer has been harvested but before:rf.epoch/snapshottedfires).: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 viare-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? trueand 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-attemptper 014 §Retry and backoff). Tools that filter for issues subscribe to:warning/:error; tools that surface activity timelines subscribe to:infoas well. - Frame-exit machine teardown — single emit on the lifecycle channel. When a frame's destroy walks each surviving machine snapshot,frame.cljcemits 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-idis reserved for the registered TYPE) — symmetric with the fx-substrate:rf.machine/destroyed. The:reasontag discriminates why the actor went away —:parent-frame-destroyedis the ONLY reason this channel carries (frame-exit reaping is its sole trigger); the fx-substrate's:rf.machine/destroyedemit sites (lifecycle_fx/finalize.cljc+lifecycle_fx/destroy.cljc) carry every non-frame-exit reason under the same:reasonslot (:rf.machine/finishedfor natural termination,:rf.machine/join-reapedfor the non-cancellation reap of an already-terminal:spawn-alljoin child,:explicitfor cancellations — imperative destroy and parent-cascade teardown alike — plus the reserved:parent-unmount-cascade; see the matrix below). Tools that just want "an actor instance appeared / went away" subscribe to:op-type :rf.machine.lifecycle/*and branch on:reasononly when they need cause-specific routing.The
:reasonenum — the canonical channel/reason matrix. Each reason belongs to exactly ONE channel: the registrar-substrate:rf.machine.lifecycle/destroyedcarries only the frame-exit cause; every non-frame-exit cause rides the fx-substrate:rf.machine/destroyed. (Spec-Schemas §:rf/trace-eventand 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.):reasonChannel Emitted by Meaning :parent-frame-destroyed:rf.machine.lifecycle/destroyedlifecycle_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/destroyedlifecycle_fx/finalize.cljcThe actor reached a :final?state and the runtime auto-destroyed it after firing the parent's:on-done.:rf.machine/join-reaped:rf.machine/destroyedlifecycle_fx/destroy.cljc(verified join-resolution reap form)A :spawn-alljoin reaped an already-terminal (:completed/:failed) child at resolution — a non-cancellation post-completion cleanup, so no second, contradictory terminal cancelled reply (see 005 §Spawn-and-join via:spawn-all).:explicit:rf.machine/destroyedlifecycle_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:canceldispatch drives), or a:spawn-allcancel-on-decision. The cancellation discriminator.:parent-unmount-cascade:rf.machine/destroyedreserved — no current emit site Declared by 005 §Final states D6 to discriminate a parent-cascade child teardown from a direct destroy. The reference implementation does not emit it: parent-cascade teardowns stamp :explicit(see that row). Reserved on this channel for a future runtime that discriminates the two.The enum is open per §
:tagsis 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
:reasonslot carries the cause:Channel Source-of-emit What it observes Typical consumer :rf.machine.lifecycle/destroyedlifecycle_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/destroyedlifecycle_fx/finalize.cljc+lifecycle_fx/destroy.cljcThe 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. Tools that "just want did a machine appear/disappear?" pick either channel and rely on the
:reasonslot for cause. Tools building causal graphs (Pair, Xray, Story) subscribe to both and disambiguate via:tags :emitted-from. The naming axis (:rf.machine.lifecycle/*vs:rf.machine/*) carries the source-of-emit distinction; the:reasonslot carries the cause. A rename to:rf.machine.fx/destroyed/:rf.machine.registrar/destroyedwas 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 byrouter.cljcwhen 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:aftercallbacks 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-destroycascade 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-countis 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/destroyedsiblings; 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.-snapshottedfires 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:outcomeenum from:rf/epoch-record(:ok/:halted-depth/:halted-destroy/:halted-handler-exception, per Spec-Schemas §:rf/epoch-record§Outcomes).-outcomefires immediately after-snapshottedat the same cascade-trailer point and carries the consumer-facing summary:outcomeenum (: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-idso 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.-restoredfires after a successfulrestore-epoch!;-db-replacedfires 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:outcometag is required on-snapshottedand-outcome, absent on the other two).The consumer-facing
:outcomemapping (pinned inimplementation/epoch/test/re_frame/epoch_test.cljoutcome-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:okThe run settled cleanly. :halted-depth:blockedThe drain hit the configured depth limit; the halting event never ran. A drain-shape stop, not an error. :halted-destroy:blockedThe frame was destroyed mid-drain — a deliberate lifecycle stop. :halted-handler-exception:errorSchema-reserved cause; the reference runtime currently does NOT emit this (handler throws route through the interceptor error-capture seam and settle :okwith 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-destroyis a deliberate lifecycle stop (the frame's owner asked for the frame to go away mid-run) and:halted-depthis 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:blockeddistinguishes them from genuine errors that consumers want to flag. -:rf.epoch.cb/silenced-on-frame-destroy— listener-silencing notification emitted once per(frame, cb-id)pair when a frame previously observed by aregister-epoch-listener!callback is destroyed (per Tool-Pair §Surface behaviour against destroyed frames and).:op-type :rf.epoch.cb.:tags {:frame <id> :cb-id <id>}. The callback registration remains in place; the trace exists so a tool whose previously-firing cb has gone silent learns why without polling registry state. 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.
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.
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 (handler / snapshot reaped; includes frame-exit reaping) |
:rf.machine/destroyed (a destroy fx ran; does NOT fire for frame-exit reaping) |
Which axis to subscribe to. A tool that just wants "did an actor appear / disappear?" subscribes to the registrar-substrate axis (:rf.machine.lifecycle/*) — it is the complete, fx-independent record of instances entering and leaving the registry. A tool building a causal graph ("which fx caused this spawn / teardown?") subscribes to both axes and correlates by :spawned-id (spawn) / :actor-id (destroy) plus the cascade :rf.trace/dispatch-id. The spawn pair is symmetric with the destroy pair: each substrate emits exactly one event per spawn and one per fx-driven destroy. The asymmetry — that :rf.machine.lifecycle/destroyed also fires for frame-exit reaping while its fx-substrate partner does not — is intentional: the registrar-substrate axis is the source of truth for "the instance is gone," the fx-substrate axis is the source of truth for "a destroy fx caused it."
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
:tagsare 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-typevalues 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:
: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
;; — 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-db — APP-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/:resultare 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/skipcarries:reason :inputs-value-equalrather 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/failedre-throws so run-level error-handling (Spec 009 §Error contract's:rf.error/flow-eval-exception) still fires; the per-flow:rf.flow/failedadds 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 synchronous, event-at-a-time delivery — every registered listener is invoked once per emitted trace event while the runtime is still on the emit call stack. Listener-invocation order is not contract; tools must not depend on the order in which sibling listeners receive a given event. There is no batching, debounce window, or background delivery loop. 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.
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) ;; one :rf/epoch-record per drain-settle
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 dequeued event
;; (one per epoch). 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. The callback fires once per dequeued event — its 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 fires the callback twice — once per event — not once for the drain. A machine's:raisesub-events and:alwaysmicrosteps do not fire it: they ride inside the triggering event's epoch (per 005 §Drain semantics). The callback also fires for halted events (:halted-depth,:halted-destroy; see Spec-Schemas §:rf/epoch-record§Outcomes), discriminated by:outcome. - After commit. The callback receives a fully-formed record with
:db-after,:sub-runs,:renders,:effects, and any optional:trace-eventspopulated. The record has already been appended to the frame'sepoch-historyring buffer when the callback runs. - 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 ofgoog.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. 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 write-side stages are 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.)
- 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 anregister-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):
- 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.
- Eviction unit mismatch. Trace volume per run is wildly uneven — a single click run through a machine entry can emit hundreds of
:rf.sub/skipshort-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-idconsumes 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) vstrace-events(individual instrumentation emissions) is the one known misread. The knob is theevent-*noun family (per Conventions §Theevent-*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-historyis already retained per-event (one assembled:rf/epoch-recordper 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.)
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:frametag 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-framesub-cachealready 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:frameand 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/globalring, 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/:operationthemselves. 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
registerfor an id with no prior emit always fires. - A
replacewhose new shape matches the last-emitted shape is suppressed. - A
clearof 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-retainedslots, 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/skipruns in:rf/xraydoes 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-idacross 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 (returnsnil). 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
Nevents, 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>)returningniland 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
:skipat 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:skipflood. 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/defaultcluster (the:rf/default+nilslot)." 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 ofreg-*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? truehandler 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:advancedproduction 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.
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/createdfor the frame itself, and every:rf.error/*whose:frameis the marked frame. The framework's emit sites already thread:frameonto 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 callsre-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?whenas the rest of the emit substrate, so it DCEs out of:advancedproduction 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-arityfor malformedreg-eventinterceptor-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, namingreg-event/reg-interceptor).subs.cljc—:rf.sub/create,:rf.sub/run(the purecompute-subform — base shape only, no value-change/cascade attribution; see the:rf.sub/runop-type entry above);:rf.error/no-such-suband:rf.error/sub-exceptionfor failure paths.subs/memo.cljc—:rf.sub/runper 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/valueare emitted raw and redacted downstream by there-frame.classification/project-sub-tagschokepoint — NOT byelide-wire-value, whose container deref would break glitch-free reaction layering; and the:rf.sub/runop-type entry above);:rf.sub/skipper memo-hit (input value-equal to last-seen → user body suppressed; and Spec 006 §Invalidation algorithm).subs/cache.cljc—:rf.sub/disposeper cache-slot eviction (closed-enum:rf.sub/reason :no-more-derefers/:hot-reload/:cache-clear/:frame-destroy; and the:rf.sub/disposeop-type entry above). Single-fire under CAS-winner contention. The:frame-destroyreason is emitted fromdispose-all-for-frame-destroy!, whichre-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 fromepoch.cljc/settle!via the:trace.cascade/capture-for-epoch!late-bind hook when the installed focus predicate matches.fx.cljc—:rf.fx/do-fxper drain step (op-type:rf.fx; the emit's:tagsadditionally carries:rf.event/fx(the vector the handler returned) and:rf.event/db-present?(boolean — was the handler's return-map's:dbslot supplied?) so consumers can align run rows with handler returns without re-reading the interceptor context; the:dbVALUE is intentionally NOT stamped — slice changes already ride:rf.event/db-changed. Both slots sit under:tagsalongside:frame, consistent with the payload-shaped tag convention),:rf.fx/handledper 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/spawnedand: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/inputsearns no dedicated error id — it rides the generic:rf.warning/unknown-dispatch-optat the dispatch boundary, not fromcofx.cljc, per Conventions §The tombstone rule),:warning :rf.cofx/skipped-on-platform(emitted when a registered cofx's:platformsexcludes the active platform; mirrors:rf.fx/skipped-on-platformper 011 §Effect handling on the server).router.cljc—:rf.event :rf.event/run-startand:rf.event :rf.event/run-end(the pipeline-run markers; both also carry the redundant:rf.trace/phase :run-start/:run-endtag),: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:dbeffect that left app-db unchanged; the complement ofdb-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/runtimeroot 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/runtimeroot 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-errorlate-bind hook so the discriminable teardown signal survives production),:warning :rf.warning/teardown-hook-exception(emitted fromsafe-call-hook!when an optional late-bound cleanup hook throws duringdestroy-frame!— 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 fromconfigure-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 areg-*registration omits:doc; per 001 §:docis 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.cljcis 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 frommachines/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 unifiedevaluate-guardhelper at every user-declared guard call site inmachines/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 byresolve-guardfor a nil guard-ref does NOT emit),:rf.machine/action-ran(emitted fromrun-actionfor 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+:exceptionbefore propagating theresult/fail),:rf.machine.event/unhandled-no-op(the benign no-op when no transition matched —machines/transition.cljcfor flat / compound,machines/parallel.cljcfor 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 bymachines/lifecycle_fx/spawn.cljcon both the single-:spawnand:spawn-alljoin-init paths), plus the machine-error categories.routing.cljc—: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-blocked(enter block),: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/route-guard-loop.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 acrossresources/events.cljc,resources/registry.cljc,resources/timers.cljc,resources/route.cljc, andresources/ssr.cljc) — the:rf.resource/*lifecycle trace family (all:op-type :rf.eventexcept 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-timereg-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 alongsidework-startedon the same start;events.cljcensure 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-countof 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 an opaque content-addressed{:rf/redacted <digest>}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:loadedand records:page-error; distinct from a first-load:rf.resource/failedand 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 —nilcursor) /:in-flight(joined a live page fetch, no new generation)), the:warning :rf.warning/resource-load-more-owner-ignoredrow (events.cljcload-more path — a load-more given a non-route:owner: the owner is IGNORED, NOT attached to:active-owners/:owner-index, so no durable lease 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,: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.cljctimer-fire path;poll-firedcarries 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.cljcarm path),:rf.resource/removed(events.cljc+registry.cljc),:rf.resource/stale-suppressed(the SINGLE suppression op),:rf.resource/route-plan(route.cljc, route-entry resource planning), and the SSR / restore-reconcile family:rf.resource/hydrated/:rf.resource/hydrate-refetch(per-entry refetch-plan row) /:rf.resource/restoredplus the two:warning :rf.resource/hydrate-clock-skew/:warning :rf.resource/restore-clock-skewrows (ssr.cljc— a hydrated/restored entry's absolute:stale-atis 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-ownerare dispatched EVENT IDs (they appear only as the:rf.event/dispatchedevent 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(fromvalidate-app-schema!/validate-event!/validate-fx!/validate-sub!; there is no injection-timevalidate-cofx!— the live cofx schema surface isre-frame.cofx's:rf.error/cofx-value-invalid),:rf.error/malformed-schema(fromvalidate-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 fromreg-app-schema/reg-app-schemaswhen:schemas/malli-validateis 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 fromreg-app-schema/reg-app-schemaswhen the registered schema is a non-vector form — registry-ref keyword, compiledm/schemaobject, or other opaque value — so the walker cannot introspect per-slot:sensitive?/:large?flags; per 010 §The:schemavalue 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 inschemas.cljcand does not reach here).spec.cljc—:rf.error/schema-validation-failure :where :event :source :boundary(from the:rf.schema/at-boundaryinterceptor; per 010 §Production builds and).events.cljc—:rf.error/at-boundary-missing-schema(thrown fromreg-event-*when:rf.schema/at-boundaryis attached to a handler whose metadata-map carries no:schema; per 010 §Production builds and).events.cljc—:rf.error/reg-event-bare-interceptor(thrown fromreg-eventwhen a BARE interceptor — a map carrying:before/:after— is handed where metadata:interceptorswas 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:idmismatch),:rf.error/unregistered-interceptor(resolve-ref— an event/frame chain references an unregistered interceptor id; validated atreg-event/make-frameregistration 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, andevents.cljcat 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(carries:failing-idto discriminate body-mismatch from head-mismatch; 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.epoch.cljc—:rf.epoch/snapshottedper dequeued event (one per epoch),:rf.epoch/restoredon restore success,:rf.epoch/db-replacedon 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-destroyemitted once per(frame-id, cb-id)pair on the destroy-cascade boundary, 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/renderper registered-view render (per Spec 004 §Render-tree primitives). Per the same wrapper additionally fires:rf.view/renderedcarrying run attribution (:rf.view/id,:frame,:rf.view/render-key, plus — when an in-flight run buffer is available —:rf.view/cause-event-idand:rf.view/cause-subs). Per:rf.view/renderedALSO carries:rf.view/mount?(a boolean —trueon the component instance's first render,falseon 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-subswhich over-reports). Per the wrapper ALSO emits the new:rf.view/unmountedop when a registered-view instance tears down (carrying:rf.view/id,:frame,:rf.view/render-key). The emit sites sit inside the substrate-agnosticviews.cljsframe-aware-view wrapper, so every adapter (Reagent ratom watch chain, UIx hooks, Helix hooks) composes them; the new ops/fields ride the same per-render / per-instance-teardown path.:rf.view/renderedis capped at 100 per run with a one-shot:rf.view/rendered-cap-reachedmarker (carries:frame+:rf.view/dropped-after) to bound the per-run buffer's heap budget for full-page re-render storms (:rf.view/unmountedis NOT capped — one emit per instance teardown). Consumers (Xray Reactive / Views panels) walk the per-run buffer's:rf.view/renderedentries to graph cause→effect attribution and read:rf.view/mount?/:rf.view/deref-subs/:rf.view/unmountedto label each view's mount-vs-rerender-vs-unmount ACTION and its per-view reactive REASON; tools that already consumed:rf.view/renderfor render-count metrics continue to work unchanged.adapter/context.cljs—:rf.error/frame-context-corrupted(function-component_currentValueread observed a non-coercible shape;).frame.cljc(provider-arg validation) —:rf.error/bad-frame-provider-arg(a publicframe-providerwhose:frameis non-nil but neither a keyword nor a live frame value — API-shrink #1, rf2-csbbwu normalized:frameto accept a frame value alongside a keyword id; rides the always-on error-emit axis via the:error-emit/dispatch-on-errorlate-bind hook, then throws. Validated at every public provider entry point — Reagentre-frame.views.provider/frame-providerand the shared UIx/Helix corere-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-keyword-frame). The split frame-boundary components additionally validate their mount opts at the entry points (diagnostic-channel, thrown ex-info, viare-frame.views.frame-boundary): the ENSURE component (rf/frame-root {:id …}) rejects a missing/non-keyword:idwith:rf.error/frame-root-missing-id, a stray:framewith: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-absentand a stray:idwith:rf.error/frame-provider-given-id.substrate/adapter.cljc—:error :rf.error/write-after-destroyemitted by thereplace-container!wrapper when called with a nil container (the frame was destroyed mid-drain or before a scheduled write fired; the underlying adapter'sreplace-container!is NOT invoked). Rides the always-on error-emit axis via the:error-emit/dispatch-on-errorlate-bind hook — the substrate cannot static-requirere-frame.error-emit. Per 006 §replace-container!and.http_managed.cljc+http_encoding.cljc(the HTTP artefact ships eighthttp_*.cljcfiles; emits cited here come fromhttp_managed.cljcunless 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:beforethrew, 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¶
All dev-side instrumentation is a development-only concern. In production builds, the trace surface, the schema validation surface (Spec 010), 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; production binaries contain no instrumentation machinery at all.
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:
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)
defoncecells (which carry{}and0). - 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:
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
falseexplicitly so dev-side trace enrichment elides in production. The runtime gate below is the JVM mirror of CLJSgoog.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:
- Java system property
re-frame.debug— set on the JVM command line with-Dre-frame.debug=false. - 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/dispatchedemit) 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-eventspayloads, fires noregister-epoch-listener!listeners, and refusesrestore-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
:eventsstream ofregister-listener!, per §Event-emit listener) keeps firing. - The error-emit substrate (the
:errorsstream ofregister-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:
implementation/core/test/re_frame/elision_probe.cljsis 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 representativedispatch-syncflow. The probe roots the dead-code-elimination graph at every surface so a leak surfaces in the bundle.implementation/shadow-cljs.edndeclares two:advancedbuilds withre-frame.elision-probe/runas the entry point::elision-probe—:closure-defines {goog.DEBUG false}(production):elision-probe-control—:closure-defines {goog.DEBUG true}(control)implementation/scripts/check-elision.cjsgreps both bundles for sentinel strings drawn from the gated branches (schema reason fragments and:rf.registry/*trace operation keywords). The contract:- Production bundle: every sentinel MUST be ABSENT.
- Control bundle: every sentinel MUST be PRESENT.
- 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-probeso the DCE graph reaches it. - Add a sentinel to
DEV_ONLY_SENTINELSincheck-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 aroundtrace/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'sswap!site is inside the same elision gate. register-epoch-listenerand the per-event:rf/epoch-recordassembly — epoch projection runs inside the trace surface and elides with it.- 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:errorsstream ofregister-listener!— is a documented exception: it rides a small always-on error-emit substrate that survivesgoog.DEBUG=false, fanning out one tight record per production-reachable runtime:rf.error/*. See §What IS available in production below.) - Source-coord enrichment (
:rf.trace/trigger-handler),:rf.trace/dispatch-id/:rf.trace/parent-dispatch-idcorrelation,:rf.event/origintagging — all ride the trace event and elide with it. - Schema validation (
:rf.error/schema-validation-failure) and registrar hot-reload notifications (:rf.registry/handler-registeredand siblings) — same gate, same elision. - 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:
- The event-emit listener surface (
register-listener!/unregister-listener!with the:eventsstream — 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 byre-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-idcorrelation,:rf.trace/parent-dispatch-id, source-coord ride the dev-only trace surface and elide with it).:outcomereports 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:okto an off-box shipper::ok(clean settle — db committed, flows ran,:fxwalked),: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:fxwere skipped.:rolled-backis the STABLE public vocabulary fortransaction rejected; it does not imply a physical write-pair — rf2-uhk9ko), or:flow-error(a flow's:outputthrew per 013 §Failure semantics rule 3 — the cascade halted before:fx). The chain-exception path reports:erroreven when a downstream rejection would also apply: it is the proximate, most-actionable signal, and a chain throw short-circuits the:dbcommit so there is no candidate to validate. The:eventvector is passed throughre-frame.elision/elide-wire-valueONCE 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 production-reachable 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:errorsstream ofregister-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:exceptionobject. This is the documented exception to the always-on axis's "structured data only — never raw values" rule (§The promotion criterion): the:eventvector is elided through the wire-walker, but the opaque:exceptionrides raw for the shipper. The NORMAL production observation surface is the frame-owned:observability :errorssink (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,:exceptiondropped 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:tagstree-key so a:hook-failuresentry'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:
- Raw trace listener —
register-listener!/unregister-listener!(§Listener API). - Assembled-epoch listener —
register-epoch-listener!/unregister-epoch-listener!(§Assembled-epoch listener, Tool-Pair §Time-travel). - Event-emit listener —
register-listener!/unregister-listener!(the:eventsstream) (API.md §Event-emit). - Error-emit listener —
register-listener!/unregister-listener!(the:errorsstream) (API.md §Error-emit). - Performance API channel —
performance.measurebrackets (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 ofgoog.DEBUG, defaulttruein dev /falsein:advancedprod CLJS, defaulttrueJVM with-Dre-frame.debug=falseopt-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=falseJVM-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 withgoog.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 |
elided — emit! 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 runs at build-time before ring-append |
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 production-reachable 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 off — re-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
:observabilitysink declared withregister-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/:errorsstreams ofregister-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:observabilitysink 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, devregister-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 fully-projected per-run slice (:db-before, :db-after, :trace-events); the recorder appends one record per run with no further shaping. (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
:errorsstream ofregister-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-coordfor 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 production-reachable runtime:rf.error/*; 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 captureswindow.onerror,window.onunhandledrejection, and any explicitSentry.captureExceptioncall 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
:advancedwith:closure-defines {goog.DEBUG true}. The trace surface is preserved, theregister-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:
- Trace-event allocation — building the trace map per emit.
- 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 formperformance.measure(name, {start, end})with numericperformance.now()timestamps rather than named marks. Noperformance.markentries are ever allocated — the two marks per bracket were pure buffer growth with no documented consumer (nothing reads the:start/:endmarks). 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 there-frame.performance/retain-entries?goog-define(default off). A livePerformanceObserverstill receives the entry — observer callbacks fire atmeasure()time, before the clear — so timing is delivered to any attached observer / host APM; the retained buffer thatgetEntriesByType('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):
:examples/counterbuilds the standard counter example under:advancedwith the perf flag off (the goog-define default).:examples/counter-perfbuilds the same source under:advancedwith:closure-defines {re-frame.performance/enabled? true}.scripts/check-perf-bundle.cjsgreps both bundles. The contract:- Off bundle MUST NOT contain
performance.measure,clearMeasures, or any"rf:entry-name fragment (and — since the bracket allocates no marks — MUST NOT containperformance.markeither, in both bundles). - 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 falseso the macro expansion's(if enabled? ...)is statically dead and the JVM body runs as if instrumentation were absent. - Expands
mark-and-measureto(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-typevalues (:rf.machine/transition, etc.) for state-machine activity. - Per-frame override visibility —
:fx-overrides/:interceptor-overridesare 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-idsenumerates them.
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:
- The defrecord.
re-frame.trace/HandlerScope's positional slot list gainsX(constructors->HandlerScopecallers update; all explicit->HandlerScopeliterals inwith-call-siteandwith-dispatch-id+call-siteadd the new positional arg). - The meta-derived reader (if
Xis meta-derived).handler-scope-from-metareads the slot off the registrar meta map at scope-bind time, with the same nil-when-absent convention as:sensitive?/:no-emit?. - The inheritance rule (if
Xinherits from parent scope).inherit-scopeadds a(nil? (:X new-scope)) (assoc :X (:X parent))branch — mirror of the:call-site/:dispatch-idbranches. Slots that are purely meta-derived (innermost-wins) need noinherit-scopechange. - The emit-side hoist (if
Xrides emitted events).build-eventreads 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. - This canonical slot list. The two tables above (§Canonical slot set and §Emit-side hoist contract) gain a row for
X. - Reserved namespace (if
Xis 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 inConventions.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 (:erroror: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.
- The causal channel — effects-as-data, replayable, part of the semantic value. It is the program: a
:dispatch/:fxentry is data the run executes, not a log line. Never elided. - The diagnostic channel —
trace/emit!(every:op-typetrace 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 undergoog.DEBUG=false(CLJS:advanced), runtime-gated onre-frame.debug(JVM). For development eyes and tools. - The always-on error axis — the error-emit substrate (the
:errorsstream ofregister-listener!, surface #4, per §What IS available in production). Production-survivable: it is NOT gated byre-frame.interop/debug-enabled?, so it survives:advanced+goog.DEBUG=false(CLJS) and-Dre-frame.debug=false(JVM), fanning out one tight record per production-reachable:rf.error/*to app-registered shippers (Sentry / Honeybadger / Rollbar).
Production guarantees, once:
- CLJS
:advanced+goog.DEBUG=falseDCEs 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=falseruns 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:
- 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).
- Contract breach or resource leakage — it indicates a state the next operation cannot see locally: leaked handles, skipped teardown, suppressed writes, corrupted invariants — rather than a malformed input the caller can observe and fix at the call site.
- 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. 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 production-reachable :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 one graduated catalogue row to date; 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; a hook 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-failedcarrying a:hook-failuresvector (:recovery :ignored— teardown stays best-effort). The per-hook detail stays on the diagnostic channel as:rf.warning/teardown-hook-exceptionat its causal positions insidesafe-call-hook!(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).
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 renderex-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/idis the SOLE canonical machine discriminator.(:rf.error/id (ex-data e))returns the keyword for structured branching. Toolscase/condpon 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 andfalseidentically (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:
Shape and reservation:
- Value is a string (the
pr-strof 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-eventdirectly as a fn). - User-supplied
:rf.handler/sourcein the registration metadata-map overrides auto-capture, mirroring the:ns/:line/:fileoverride semantics of Spec 001 §Source-coordinate capture so code-gen pipelines can stamp the originating source. - Coverage is scoped to
reg-eventonly at the top-level registration's:eventregistry 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-machineis a partial exception: it does NOT carry:rf.handler/sourceon the top-level:eventslot, but it DOES capture per-guard and per-action fn-source — co-located onto each:guards/:actionsentry inside the machine's own:eventregistration 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/sourceis derived on demand* from the enclosing:eventspec 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-actionside-table —(rf/registrations :machine-guard)returns{}. See Spec 005 §:machine-guard/:machine-actionhandler-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 production-reachable runtime:rf.error/*set enumerated in the paragraph below. - diagnostic — the category rides the dev-only trace surface, DCE'd in CLJS
:advancedproduction and JVM-gated onre-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, wherere-frame.debugis dev-default-on, not through the CLJS always-on axis). Athrown ex-inforegistration rejection (the "Surfaced as a thrown ex-info" rows) is diagnostic-channel for catalogue purposes — it is not delivered to the error-emit listener.
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: the trace emission and schema/type validation behind every row here are production-elided per §Production builds. See Spec 000 §Contract C-000.35 for the production-elision-equivalence clause this section grounds.
OUT-OF-CATALOGUE. Two emitted :rf.* categories are NOT error-event-catalogue rows; the co-edit invariant excludes them. (1) :rf.route/navigation-blocked rides the :rf.event op-type — it is a dispatched user-event lifecycle trace (the no-op default event a :can-leave block dispatches, per 012 §Navigation blocking), NOT an error / warning / advisory category. 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 production-reachable 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, and :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) — each production-reachable on a long-lived JVM SSR host or a goog.DEBUG=false client build where the dev trace is elided. These seven 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) and the post-commit members (:rf.error/ssr-streaming-writer-failed, :rf.error/sanitised-on-projection) 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, and their named home is the resources trace family + the observability-sink routing, not this axis (see their catalogue rows). 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/schema-validation-failure, :rf.error/at-boundary-missing-schema, the :rf.warning/* advisories, the :rf.epoch/* rejections) stay dev-trace-only — they fire on dev-only paths that production never reaches, so there is nothing to survive. 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).
: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) |
:rf.event/v (vector), :failing-id (the event id), :handler-id (the event id), :phase (:before), :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) |
:rf.cofx/id, :failing-id (the fully-qualified cofx id), :phase (:before), :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 |
:rf.event/v (vector), :failing-id (the interceptor :id), :phase (:before / :after), :source-coord ({:ns :file :line}, macro path only), :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), :state (the active state path — the always-on attribution, same slot :rf.machine/guard-evaluated uses), :action-id, :state-path, :transition, :rf.event/v, :exception-message, :exception-data (the last three 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, :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 :where tag (:reactive / :compute-sub) discriminates the resolution path |
:replaced-with-default — the sub returns nil; views see no value |
:sub-query, :rf.sub/id, :where, :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, :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 lease 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 §Lease 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 lease'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 — renders/commits caused by the epoch-close notify — run outside the fan-out and never trip it. 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 lease / 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 structural evidence only (the :kind + the target's key-set, never the field VALUES — a :story-override embeds an app value under :value). Thrown by re-frame.substrate.observation/probe / …/acquire! |
:no-recovery — the op throws; construct targets via resolve-target |
(thrown ex-data: :rf.error/id, :where, :kind (the malformed value, when a map), :target-keys (the target's key-set — structural, no field values)) |
:rf.error/observation-malformed-lease |
:error |
diagnostic | A lease-taking observation port op (read / release!) received a value that is NOT a real ObservationLease — nil, a map, or any arbitrary host object. read / release! field-access the lease state (lease-state) and then DEREF it, so an unvalidated non-lease 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 LEASE arm). The port validates at the shared lease 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 lease is not a target, so it earns its own id — not a second family). current? is EXEMPT — it is a pure no-throw kept-check predicate and returns false for a non-lease, never throwing. A pure throw-error! on the diagnostic channel — a non-lease reaching a lease 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; leases come from acquire! |
(thrown ex-data: :rf.error/id, :where, :lease-type (the offending value's host type)) |
:rf.error/observation-port-version-mismatch |
:error |
always-on | day8/re-frame2-ui loaded against a core whose re-frame.substrate.observation/port-abi-version differs from the version it compiled against — the R-6 lockstep release train's explicit ABI drift guard (per 006 §The internal observation port §Scope). 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; align the core and re-frame2-ui artifact versions (they release together) |
: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 day8/re-frame2-ui ViewCell mark-dirty defect (per 006 §Disposal-notification callback failures). The drain contains each lease'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-lease / 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 on-change consumer bug is a re-frame.ui defect to fix |
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), :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), :reason |
:rf.error/schema-validation-failure |
:error |
diagnostic | A :schema-validated value failed validation |
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 validation entirely (per Spec 000 §Contract C-000.35), so this row applies only in dev. 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 |
:where (:event/:sub-return/:app-db/:fx-args/:flow-output/:machine-data/:machine-output/:sub-override), :path, :value, :explain (Malli explanation map) |
: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), :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): :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 is production-survivable through the always-on error-emit listener (surface #4); 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 004 §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/Helix 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-keyword-frame-provider-arg!) |
:supply-keyword-frame — 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-keyword-frame), :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-id — frame-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 |
diagnostic | 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. 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 |
:rf.trace/event-id (first of the event vector, when vector-shaped), :rf.event/v (vector), :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 |
:overrides-map, :looked-up-id |
:rf.error/reserved-fx-override |
:error |
always-on | A :fx-overrides entry (fn-value or keyword-redirect) targeted a REJECT-tier reserved fx-id — a state-installing lifecycle fx (:rf.machine/spawn, :rf.machine/destroy, :rf.fx/reg-flow, :rf.fx/clear-flow) or the nav-token threader (:rf.route/with-nav-token) — which may NOT be overridden because its body installs/clears durable frame runtime-db state (or threads a correctness-critical nav-token) that later framework behaviour depends on far from the override site (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. 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, :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, :recovery (:skipped) |
:rf.ssr/hydration-mismatch |
:warning |
diagnostic | First client render diverges from server-supplied render-tree, OR the client-computed head model differs from the server-supplied head. The :failing-id discriminator routes the two cases (:rf/hydrate for the body, :rf.ssr/head-mismatch for the head). Per 011 §Hydration-mismatch detection and 011 §Mismatch detection — head |
:warned-and-replaced — body: re-render client-side; the server's HTML is replaced. Head: client renders its head; server's is replaced |
:server-hash, :client-hash, :failing-id, :first-diff-path? (body), :head-id (head) |
: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-replaced — 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-replaced — 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 | A compatibility-check fx (:rf.ssr/check-version or :rf.ssr/check-schema-digest) fired but no actual-value hook (:rf2/runtime-version for version, :schemas/app-schemas-digest for schema-digest) is registered, so the comparison cannot be made. The fxs never throw — degraded-but-running is the locked posture. Per 011 §The :rf/hydrate event and |
:logged-and-skipped — the comparison is no-opped; hydration proceeds |
:check (the calling fx-id, e.g. :rf.ssr/check-version), :reason (why no actual value could be resolved) |
:rf.ssr/invalid-version |
:warning |
diagnostic | A :rf/version source value (the host's explicit :version build opt or the :rf2/runtime-version late-bind hook) 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 next version source (and ultimately the v1 = 1 integer default), 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 on the trace bus rather than ship a malformed map. Emitted by re-frame.ssr.ring.pipeline/fail-closed-status (ssr-ring/pipeline.clj). 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, :recovery |
: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 004 §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, :recovery |
: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, :recovery |
:rf.error/legacy-runtime-root |
:error |
diagnostic | 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). Dev-tier; production DCE-elides |
: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) |
: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 | A route :resources entry could not be PLANNED on route entry — its :scope / :params did not resolve (a fail-closed scope/params throw caught at the route-resource planning boundary). A failed route-resource plan is surfaced on the route slice's :error (visible to the :rf/route sub + Xray) and emitted as an error trace, NEVER swallowed as a silent cache miss (Spec 016 §Route integration). FIRST-error-wins when several route resources fail to plan. The route still commits (URL / slice publish are not blocked); the unplannable resource is simply not ensured. Per 016 §Route integration |
:fix-params — fix the route resource's :scope / :params resolver (or gate it with :when) so it resolves to serializable EDN that conforms to :params-schema |
:route-id, :resource-id, :nav-token, :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 (mirroring the :on-match error trap), 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, :recovery, :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 lease. 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 lease to the feed (:active-owners + :owner-index) and silently extend its liveness / GC lifetime until an explicit :rf.resource/release-owner — the owner-lease 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, :recovery, :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 |
: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 |
: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 cleanup-hook failure (per §Observability channels and the promotion criterion). destroy-frame! runs a best-effort recipe of optional late-bound cleanup hooks; rather than fan out one always-on emission per failed hook (an SSR per-request-destroy × M req/s flood of the production error shipper), the runtime accumulates the per-hook failures 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 hooks are detail rows, and one record preserves the which-hooks-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 <late-bind-hook-key> :exception <ex> :where :safe-call-hook!} — one entry per failed hook), :reason |
:rf.warning/teardown-hook-exception |
:warning |
diagnostic | 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/on-frame-destroyed, :trace.tooling/release-frame-ring!) 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-hook detail under :hook-failures. (Per-hook visibility is on the diagnostic axis; only the always-on emission collapses to one report.) Emitted from frame.cljc's safe-call-hook!; dev-only (interop/debug-enabled?-gated) |
:ignored — teardown continues; the failed hook's cleanup may be incomplete |
:hook (the late-bind hook key that threw), :frame (the frame being destroyed), :exception, :where (:safe-call-hook!) |
: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), :recovery |
: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 read-side 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), :recovery, :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, :recovery |
: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-provider) or at TOP LEVEL (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-provider in the view tree, or to top-level boot |
:frame (the id under construction), :recovery, :reason |
: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 was already occupied — a live frame present at the guarded-CAS decide, OR a concurrent install that WON the frames-registry CAS in the decide→install window (rf2-vxgfnd.76). 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 (or concurrently-created) 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) 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 upsert-frame! guarded-CAS 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) dev hot-reload 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. Dev-hot-reload-only: production never re-registers after boot, so there is nothing to reproject, and the emit + call site sit inside interop/debug-enabled? (gated a second time inside trace/emit-error!), so the whole path DCEs under CLJS :advanced + goog.DEBUG=false — zero production cost. 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 dev-hot-reload tick has no caller to surface a throw to (it runs on a microtask / 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 — a dev-hot-reload assembly typo is not a fact an app must be able to react to at runtime). Dev-hot-reload-only; 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 |
: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.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, :recovery, :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, :recovery, :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, :recovery, :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, :recovery, :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, :recovery, :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 |
diagnostic | The :rf.server/safe-redirect fx received a :location string that could not be parsed as a URL (per 011 §Redirect precedence). The redirect is rejected; the response accumulator's :redirect slot is unchanged. 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 |
:frame, :location, :reason |
:rf.error/safe-redirect-scheme-rejected |
:error |
diagnostic | 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:). Consistent with the custom-editor scheme rejection (per Security §Editor URI scheme allowlist). Emitted by re-frame.ssr.response/safe-redirect-fx. |
:no-recovery — the redirect is rejected; no Location header is set |
:frame, :location, :scheme |
:rf.error/safe-redirect-host-disallowed |
:error |
diagnostic | 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 carried a host, 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. Emitted by re-frame.ssr.response/safe-redirect-fx. |
:no-recovery — the redirect is rejected; no Location header is set |
:frame, :location, :host, :reason (one of :relative-only-violation, :not-in-allowlist), :allowlist (the allowlist vector, when supplied) |
: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-envelope → validate-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. Emitted from router.cljc, subs.cljc, and substrate/observation.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 surface throws instead — same id, two surfaces |
:frame, :event (the elided attempted event / query vector), :event-id (the vector head), :rf.event/v (dev-trace tag when called from dispatch), :rf.sub/query-v (dev-trace tag when called from subscribe) |
: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, :rf.event/v, :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, :recovery |
: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 child invoke-specs inside the same :spawn-all block share an :id keyword. Each :id must be unique. Surfaced at registration time. Per 005 §Spawn-and-join via :spawn-all |
:no-recovery — registration is rejected |
:machine-id, :state, :duplicate-id |
: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 join before any join-state is seeded, so a never-running child cannot deadlock the :all join. 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. :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, :recovery (:join-hangs), :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.can-leave (routing/can_leave.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 enter-gate mirror of :rf.error/can-leave-non-boolean (rf2-p69yaz Option A — :can-enter is a first-class mirror of :can-leave). The runtime BLOCKS the entry (fail-closed) and emits the loud diagnostic. Emitted by re-frame.routing.can-leave (routing/can_leave.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.error/route-guard-loop |
:error |
diagnostic | A target route's :can-enter guard re-blocked the SAME target across successive :rf.route/continue resume attempts past the loop ceiling — a continue → block → continue spin (rf2-p69yaz point 7). The runtime STOPS re-issuing (fail-closed, no resumable pending slot is stranded) rather than loop forever, and emits the loud diagnostic. Emitted by re-frame.routing.can-leave (routing/can_leave.cljc). Per 012 §Navigation blocking |
:blocked-navigation — the navigation is stopped; fix the :can-enter sub (or its resume policy) so the retried navigation can eventually proceed or the flow cancels |
:requested-url, :rejecting-route (the target), :rejecting-guard, :attempts (the consecutive re-run count), :reason, :recovery, :frame (the navigating frame) |
: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.can-leave (routing/can_leave.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/navigate-arity-misuse |
:error |
diagnostic | A [:rf.route/navigate route-id params opts] event placed an opts-shaped key (:replace? / :scroll / :fragment / :bypass-guards?) in the PARAMS slot (2nd arg) when it is not a declared path-param of the target route — almost certainly a positional swap (the key belongs in the OPTS map, the 3rd arg). Rejected loudly at the event boundary so the swap fails rather than navigating with a wrong URL or silently dropping the intended opts. 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). Move the opts-shaped keys into the OPTS map |
:where (:event), :route-id, :keys (the misplaced keys), :reason, :frame (when scoped) |
: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/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 API (reg-route, match-url, route-url) 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 / :query-retain. 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 attempt failed with a retryable category and the runtime is scheduling another attempt. Per 014 §Retry and backoff | :retried — a new attempt is scheduled; the consumer sees the trace and the eventual final outcome via :on-failure / :on-success |
:request-id, :url, :attempt, :max-attempts, :failure (a :rf.http/* failure-category map), :next-backoff-ms |
: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), :recovery |
: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/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/transport → maybe-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/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 is neither a keyword (DOM tag / registered view / :<> / :> / :rf/suspense-boundary) nor a callable component (a fn or Var) — a string / nil / number / boolean / collection head has no HTML interpretation, and emitting its raw EDN form 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. Thrown by re-frame.ssr.emit (shared by the sync emitter and the streaming shell walker) during server render. Per 011 §XSS at output boundaries |
:use-a-keyword-or-callable-hiccup-head — produce a keyword or callable hiccup head |
:head, :element |
: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-raw-text-in-body |
:error |
diagnostic | Raw string content sits under a body-position <script> / <style> — unsupported: route JSON-LD through reg-head, trusted inline JS/CSS through the shell's :body-end / :head-extra opt. Thrown by re-frame.ssr.emit. Per 011 §XSS at output boundaries |
:move-content-to-reg-head-or-shell-opts — move the content to reg-head or a shell opt |
:tag, :source |
: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 :params / :query supplied to route-url did not validate against the route's declared schema (a caller bug). Thrown by re-frame.routing.registry and surfaced through :rf.route/navigate. Per 012 §Path-pattern grammar (canonical) |
:no-recovery — supply params/query that satisfy the route schema |
:route-id, :slot (:params / :query), :value, :error |
: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 lands with the S5 serialiser). Thrown ex-info (canonical builder), not a trace; Tier-1/dev surfaces only. Emitted by re-frame.ui.tree / re-frame.ui.rules / re-frame.ui.runtime / re-frame.ui / 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) 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. The S2 resource-lease slice (rf2-vxgfnd.106) reuses this id in a third fail-loud arm: (c) a LEASE-DESCRIPTOR arm — re-frame.ui.lease-descriptor/validate-descriptor! (thrown as re-frame.ui/lease) validates every executed (ui/lease …) declaration against the closed v1 descriptor grammar (:resource / :scope / :params) BEFORE anything is captured or dispatched: a non-map descriptor, an unknown key (every offender reported deterministically in :unknown, sorted by printed form), a missing :resource, or a :resource that is not a qualified keyword fails with :recovery :fix-lease-descriptor; the compiled site guards (re-frame.ui.reactive/lease-site) reuse the same id for a lease site executing outside its ViewCell capture or without a lexical site id, and for a compiler site id executed twice in one capture. (This arm superseded the S1 staging row :rf.error/ui-lease-unavailable, retired when the S2 slice replaced the stub — like its (sub …) twin :rf.error/ui-sub-unavailable, retired at S2b, rf2-vxgfnd.8.) |
:no-recovery — fix the template or the runtime value; the escapes are named in the message (the lease-descriptor arm carries :fix-lease-descriptor) |
:value / :attr / :row (per branch), :recovery; the re-frame.ui.semantic version arm adds :got + :supported, its node-discrimination arm adds :value + :got + :path; the lease-descriptor arm carries :recovery (:fix-lease-descriptor) plus per-branch :descriptor-summary (non-map descriptor; also the site guards' descriptor rendering), :descriptor-keys (the map branches), deterministic :unknown (unknown-key branch), :resource-summary (non-qualified :resource), and the site guards add :site-id + :resource-id |
: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). Emitted by re-frame.ui.tree/keyed-run |
:give-each-row-a-distinct-key — keys must be unique per list site 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 |
:error |
diagnostic | STAGING (transient, rf2-vxgfnd): a compiled event handler fired with NO installed dispatch hook — the S1 seam where committed-frame dispatch (S2/S3) will attach. Compiled handlers lower to fns calling re-frame.ui.runtime/dispatch-event!; until frame wiring lands, tests install a capture hook via re-frame.ui.runtime/set-dispatch-hook! and a bare fire fails loudly rather than dropping the intent. Replaced by committed-frame dispatch at S2/S3. Emitted by re-frame.ui.runtime/dispatch-event! |
:install-a-dispatch-hook — tests: set-dispatch-hook!; applications: wait for the S2 frame wiring |
:event, :recovery |
: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 |
:error |
diagnostic | A selector is outside its tier's closed contract. Tier 1: find/find-all accept an unqualified tag keyword, qualified view id or defview var, attr map (rf=), or pred fn; path vectors and other values are rejected. Tier 3: query accepts a native CSS string only. Thrown ex-info (canonical builder); dev/test only. Emitted by re-frame.ui.test |
:use-a-grammar-selector — use the named tier's selector form |
:selector, :recovery |
: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 | ui.test/flush! was called re-entrantly while a frame's run-to-completion event drain was still open. Rendering there could expose a partially settled queued write side. The call throws before draining ViewCell notifications or entering React act; dev/test only. Emitted by re-frame.ui.test |
:no-recovery — let the event drain reach quiescence, then flush once |
:frame, :frame-epoch, :recovery |
:rf.error/ui-test-bad-opts |
:error |
diagnostic | A ui.test opts map violated its CLOSED contract (root-identity-and-mount §9 + the 07 §2 ui.test table): render — an unknown key; {:frame f} AND {:app-db v} together (they are XOR); {: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 — a key other than :app-db (richer construction is rf/make-frame's vocabulary), or a non-map :app-db seed. 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 / :app-db / :sub-overrides (per branch), :recovery |
:rf.error/ui-test-frame-collision |
:error |
diagnostic | A plan-bearing ui.test/render (rf2-vxgfnd.55) declared a frame-root plan for a frame-id that is ALREADY LIVE. A Tier-1 test render OWNS fresh ISOLATED frames — each declared plan mints (and tears down) its own frame, seeded with its :initial-events/config. Running the production ENSURE path over an already-live id would ADOPT the ambient frame (create-if-absent — the re-frame.ui.frames preflight ENSURE), so the plan's declared seed/config would be silently IGNORED and stale ambient state reused — a test-isolation false positive (the assertion still passes on ambient state). Rejected BEFORE any frame/install mutation, validated over ALL of the root's plans so a collision in a later plan installs nothing (zero writes on failure); the pre-existing frame(s) are untouched. Production adoption semantics are DELIBERATELY unchanged — this is the test host's fresh-frame contract, not a frame-root behaviour. Thrown ex-info (canonical builder); dev/test scope only (re-frame.ui.test never ships in production bundles). Emitted by re-frame.ui.test |
:isolate-the-test-frame — destroy the pre-existing frame before rendering (a fresh test frame is created + seeded per plan), or target a held frame with a plan-free form + {:frame f} |
:root-id, :collisions (the already-live plan frame-id(s)), :recovery |
: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/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 mount/create-root/hydrate-root sites in different files resolving to equal root-ids within one build; thrown at macro expansion) 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). 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 (build) / re-frame.ui.client (client) |
:make-root-ids-unique — author :root-id, or add :disambiguator where one view mounts twice |
:root-id, :provenance + :sites (build) / :existing + :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 root — one container, one root (root-identity-and-mount contract §7; rf2-vxgfnd S1c). Thrown BEFORE any render; the owning root is untouched. Thrown ex-info (canonical builder). Emitted by re-frame.ui.client |
:unmount-the-owning-root-first — unmount! the owner, or mount into a different node |
:root-id, :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-prefix — unmount! 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}). The S5 arms per the root-identity-and-mount contract — manifest missing/unreadable at hydrate, schema-version incompatibility, unserialisable props at server emit, identifier-prefix conflict — land with their 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 |
:use-ui-mount — client-only roots mount with ui/mount; hydration needs the S5 server-emitted manifest |
:missing, :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, or 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 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. 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 — create-root + render! (or mount) a fresh root |
:root-id, :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 (06 §2 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). One frame, one plan: frame config belongs in one boot/root site. (The S5 hydrate-preflight arm — a referenced payload id already installed with a different content digest — lands with server rendering.) Thrown ex-info (canonical builder). Emitted by re-frame.ui.compiler.root (build) / re-frame.ui.frames (runtime preflight) |
:align-frame-plan-config — keep the frame's config in one root site, or align the configs |
:frame-id, :fingerprints + :sites (build arm) / :installed + :arriving (runtime arm), :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 — the whole category is production-elided per Spec 000 §Contract C-000.35 (the boundary interceptor :rf.schema/at-boundary is the one production-reachable exception, and it rides the :where :event recovery unchanged, per 010 §Production builds).
: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
:cofxboundary. A recordable-coeffect value that violates itsreg-cofx:schemais NOT a:where :cofxcase 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.:cofxis therefore absent from this category's:whereenum.
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 the top-level field on the trace event itself (per the error event
;; shape above), not a :tags key — it appears on every trace event, error or
;; otherwise. The schemas below describe the :tags payload only.
(def HandlerExceptionTags
[:map
[:category [:= :rf.error/handler-exception]]
[:failing-id :keyword]
[:reason :string]
[:event [:vector :any]]
[:handler-id :keyword]
[:exception-message :string]])
(def SchemaValidationTags
[: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 SchemaValidationTags 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-handlerfalling 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-mismatchwarn-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-shapedrops the offending top-level effect-map key while the legal closed-set keys:db/:rf.db/runtime/:fxstill apply).:no-frame-context— the corrupted-context reader (:rf.error/frame-context-corrupted) emitted the diagnostic and returned nil rather than synthesising a:rf/defaultframe (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 production-reachable 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:
- Names the failing thing — the registered id, in backticks.
'Event handler:cart/add-itemthrew an exception.'not'A handler threw.' - Names the broken contract — what was expected.
'... expected to return an effects-map; got a vector.'not'... bad return value.' - 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.' - Stays under ~20 words. The structured
:tagspayload carries the detail;:reasonis the headline. - Is mechanically composable from the
:tagspayload. Implementations build:reasonfrom a category-specific template plus:tagssubstitutions; nothing in:reasonis 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 decisionsbelow.
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:
-
:rf.trace/dispatch-idunder: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-idupward across:rf.event/dispatchedevents (the inter-run lineage channel). -
:rf.trace/trigger-handlerat 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:
- 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-idacross rings; the framework does not maintain a process-global cross-frame index. - 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. - 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. - In-process
trace-bufferAPI. Per-frame, event bundles by default;:flat trueopt-in for callers that want raw trace events. Signature:(rf/trace-buffer frame-id)/(rf/trace-buffer frame-id opts). - 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-egressreads the per-frame classification registry — PATH-BASED, derived trees included (EP-0025).project-egressis 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 toelide-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/:largeeffect (: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-treerecord (rendered hiccup / DOM, a resolved:effective-argsmap, 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 (ruled1hms84=(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 byrf/elide-wire-value(per §Size elision in traces below and API.md §rf/elide-wire-value). The walker emits the:rf/redactedsentinel for sensitive values and the:rf.size/large-elidedmarker for large values; when both predicates match the sensitive drop wins (the size marker would leak:path/:bytesand 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-valueresolves its wire-egress frame from the carried stamp — the explicit:frameopt (override) wins, else the in-effect carried-invariant scope (with-frame/ frame-provider). There is no:rf/defaultfloor: 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/redactedrather than shipped verbatim under no policy (:rf.size/include-sensitive? trueis 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 inbuild-eventfrom handler-scope / schema overlap. But some classification-projection clauses decide sensitivity DURING projection — the projection runs afterbuild-event, sobuild-eventnever saw the signal. Examples: a sensitive machine's:rf.error/machine-action-exception:exception-data(Spec 015project-machine-error-tags) and a registration-classified (or frameless-fail-closed):rf.sub/runoutput (project-sub-tags— the sub's own:sensitiveregistration 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, mirroringbuild-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 bystrip-sensitivewith 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.
:sensitivepayload classification onreg-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 positionalredact-interceptor— EP-0015 §7.) - Trace-only redaction. The internal redaction interceptor writes the redacted event to framework trace/error emission slots. The regular
:eventcoeffect stays raw so handlers can perform the requested work. - Sentinel keyword. Redacted values are replaced with the framework-reserved
:rf/redactedsentinel. 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? trueevents 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? trueevents 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=falsebuild elidesemit!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.
:swallowmasks a bug;:replacementfabricates 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
:errorsstream ofregister-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-pdfslot sees:path [:user :uploaded-pdf]. The handle is copy-pasteable without rebasing.:bytes— REQUIRED. Thepr-strbyte 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:vectoris paginatable viaget-pathwith an index range; a:stringof 5MB is not.:reason— REQUIRED. The declaration provenance — the:sourceof 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:largeeffect — a handler returning{:large [[…]]}with its:dbwrite 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 imperativeadd-marksmark, and areg-app-schema{:large? true}slot prop are no longer routes into the elision registry for durable app-db classification.:hint— REQUIRED (may benil). 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/atkeyword without needing a reader hook. The path inside the handle is the same as the marker's:pathfield. Passing the handle to the existing re-frame2-pair-mcpget-pathtool fetches the literal elided value, subject to that tool's own cap check (a:rf.mcp/overflowis the failure mode if the literal is over-cap).:digest— OPTIONAL. Asha256:<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?tofalseand:rf.size/include-digests?tofalse. 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?tofalse(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 totrue; 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-largecount alongside the existing:dropped-sensitivecount (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? truetrace event with the value already redacted; no:rf.size/large-elidedmarker 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:assocslot. The patch itself stays small (path + marker). The:handlecarries:as-of-epoch <epoch-id>when the marker rides a past-epoch payload —get-pathresolves against the existing epoch-record's:db-aftersnapshot 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/summaryat the top (the slice shape) AND embed:rf.size/large-elidedmarkers 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-valuewalker 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'dwarning 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.