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 cascade 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 deliberately 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 — cascade bundles ((rf/trace-buffer frame-id), §Cascade 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 cascade (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 cascade a dispatch belongs to — "this trace event fired inside the cascade 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 cascade this event belongs to
:rf.trace/parent-dispatch-id <uuid-or-counter>} ;; on :rf.event/dispatched only — the cascade
;; that caused THIS dispatch
...}
Semantics:
:rf.trace/dispatch-idis per dequeued event — the cascade 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 six-domino cascade —: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-cascades, 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 cascade'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 cascade (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 cascade-from-cascade 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 cascade (their:rf.trace/dispatch-id) and the inter-cascade lineage hangs off the cascade root.- Top-level dispatch. An
:rf.event/dispatchedevent with no:rf.trace/parent-dispatch-idis a root of a cascade. Pair-shaped tools draw cascade trees by walking:rf.trace/parent-dispatch-idupward across:rf.event/dispatchedevents; the per-cascade body (every other trace event in that cascade) is the slice of the trace stream sharing the cascade's:rf.trace/dispatch-id. - The cascade-correlation primitive. Because the runtime emits event-at-a-time (no
:child-ofspan field),:rf.trace/dispatch-idis the only intra-cascade correlation channel and:rf.trace/parent-dispatch-idis the only inter-cascade correlation channel. The pair lets tools both (a) group raw spans by cascade and (b) walk lineage between cascades, without consulting the:rf/epoch-recordprojection. Tools that prefer structured per-cascade 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 cascade views: "show me every fx that ran in this cascade" 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 (previously :ui); 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)¶
Per Mike-approved Option A (2026-05-28) the framework carries one closed-enum axis classifying every dispatch's trigger kind / functional origin: :source on the dispatch envelope. A prior :rf/dispatch-origin axis (10-value :user / :router / :websocket / :http / :ssr / :fx-emit / :timer / :test-harness / :tool / :internal) ran parallel to :source; the audit found every :rf/dispatch-origin value either co-occurred with a finer :source stamp (e.g. :rf/dispatch-origin :timer ↔ :source :after-timer, :internal ↔ :machine-spawn, :ssr ↔ :ssr-hydration, :fx-emit ↔ :fx-dispatch / :fx-dispatch-later / :machine-action, :test-harness ↔ :test) or named an origin slot :source lacked (:router, :tool, :websocket). The collapse extends :source with the three new values and drops :rf/dispatch-origin entirely.
Substrate-internal stamp sites (canonical inventory):
:source value |
Stamped by | When |
|---|---|---|
:ui |
UI handler call-site | a user button / input handler dispatches |
:frame-init |
reg-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 (re-frame.test_support/dispatch-sequence) |
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. Previously :ui — changed so 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 fn form ((rf/dispatch* event)) — 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 ...]] cascades — 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; renamed and flattened from EP-0010's :rf.world/inputs) — 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. EP-0017 changes what that lens shows: it 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 EP-0015 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 EP-0017 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 / :rf.error/world-inputs-renamed → :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 drain loop dropped queued events on a mid-cycle destroy. |
: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 spawn-and-join lifecycle. |
: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 / :rf.route.nav-token/stale-suppressed |
:rf.route.nav-token |
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/cascade) — 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 (Mike ruling #6, EP-0001). 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 / cascade 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 a handler / snapshot is reaped.: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). Per (Option A revised), 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; was:spawn-id) — 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),: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(the surrounding parent actor or frame was destroyed and the runtime walked surviving children). Existing observers that filter on:tagssee the new key additively — no breaking change. Per rf2-sfunt8 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)::work/id(the canonical machine work-id[:rf.work/machine <actor-id> <invoke-id> <generation>]),: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.: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).:cancelled( — one unified event id replacing the:cancelled-on-resolution) fires on every cancellation path;:reasondiscriminates the closed set:on-exit / :on-destroy / :on-resolution / :on-supersede / :on-frame-destroy. Per rf2-sfunt8 the:cancelledrow additionally carries the reply-envelope cancellation facts (cancellation as DATA — Managed-Effects §Cancellation)::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),:work/kind :timer,:rf.reply/status :cancelled,:rf.reply/work-status :cancelled,:rf.reply/cancelled? true, and:rf.reply/cancel-reason(the closed:reasonset). Per rf2-hawtjr the:firedand:stale-afterrows additionally carry the firing dispatch's causal completion timestamp under:completed-at/:rf.reply/completed-atwhen the synthetic:after-elapsed dispatch supplied a:rf/time-mstoken. 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 :any/{:n N}/{:fn ...}resolves 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-id(was:machine-id) and the declarative invocation path under:invoke-id(was:spawn-id). Per rf2-d63qtp the resolution traces additionally carry the DECISIVE child completion's reply-envelope facts (the child completion that drove the resolution — Managed-Effects §Status taxonomy)::work/id(the child's canonical machine work-id[:rf.work/machine <spawned-id> <invoke-id> <generation>]),: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: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 surviving sibling completing after the join already resolved under:cancel-on-decision? false) carries the canonical:status :stale/:work/status :suppressedreply facts (:stale/reason :rf.machine.spawn-all/join-resolved). Per rf2-obczvv (XState v5 alignment) it additionally carries:folded? <bool>—truewhen the late result was folded into the:done/:failedrecord (cancel-off, the surviving sibling ran to completion),falsewhen it was dropped as a frozen-record straggler (cancel-on-decision, no live join to fold into); the:resolved?latch stays true and no resolution / cancellation re-fires on either path.:rf.machine.spawn/cancelled-on-join-resolution— fires once per sibling cancelled by:cancel-on-decision? true(the default) when a:spawn-alljoin condition resolves and surviving siblings 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 | :on-timeout>}. 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-failed/:rf.machine.spawn/timed-outinstead. Per rf2-sfunt8 the trace additionally carries the reply-envelope cancellation facts (cancellation as DATA — Managed-Effects §Cancellation)::work/id(the survivor's canonical machine work-id[:rf.work/machine <spawned-id> <invoke-id> <generation>]),: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/cascade 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-cascade) the dispatching cascade'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 cascade (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 cascade buffer via the:epoch/cascade-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 cascade. 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. (EP-0025 removed input→output propagation — a sub no longer inherits 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 cascade 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 cascade's drain. Eviction fires that land OUTSIDE a cascade (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 cascade?". 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 (EP-0017 slice A.3 — 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. (Pre-EP-0017 these meanings were conflated: the run op stamped the arg under:rf.cofx/value, so the marks redaction — which targets:rf.cofx/value— never acted on the produced value and the produced value was never stamped. The two were split: produced value under:rf.cofx/value, arg under:rf.cofx/arg.):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 (EP-0017 slice B.7) 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 cascade-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(cascade-wide, over-reports — lists every sub that ran in the cascade 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 cascade (intersection resolved at emit time against the in-flight cascade 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 cascade. Narrower than:rf.view/deref-subs(full read-set, changed-or-not) and:rf.view/cause-subs(cascade-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-cascade) the dispatching cascade's :rf.event/run-startevent-id. Absent outside a cascade.:rf.view/cause-subs[query-id …](when in-cascade) distinct sub-ids that ran in the cascade, first-seen order, capped at 100. Absent outside a cascade. 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 cascade (intersect:rf.view/deref-subswith the cascade'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 or is planned; the structural parent is deliberately never named,).:rf.view/deref-subsis what makes the reason PER-VIEW rather than cascade-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 cascade with a one-shot:rf.view/rendered-cap-reachedmarker (:tags {:frame <id> :rf.view/dropped-after 100}) to bound the per-cascade 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 — frame-exit emits:parent-frame-destroyed; the fx-substrate's:rf.machine/destroyedemit site (lifecycle_fx.cljc) carries the other reasons under the same:reasonslot (:rf.machine/finishedfor natural termination,:explicitfor[:rf.machine/destroy <id>],:parent-unmount-cascadefor parent-cascade teardown). 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 (canonical values used by the runtime)::reasonEmitted by Meaning :parent-frame-destroyedframe.cljc(destroy-frame!)The actor's owning frame was destroyed; its snapshot was reaped as part of the frame-exit cascade. :rf.machine/finishedlifecycle_fx/finalize.cljcThe actor reached a :final?state and the runtime auto-destroyed it after firing the parent's:on-done.:explicitlifecycle_fx/destroy.cljcThe actor was destroyed by an explicit [:rf.machine/destroy <id>]fx.:parent-unmount-cascadelifecycle_fx/destroy.cljcThe actor was a spawned child whose parent state exited (per 005 §Cancellation cascade). The enum is open per §
:tagsis the open-ended bag; future causes are additive.Two-channel teardown — what each channel sees. The runtime emits machine-destroy traces on two parallel channels with deliberately distinct purposes; the channel name carries the source-of-emit, the
:reasonslot carries the cause. Per audit Finding 5:Channel Source-of-emit What it observes Typical consumer :rf.machine.lifecycle/destroyedframe.cljc(and re-emitted on the unified lifecycle channel for:reason :parent-frame-destroyed)The registrar-substrate observation: the actor handler / snapshot disappeared from the registrar. One event per destroyed instance, including frame-exit reaping. "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 the drain loop detects(:destroyed? (:lifecycle frame))mid-cycle and drops remaining queued events.: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-app-db!/reset-app-db!/replace-runtime-db!/replace-frame-state!— 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 cascade 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-cascade) 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."
Why the split was kept (not renamed to :rf.machine.fx/* + :rf.machine.registrar/*). Considered and rejected on churn grounds — the existing names already 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), and the cost of churning every tool / fixture / docstring outweighed the marginal naming-axis clarity. 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/elapsed-ms |
event | The prior :rf/dispatch-origin axis was collapsed into :source — the closed-enum functional-origin discriminator now rides on the bare :source tag (still a bare carve-out under the existing 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 (rf2-1xdotm) 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.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.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 (EP-0017 slice B.7) 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 — cascade 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). (Historical drift in v2 development used both top-level and tag placement, plus the :frame-id alias; both are retired — one shape, one reader.) :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 cascade 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 (Mike ruling #6): 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 ;; cascade-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
;; cascade) 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 cascade 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. This is the inversion from the prior design, where :rf.event/db-changed fired before flows (flows then mutated the already-installed db). Now :rf.event/db-changed reflects the flow-augmented db — the value installed already carries every flow's output. A consumer placing flows on the cascade 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 cascade-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 cascade. (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 cascade duration. Although the trace fires at cascade 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-cascade 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 cascade-level :rf.error/flow-eval-exception, and the cascade 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 ;; cascade-level error (always-on substrate)
:rf.event/run-end ;; cascade-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 cascade-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.)
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 (EP-0017) |
:rf.cofx/generated |
dev-only | a recordable generator ran at processing-start to fill a declared-absent fact (EP-0017 slice B.7); 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 (Mike ruling #6); 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/* (cascade-level) |
always-on | every cascade 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 | cascade-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 (rf2-iqh5yf): a bare Throwable is not serializable and bypassed the central trace-projection chokepoint, so the raw object reached trace delivery / epoch capture / tooling listeners unprojected. The shape now 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, rf2-p44r3u) |
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 cascade-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.
(rf/clear-listeners! :trace)
;; Test-time helper: drops all registered listeners on the stream atomically.
;; Returns nil. Used by `re-frame.test-support/make-reset-runtime-fixture` to
;; restore a clean listener registry between tests; 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 cascade per event:
(rf/register-listener! :epoch
:my-app/cascade-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 dedicated register-epoch-listener! / unregister-epoch-listener! facade fns remain available as the Tool-Pair-named alias for this stream (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 cascade'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 six-domino cascade (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 cascades. 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 cascade-so-far, and :halt-reason carries a structured descriptor of why the drain halted. This is the devtools surface for failing cascades — 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 cascade" — 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.
Cascade projection (group-cascades / domino-bucket)¶
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 six-domino slice of the stream — one record per cascade with the event vector, handler emit, fx-map emit, effects, sub-runs, and renders already split into named slots. The framework ships that projection as a pure-data function in re-frame.trace.projection, re-exported from re-frame.core:
(rf/group-cascades 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 cascade 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 cascade's host frame, per
;; ;; 002-Frames §Routing the dispatch
;; ;; envelope; nil for an :ungrouped
;; ;; slot or a cascade 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 cascade-bundle shape (rf/trace-buffer frame-id) returns by default, since the ring pre-computes this projection per-cascade. (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 cascade-record 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 cascade so consumers render cascades 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-cascades.
Per (per-event correlation) the projection is robust against errors, fx, sub-runs, and renders that fire inside an event's cascade even though they aren't :rf.event/dispatched — every such event carries :tags :rf.trace/dispatch-id so they group into that event's cascade record 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 (cascade-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 cascade (one dispatched event = one cascade = 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 cascade-keyed eviction.
Architectural shift — cascade-keyed, per-frame¶
The design was a single process-global ring sized by raw trace-event count (default 200 events). That design had two structural problems:
- 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 — observed under and (the motivating bug).
- Eviction unit mismatch. Trace volume per cascade is wildly uneven — a single click-cascade 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 event count means one chatty cascade can evict every earlier cascade'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-cascade is running (see §Emit-site routing below). Each frame's ring is sized independently via frame metadata. Frames are isolated by design (per 002 §Per-frame and trace surface); the trace surface now matches.
- Cascade-keyed eviction. The ring slot is the cascade. One
:rf.trace/dispatch-idconsumes one slot regardless of how many trace events that cascade emitted. When cascade #N+1 arrives, the oldest cascade (and every trace event ever emitted under it) is dropped as a unit. A cascade with 5 traces and a cascade with 50,000 traces each occupy one slot.
Retention contract — the single knob :rf.trace/cascades-retained¶
| API | Default | Notes |
|---|---|---|
:rf.trace/cascades-retained (frame metadata) |
50 | Per-frame override. Sets the number of cascade slots retained in this frame's ring. 0 disables the ring (synchronous delivery still works). |
(rf/configure! {:trace-buffer {:cascades-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-cascade trace cap, no per-trace-type cap (no :skip-specific budget, no :sub-specific budget), no per-frame override beyond the cascade count, no other knobs. Operator-facing tuning is one number: how many cascades 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 cascade the operator cares about. Only newer cascades push older cascades out.
- Naturally bounds skip noise. Sub-skip emits stay associated with their parent cascade; the burst lives or dies as a unit with the rest of that cascade's traces.
- Aligns with epoch-history.
epoch-historyis already retained per-cascade (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 cascade-driven; trace volume per cascade is incidental. A cascade with 50K traces is a slot like any other.
There is no worst-case memory bound on a single cascade's trace volume by design. If a cascade emits 100K traces, that cascade's slot holds 100K traces until evicted. The operator's tradeoff is in the cascade-count knob, not in trace-volume bounding. (Apps whose cascade-volume budget genuinely matters tune the per-frame cascade-count downward; this is the same control surface the global ring exposed, projected onto the new unit.)
Emit-site routing (per-frame)¶
Trace events that ride inside an in-flight cascade 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 cascade), 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 cascades — merge by :dispatch-id¶
Frames are isolation boundaries, not communicating agents — re-frame2 does not support one frame dispatching directly into another; the cascade of a dispatched event lives in exactly one frame. But several distinct cascades 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 cascades from a multi-frame story session).
The contract: each frame retains the traces of cascades 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 cascade — 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 cascades. - 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¶
The original (now-overturned) :rf/global ring proposal was rejected during the design phase because hot-reload at scale creates a memory leak: every file save re-fires the entire ns-load worth of reg-* traces, the ring grows without bound, and the operator never benefits from the noise (the registrations haven't changed). The B3 ruling alone (no ring) closes the memory leak, but it leaves the live stream itself flooded with reload-noise that no consumer wants.
B4 ruling: 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 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. Per (B4 ruling, 2026-05-25).
Together (B3 + B4): rings hold cascades 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 cascade" 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, cascade bundles by default¶
The query surface is per-frame, with a frame-id required argument. The default return shape is cascade bundles (one map per cascade with the cascade's :dispatch-id, its trace events, and the structured projection slots); the :flat opt-in returns raw trace events for callers that want the pre-cascade shape.
| API | Signature | Notes |
|---|---|---|
(rf/trace-buffer frame-id) |
(frame-id) → vector |
Returns the frame's cascade-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-cascades-shape projection per §Cascade projection, pre-computed per-cascade by the ring. Empty when no cascades 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 cascade-level fields; absent key = no constraint. The :flat true opt returns raw trace events instead of cascade 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 {:cascades-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-cascade 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 (cascade bundles) matches the storage unit; tools whose existing code shapes around the raw stream pass {:flat true} and re-fold via group-cascades themselves. (The :flat form is not a "polyfill of the old surface" — the storage is genuinely cascade-keyed, so even :flat reads the ring's per-cascade slots and flattens them; it is not reading a pre-cascade 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 cascade bundles. The other filter keys apply to events when :flat true, otherwise to cascades. |
: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 cascade-bundle reads, matches cascades 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 cascade-bundle reads, matches cascades whose root :rf.event/dispatched carries this origin. |
:dispatch-id |
number | Match the cascade's :dispatch-id (cascade-bundle reads) or :tags :rf.trace/dispatch-id (:flat). |
:since-ms |
number | Keep cascades / events whose :time (host-clock ms) is strictly greater than this. |
:between |
[t0 t1] |
Two-element vector — keep cascades / events whose :time falls in [t0, t1] inclusive. |
:pred |
(fn [ev-or-cascade] → truthy) |
Arbitrary predicate. Receives the cascade 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 cascade-bundle reads, cascade-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 — cascade-keyed. When the ring is full at
:rf.trace/cascades-retainedslots, the oldest cascade slot (and every trace event ever emitted under its:dispatch-id) is evicted as a unit as the new cascade arrives. No allocation churn beyond the slot count. - Per-frame isolation. Each frame's ring is independent — a burst of
:rf.sub/skipcascades 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-cascade). 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 cascades 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. - Cascades-retained-zero semantics. When configured with
{:cascades-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 cascades-retained on a populated ring. Applied while the ring holds more than
Ncascades, drops the oldest cascades first to fit (same eviction order as the ring discipline). Raising it keeps existing cascades 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 cascade-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 cascade.
- "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 cascade 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 cascades (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 (cascade-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 (Xray carried one as trace-bus/self-emitted? between and). Promoting the gate to the framework lets any consumer mark a handler internal-only and trust the runtime to suppress the cascade.
Semantics:
- What's suppressed.
:rf.event/dispatched(queue-time, when the target handler's meta carries the flag), the cascade 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.
- Cascade 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 cascade 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 (observed under : the ring held 200/200 :rf/xray events and zero app events, so any other raw-buffer consumer saw only inspector noise). 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;reg-framereads 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(EP-0018 — 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 cascade 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 EP-0017 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;:rf.error/world-inputs-renamedis emitted elsewhere — the dispatch-boundary guard — not fromcofx.cljc),: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 cascade 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(EP-0002 — emitted when a frame-scoped op carries no frame stamp and runs under no scope; replaces the retired:rf.warning/dispatch-from-async-callback-fell-through-to-defaultwarning),: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 EP-0001 two-partition migration boundary, hard-error in final form with a temporary:rf.warning/legacy-runtime-rootduring the campaign; supersedes the retired:rf.warning/runtime-state-droppedclobber diagnostic, since 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 drain loop detects a destroyed frame mid-cycle; 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 — EP-0008),: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:cascades-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/cascades-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/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; rf2-ywv74m), plus the machine-error categories.routing.cljc—:rf.route/fragment-changed(fragment-only navigation; — renamed from:rf.route/fragment-changed),:rf.route/registered/:rf.route/cleared/:rf.route/activated/:rf.route/deactivated(lifecycle pair),:rf.route/navigation-blocked,: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.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 EP-0021 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 (rf2-3tysyj): 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); rf2-bi8vg1),: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— EP-0020) +: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 — the never-emitted:rf.resource/work-suppressedof an earlier draft is folded into it),: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!; the injection-timevalidate-cofx!was retired per rf2-nkf4l3 — 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 rollback; 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 post-commit validation guard emits this category — with:rollback? false— if a wholesale post-commit-validator throw still reaches its catch, so a swallowed throw is never 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 used to read as the metadata-map and the chain was silently dropped — the loud rejection is the realisation of Conventions §No silent swallow); and the EP-0018 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(EP-0022 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/reg-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-app-db!/reset-app-db!/replace-runtime-db!/replace-frame-state!) success, plus the six restore-failure categories and the three injection failure categories (: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 cascade attribution (:rf.view/id,:frame,:rf.view/render-key, plus — when an in-flight cascade 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 cascade-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 cascade with a one-shot:rf.view/rendered-cap-reachedmarker (carries:frame+:rf.view/dropped-after) to bound the per-cascade 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-cascade 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(rf2-9kpigo — a publicframe-providerwhose:frameis non-nil but not a keyword; rides the always-on error-emit axis (EP-0008) 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 EP-0024frame-providername-family additionally validates its mount opts at the provider entry points (diagnostic-channel, thrown ex-info): the UI-ownedrf/frame-providerrejects a missing:idwith:rf.error/owned-frame-provider-missing-id, and the scope-onlyrf/frame-provider-existingrejects any lifecycle / construction opt with:rf.error/frame-provider-existing-lifecycle-opt.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 (EP-0008) 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 cascade-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 {:cascades-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 surfaces (replace-app-db!/reset-app-db!/replace-runtime-db!/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 {:cascades-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 {:cascades-retained N}})) — pulling "the last N cascades 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 by design (both for performance and for security — dev-side enrichments would otherwise leak to production consumers).
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(post-commit:dbschema validation rejected the new state and the container was restored to its pre-handler value per 010 §Per-step recovery row 4 — flows and:fxwere skipped), 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 rollback would also apply: it is the proximate, most-actionable signal, and a chain throw short-circuits the:dbcommit so there is no post-commit state 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, 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/no-such-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 (the last two promoted per EP-0008). (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). For the COMPONENT-ATTRIBUTED categories whose failing component is DISTINCT from the dispatched event — :rf.error/interceptor-exception (the failing interceptor id) and :rf.error/coeffect-exception (the failing cofx supplier id) — the record ALSO carries :failing-id <kw> and :reason <string> (rf2-n4x74b). 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 failed (the classified component id otherwise rides only the dev-trace tags, which DCE under goog.DEBUG=false). The two slots are present ONLY when the failing id is distinct from :event-id — 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. 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 (the per-frame :on-error recovery policy was removed). 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 deliberately. 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 EP-0008 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 reg-frame, 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/reg-frame :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.mark/performance.measurebrackets (§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 + cascades-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 cascade retention via (rf/configure! {:trace-buffer {:cascades-retained N}}) (or per-frame :rf.trace/cascades-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 by design, 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 cascade 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-cascade 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-cascade slice (:db-before, :db-after, :trace-events); the recorder appends one record per cascade 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-cascade 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 {:cascades-retained N}}) |
Per-frame cascade-keyed ring for register-listener! late-attach (N=0 opts out of the ring entirely; default 50; per-frame override via :rf.trace/cascades-retained metadata on reg-frame) |
#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.mark / performance.measure bracket sites (:event, :sub, :fx, :render) |
#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 (post-EP-0010 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 |
Browser-native PerformanceMeasure entries with name / startTime / duration / detail |
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.mark / performance.measure, 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 flag¶
A consumer flips it 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.
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):
performance.mark(<name>:start)
try <body>
finally
performance.mark(<name>:end)
performance.measure(<name>, <name>:start, <name>:end)
The try/finally ensures a partial measure entry still lands when the body throws — observability does not become silent on the unhappy path. The thrown exception still propagates after the :end mark fires.
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¶
// All re-frame entries from the most recent run.
performance.getEntriesByType('measure')
.filter(e => e.name.startsWith('rf:'));
// Live: a PerformanceObserver fires per emitted entry.
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 });
Chrome DevTools' Performance panel renders the measures as named tracks alongside React renders, network, and paint — no custom UI required.
The User Timing entry buffer is bounded by the host (Chrome's default is 10000 entries); long-running pages that want every entry should attach a PerformanceObserver and offload to durable storage rather than rely on the buffer.
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.mark,performance.measure, or any"rf:entry-name fragment. - On bundle MUST contain all three.
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.
The browser smoke at tools/xray/testbeds/perf_counter/spec.cjs complements the grep: it serves the perf-on bundle, drives a real dispatch through the +/- buttons, and reads performance.getEntriesByType('measure') to confirm at least one entry per bucket lands. A passing grep is necessary but not sufficient; the smoke proves the four call sites actually fire under a real cascade.
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/snapshot-of/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-cascade 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 cascade. :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 was originally carried by five sibling dynamic Vars (*current-trigger-handler*, *current-call-site*, *current-dispatch-id*, *current-sensitive?*, *current-no-emit?*) bound side-by-side at every handler-scope site. The five-Var arrangement landed across (trigger-handler / error path), (trigger-handler / success path), (call-site), (dispatch-id), (sensitive?), and (no-emit?). Per they were consolidated into one HandlerScope record bound to one Var: one binding-frame allocation per scope instead of five, one Var to mock in tests, one record-field edit when a sixth 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 cascade 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 by design, 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). Deliberately 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 by the EP-0008 audit.
- Frame-teardown failures (the EP-0008 C4 fix). 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 cascade 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 (a refinement, not a reversal). Earlier the message string was the stringified discriminator keyword — machine-branchable from the message alone, but not human-actionable. The contract now separates the two channels while keeping the property the old shape bought:
(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.
Discriminator-slot migration — five variants collapse to one¶
Pre-canonicalisation, thrown-error ex-data carried the discriminator under five different slots depending on the surface:
| Old slot | Old shape | Where it lived |
|---|---|---|
:error |
{:error :rf.error/flow-cycle …} |
flows registry / topo (the dominant variant) |
:type |
{:type :rf.error/… …} |
reagent-slim React-19-removed family |
:kind |
{:kind :rf.error/… …} |
http util_json |
:reason (overloaded) |
the kw doubling as the human reason | machines-viz scxml, ai-generate |
| (none) | kw only in the message string, no ex-data slot | most other sites (require-fn!, require-adapter!, …) |
All five collapse to the single canonical :rf.error/id slot. The migration is a rename, not a deprecation (pre-alpha posture — no back-compat shim): the old slot is removed and :rf.error/id added in its place. Where the kw lived only in the message string (the (none) row), :rf.error/id is the structured slot and the message becomes the human sentence + the [:rf.error/<id>] token (per the human-message policy above — the bare-keyword message is dropped, not preserved). Where :reason was overloaded to carry the discriminator kw, :reason reverts to a human-readable sentence and :rf.error/id carries the keyword.
The three reference exemplars — re-frame.flows.registry/flow-error (was :error), re-frame.late-bind/require-fn! (was none), re-frame.substrate.adapter/require-adapter! (was none) — all route through re-frame.error/throw-error! / thrown-ex-info and demonstrate the canonical shape on first read.
: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. Originally introduced for :rf.error/* only; widened so consumer tools can render jump-to-source links from any trace event in a cascade, not just errors. The error-path emit shape is unchanged — 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; their *-suffix fn counterparts (per Conventions §*-suffix naming) do not stamp:
| Surface | Macro (stamps) | Fn-form (no stamp) |
|---|---|---|
| Dispatch (queued) | dispatch |
dispatch* |
| Dispatch (sync) | dispatch-sync |
dispatch-sync* |
| Subscribe | subscribe |
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 EP-0017 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 per EP-0017, 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) |
Yes |
Fn form (dispatch*, subscribe*, dispatch-sync*) |
No |
Higher-order use ((map dispatch* xs)) — fn form required |
No |
View-render injected dispatch / subscribe locals (per reg-view) |
Yes (view-level) — the macro injects the reg-view definition-site coord into the frame-handle (below) |
frame-handle ops built with :dispatch-opts / :subscribe-call-site (the reg-view injection path) |
Yes (view-level) — the op merges the supplied coord into its dispatch* / subscribe call |
Bare captured frame-handle ops ((:dispatch (rf/frame-handle)) / (:subscribe (rf/frame-handle)), no coord) |
No — the returned op delegates through 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 frame-handle, which shadow the coord-capturing macros for the whole body. A view's on-click #(dispatch [...]) therefore reaches dispatch* 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-frame-handle (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 deliberately view-level: "go to code" lands on the reg-view definition, not the exact #(dispatch …) line. (Per-call exact coords were considered and deferred; they would land on the same :rf.trace/call-site field, so they can be added later with no change to consuming tools.) 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 *-fn call (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 (EP-0018 unified the former per-form :rf/db-handler / :rf/fx-handler / :rf/ctx-handler wrappers to this one id) — 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 — five prefix shapes¶
Error categories use five 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 EP-0008 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 (EP-0008) 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 union of two earlier tables (the categories table and the default-recovery table) plus the categories that were previously declared inline elsewhere in this Spec. 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.
INTENTIONALLY-OUT-OF-CATALOGUE. Two emitted :rf.* categories are deliberately NOT error-event-catalogue rows; the co-edit invariant excludes them by design, not by oversight. (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 — superseded by the always-on :rf.error/no-frame-context per EP-0002). It was once a catalogue/source DRIFT: re-frame.views.warn_once still carried a dev-gated emit site as a structural no-op (its firing case was unreachable — the :rf.error/no-frame-context throw happens first). That dead emit site has since been DELETED (PR #4029): the source no longer emits the category and the conformance scan's out-of-catalogue-allow-list is now EMPTY. The category stays out of the live catalogue (a live row would contradict the EP-0002 retirement); source now matches the catalogue's RETIRED ruling.
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/no-such-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, EP-0008), the teardown-discrimination category :rf.error/on-destroy-handler-exception (the dedicated :on-destroy-throw signal, EP-0008), 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 fail-closed spawn reject :rf.error/machine-spawn-unregistered-type (rf2-ywv74m) — 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 EP-0008 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 — rf2-nv3mua; 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 — were considered and 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 EP-0015 §S8 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 (the per-frame :on-error recovery policy was removed — 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 (EP-0017) 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 / reg-frame metadata-map :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 (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 |
: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), :action-id, :state-path, :transition, :rf.event/v, :exception-message, :exception-data |
:rf.error/fx-handler-exception |
:error |
always-on | A registered fx threw during effect resolution | :no-recovery — the fx is skipped; cascade continues if other fx independent |
:rf.fx/id, :rf.fx/args, :exception-message |
: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 |
:replaced-with-default — the unresolved input is substituted with nil; the sub's body still runs |
:rf.sub/id, :unresolved-input, :resolved-inputs |
: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 |
:no-recovery — hard-fail to surface bugs early. Production builds elide the validation entirely (per Spec 000 §Contract C-000.35), so this row applies only in dev. The :machine-output boundary is best-effort (the machine already finished — :rollback? false), per 005 §Completion-output validation |
:where (:event/:sub-return/:app-db/:fx-args/:cofx/: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 post-commit validation. validate-app-schema! isolates the throw PER-ENTRY: it surfaces this distinct category, fails CLOSED (rollback — does NOT install the unvalidated commit), 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 rollback, AND disabling post-commit 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 (with :rollback? false) if a wholesale validator-machinery throw still reaches it, so a swallowed throw is never invisible. Dev-only (the validation body is interop/debug-enabled?-gated). The :machine-output boundary (EP-0029 A8) 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 commit is rolled back (the per-entry 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. Renamed from :rf.spec/violation; 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 |
diagnostic | The run-to-completion drain hit its depth limit | :no-recovery — always indicates a bug; halt the cascade |
:depth, :queue-size, :last-event |
: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 replace-app-db! / reset-app-db! / replace-runtime-db! / 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 (EP-0002; 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 not a keyword (a string {:frame "app"}, a number, a collection, …). Frame ids are keywords ("keyword in", per 002 §Frame identity), so a non-keyword :frame 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 (rf2-9kpigo). 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 keyword frame id (e.g. :todo) |
:received (the offending value), :where (the validating provider call site), :recovery (:supply-keyword-frame), :reason |
:rf.error/owned-frame-provider-missing-id |
:error |
diagnostic | The UI-owned rf/frame-provider (EP-0024 — the create-on-mount / provide-id / destroy-on-unmount lifecycle boundary) was mounted with no :id. The owned provider 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 provider preserves durable state across hot reload, and an anonymous owned provider could neither be re-mounted idempotently nor addressed by descendants. The provider fails fast at mount rather than minting an un-addressable anonymous frame. (Distinct from :rf.error/frame-provider-existing-lifecycle-opt, which fires on the SCOPE-only frame-provider-existing when it is handed a lifecycle opt; and from :rf.error/bad-frame-provider-arg, the non-keyword-:frame boundary on the scope provider.) Mount-time validation, thrown ex-info, not a trace; diagnostic-channel. Per 002 §The frame-provider name family and API §Registration |
:supply-frame-id — the provider fails fast; supply :id (the owned provider's required key), or switch to rf/frame-provider-existing to SCOPE an already-created frame |
:where (the validating provider call site), :recovery (:supply-frame-id), :reason |
:rf.error/frame-provider-existing-lifecycle-opt |
:error |
diagnostic | The SCOPE-only rf/frame-provider-existing (EP-0024 — provides an ALREADY-CREATED frame id through React context; creates / refreshes / destroys nothing) was handed a frame-construction / lifecycle opt (:id, :images, :initial-events, or any other make-frame record-config / image-selection key). The scope-only provider takes :frame ONLY; a lifecycle opt means the caller wanted create-semantics from a component that neither creates nor owns a frame. Rather than silently no-op the lifecycle opt, the provider fails loud and points at the owned rf/frame-provider. Mount-time validation, thrown ex-info, not a trace; diagnostic-channel. Per 002 §What frame-provider-existing is and API §Registration |
:use-frame-provider-for-lifecycle — the provider fails fast; drop the lifecycle opt (pass :frame only), or switch to the UI-owned rf/frame-provider if you want create-on-mount / destroy-on-unmount |
:offending-keys (the rejected lifecycle/construction key set), :where (the validating provider call site), :recovery (:use-frame-provider-for-lifecycle), :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 EP-0025 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, EP-0012). 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) |
n/a — success-path trace, not an error/warning | :rf.fx/id, :rf.fx/args, :frame |
: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.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 (rf2-nv3mua): (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 (EP-0008): 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 EP-0002 resolution-6 :rf.error/no-frame-context precedent that established frameless always-on emission. 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 (EP-0002). A plain (non-reg-view) Reagent fn cannot read the surrounding frame-provider's frame; under the carried-frame invariant it no longer falls 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 (EP-0002). 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 cascades 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 cascade 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.error/legacy-runtime-root |
:error |
diagnostic | A stray legacy :rf/runtime root was found at the top of app-db — either written by user code or carried over from v1-shaped code (EP-0001, Mike ruling #8). 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 in the final form — per Conventions §The legacy :rf/runtime root. The whole :rf.warning/runtime-state-dropped containment diagnostic of the pre-partition design (which fired after a {:db fresh-map} return clobbered the co-located runtime root) is retired: the partition makes that 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 (EP-0001). Never shipped. A campaign-temporary migration-warning counterpart to :rf.error/legacy-runtime-root was contemplated to let the in-repo migration proceed incrementally, but the hard error landed in its final form in EP-0001 bead 9 with no temporary warning retained — the stable vocabulary is the final vocabulary, no long-lived alias (Mike ruling #8). 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/invalid-resource-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/state …] 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 EP-0002 kills). 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. EP-0008 — classified DIAGNOSTIC (NOT promoted): 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 — unlike head-resolution/teardown where nothing local sees it), so promotion-criterion leg 2 (a leak/breach the off-box monitor cannot see) fails; an operational resource timeout is data-plane telemetry, not a framework-contract breach. Named home (not unowned): the resources trace family + the EP-0015 §S8 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. EP-0008 — classified DIAGNOSTIC (NOT promoted): 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), so promotion-criterion leg 2 (a leak the off-box monitor cannot see) fails; it is data-plane telemetry, not a framework-contract breach. Named home (not unowned): the resources trace family + the EP-0015 §S8 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 (EP-0021) 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 (EP-0016 issue-7 tripwire) |
: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 (EP-0021 / rf2-bi8vg1): 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 rf2-d095i1 characterized. 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/invalid-mutation-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.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 cascade) 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 — rode only the DCE'd dev trace before EP-0008. Promoted onto 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; EP-0008 Open Issue 1, ruled). 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. (EP-0008 R2: per-hook visibility is kept here 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 (rf2-is8ov5). 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 (rf2-re5a98). 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 (rf2-u5kmf8). 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 :cascades-retained — the retired {:depth N} shape, a negative value, or a non-numeric value. :cascades-retained N (non-negative integer) is the SOLE recognised opt (per §Retention contract — the single knob :rf.trace/cascades-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 cascades-retained is unchanged. The :reason names the fix ({:cascades-retained N}) |
:opts (the rejected opts map), :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 (EP-0022; 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, the old runtime could read this as metadata and silently drop the chain; the registrar now 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 EP-0023 image standard registry). Today the reserved set is #{:rf/set-db} (EP-0027 §: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 (EP-0027 §:rf/set-db) 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 (rf2-izy3b2). 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 in EP-0018 (no alias, EP-0007 rule 2). 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 in EP-0018 (no alias, EP-0007 rule 2). 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 in EP-0018 (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 (EP-0022, 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 (EP-0022, 002 §Validation and resolution timing). A live reg-event / reg-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 (EP-0022, 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 (EP-0022 flip): 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) (EP-0022, 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). Value-valued overrides are retired since the EP-0022 reference-only flip: an inline interceptor value as a replacement is rejected here (EP-0022, 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 (EP-0022, 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! / clear-listeners! 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! / 'rf/clear-listeners!), :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 for the same (kind, id) (different impls / source coordinates) with NO declared replacement winner (EP-0023 §Image Composition / §Image Validation). The central "order never silently decides a winner" guarantee: a genuine collision is an ERROR, not a last-write. The fix is a :replace winner naming the exact survivor, a narrower :include-ns selector, or a renamed id. 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 EP-0023 §Image Composition |
:declare-replace-winner-or-disambiguate — the call throws; declare a :replace winner, narrow the selector, or rename the duplicate id |
:image, :kind, :id, :colliding-coordinates (the source coordinates of every colliding descriptor), :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 / EP-0016 D3) naming a :resource-scope resolver that is NOT selected into the generation — "resource references missing scope resolver" (rf2-32siq3.25 — the resource→scope-resolver leg, previously deferred, is now landed here; 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-replacement-winner-unresolved |
:error |
diagnostic | A declared :replace / :replace-standard winner source coordinate identified ZERO selected descriptors (a stale winner source, a typo, a no-longer-selected namespace) or MORE THAN ONE (an ambiguous coordinate) for its (kind, id) (EP-0023 §Image Patching And Overrides — "the winner source coordinate must identify exactly one selected descriptor"). A replacement declaration must name exactly one survivor. Assembly-time validation (rf/make-frame), thrown ex-info, not a trace; diagnostic-channel. Emitted by image_assembly.cljc's resolve-replacement-winner. Per EP-0023 §Image Patching And Overrides |
:fix-the-replacement-winner-source / :make-the-winner-coordinate-unambiguous — the call throws; correct the winner source coordinate so it names exactly one selected descriptor |
:image, :kind, :id, :winner (the declared winner coordinate), plus :selected-coordinates (zero-match) or :match-count (ambiguous), :recovery |
:rf.error/image-replacement-no-collision |
:error |
diagnostic | A declared :replace / :replace-standard key (kind, id) named NO actual collision — the selected set holds ZERO or exactly ONE descriptor for that (kind, id), so there is nothing to replace (EP-0023 §Image Validation — "replace names a key with no actual collision"; §Image Patching And Overrides — "a replacement declaration for a non-colliding key is an image assembly error"). A replacement declares the survivor of a REAL collision (≥2 distinct registrations); it is NOT a silent order override that re-points a single uncollided selection. The complement of :rf.error/image-replacement-winner-unresolved (which validates the WINNER coordinate of a real collision): this validates the KEY is a real collision. 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-replacement-keys-collide!. Per EP-0023 §Image Patching And Overrides |
:remove-the-replacement-or-introduce-the-collision — the call throws; remove the replacement declaration (it overrides nothing), fix a typo'd id, or select the colliding source you meant to replace |
:image, :which (:replace / :replace-standard), :kind, :id, :winner (the declared winner coordinate), :selected-count (0 / 1), :selected-coordinates, :recovery |
:rf.error/image-standard-replacement-forbidden |
:error |
diagnostic | An image selected a descriptor colliding with a framework STANDARD registration without a :replace-standard declaration, OR declared :replace-standard against a standard that is NOT replaceable — :rf.standard/replaceable? false (the default) or an invariant-coupled standard (:rf.standard/requires-conformance non-empty, e.g. :rf.interceptor/path, which stays non-replaceable until a conformance profile exists) (EP-0023 §Image Patching And Overrides — "Standard registrations must not be shadowed accidentally"). Assembly-time validation (rf/make-frame), thrown ex-info, not a trace; diagnostic-channel. Emitted by image_assembly.cljc's resolve-collision. Per EP-0023 §Image Patching And Overrides |
:declare-replace-standard-or-rename / :remove-replace-standard-or-rename — the call throws; declare :replace-standard against a replaceable standard, or rename the id so it does not shadow the standard |
:image, :kind, :id, :standard-coordinate, :colliding-coordinates (the source coordinates of every descriptor colliding with the standard), :requires-conformance (present when the standard is invariant-coupled), :recovery |
:rf.error/image-replacement-conflict |
:error |
diagnostic | Two COMPOSED images declared CONFLICTING :replace / :replace-standard winners for the SAME (kind, id) — i.e. different winner source coordinates (EP-0023 §Image Composition — "Order must not silently decide which registration wins"). Before this check, the per-image replacement maps were merged with a bare last-merge-wins fold, so the later image's winner silently won — exactly the order-decides-the-survivor footgun the EP forbids at composition (rf2-32siq3.19). The cross-IMAGE counterpart of :rf.error/image-replacement-winner-unresolved (which polices ONE declaration against the selected descriptors): this polices two images DISAGREEING about the survivor, before resolution begins. IDENTICAL declarations across images are idempotent (they agree — no conflict). 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 merge-replace-maps. Per EP-0023 §Image Composition |
:reconcile-the-conflicting-replacement-winners — the call throws; reconcile the composed images to one agreed winner coordinate for the key, or drop the duplicate declaration |
:image, :which (:replace / :replace-standard), :kind, :id, :winners (the two disagreeing winner coordinates), :recovery |
:rf.error/image-missing-capability |
:error |
diagnostic | A frame's resolved image generation required a :rf.image/requires capability absent from the frame's supplied :capabilities map (EP-0023 §Public API — "frame creation fails before the image generation becomes runnable"). The diagnostic distinguishes a missing CAPABILITY (a host service the frame must supply) from a missing REGISTRATION. Checked at the frame boundary (rf/make-frame); the EP-0008 promotion of the capability check to an always-on row is a later slice — until then it stays diagnostic-channel (thrown ex-info aborting frame creation, not delivered to the always-on listener). Emitted by image_assembly.cljc's check-capabilities!. Per EP-0023 §Public API and API §Registration |
:supply-the-capability-or-drop-the-requirement — frame creation aborts; seat the missing capability in :capabilities or drop it from the image's :rf.image/requires |
:missing-capabilities, :supplied-capabilities, :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 …) — previously nil silently registered a runnable anonymous default frame (leaking a frame record / mis-targeting later dispatch/subscribe) and another non-map failed only by an obscure host ClassCastException with no structured ex-data. 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 (an EP-0015-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. (The EP-0024 unification of make-frame onto one constructor over the image-selection AND record-config key families does not change the :images-is-a-vector rule.) 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 (reg-frame / make-frame / owned frame-provider) 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 (Mike-ruled (a) 2026-06-23, rf2-vw5h1r): 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!, post rf2-izy3b2), 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. Previously the entire :initial-events vector was SILENTLY DROPPED — the frame was created with an empty app-db and no diagnostic, violating both the EP guiding rule (:initial-events is no LESS capable than the hand-written dispatch-sync loop it replaces, which would error loudly on an unresolved var) and Conventions §No silent swallow (rf2-jsokxu). Now 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 reg-frame) 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 (reg-frame / make-frame) INSIDE an event handler — a cascade 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-cascade 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 reg-frame. 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-reset-in-handler |
:error |
diagnostic | reset-frame! was called INSIDE an event handler — a cascade was in flight (trace/*handler-scope* bound) at the reset (EP-0027 §Reset). reset-frame! is a top-level / view LIFECYCLE op (it destroys then re-constructs the frame, re-running :initial-events); like construction, a handler changes app-db while views / top-level materialize and reset frames from it. Rejected UP FRONT, before any teardown — without this preflight the destroy succeeded (no handler-scope guard on destroy-frame!) and then the re-reg-frame hit the construction-in-handler guard, leaving the frame DESTROYED-AND-NOT-RECREATED and signalled by an error naming the wrong cause (rf2-y6uzx8). The up-front rejection is atomic: the frame is untouched. Reset-time, thrown ex-info, not a trace; diagnostic-channel. Emitted by frame.cljc's reset-frame!. Per EP-0027 §Reset |
:reset-frames-in-view-or-top-level — the call throws; move the reset to a frame-provider in the view tree, or to top-level boot |
:frame (the id under reset), :recovery, :reason |
~~:rf.error/make-frame-record-only-key~~ |
— | n/a (retired) | RETIRED (EP-0024). This row policed the EP-0023 two-constructor split: an rf/make-frame opts map carrying a record-config key (:initial-events, :fx-overrides, :platform, :ssr, :doc, :preset, :tags) outside the object-constructor set was rejected and redirected at the advanced re-frame.frame/make-frame. EP-0024 collapses the two constructors into one make-frame that honours image-selection AND record-config keys in one call (reversing the rf2-32siq3.45 option-(b) disposition), so the fence the redirect enforced no longer exists — there is no key to reject. Per 002 §Per-instance frames and API §make-frame. |
— | — |
:rf.error/live-frame-id-conflict |
:error |
diagnostic | A make-frame / reg-frame call's :id named an irreconcilable conflict against an already-live frame in the PROCESS-LOCAL LIVE-FRAME REGISTRY (EP-0023 §Id Spaces — "a frame id is unique in the process-local live-frame registry"). A frame id is unique among live registered frames (registration ids like :counter/inc may be reused across images; frame ids like :counter/main name one live frame). Behaviour change (EP-0024). This row formerly fired on EVERY duplicate live id (a blanket fail-loud). EP-0024 replaces the blanket refusal with idempotent replacement — re-evaluating the same frame declaration under a live id refreshes config + the resolved image generation while PRESERVING durable state (hot-reload / Story-re-evaluation friendly; the same contract the UI-owned frame-provider relies on for idempotent re-mount). The throw is retained ONLY for an irreconcilable conflict the runtime cannot reconcile into a replacement; an ordinary re-mount under the same id is no longer an error. Frame-creation-time validation, thrown ex-info, not a trace; diagnostic-channel. Emitted by live_frame.cljc's register-live-frame!. Per EP-0024 §Duplicate id policy and 002 §Per-instance frames |
:use-a-unique-frame-id-or-reset-the-existing-frame — the call throws; pick a fresh frame id, or reset-frame! / destroy-frame! the existing frame first |
:frame-id (the conflicting id), :recovery |
: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 (EP-0023 §Image co-fix F2): 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 (EP-0008): 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 — previously promised but undeliverable under the diagnostic classification. 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 (EP-0008): 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 (EP-0008): 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 (EP-0008): 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) itself threw while rendering the error response body; the Ring host adapter falls back to the locked default error template — "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 (EP-0008): 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 inside build-full-response's render-time catch AFTER the render-time path has ALREADY stamped the projected status; 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 status the render-time path stamped. 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, :exception (the throwable's message), :ex-class |
: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 EP-0008 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 EP-0008 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 EP-0008 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 EP-0008 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 EP-0008 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 (rf2-o5ca8k). 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 EP-0008 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 (rf2-1vpbld): 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 EP-0008 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 EP-0008 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-cascade 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 cascade 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-app-db! / reset-app-db! / replace-runtime-db! / 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 — the new app-db against the app-schema set, or (for replace-runtime-db! / replace-frame-state!) the new runtime-db against the framework-owned runtime-db validator (reg-runtime-schema). 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-app-db! / reset-app-db! / replace-runtime-db! / 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/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/no-such-cofx |
:error |
always-on | An inject-cofx interceptor referenced a cofx-id with no registered handler. Per 002 §Effects and coeffects. Emitted by re-frame.cofx/inject-cofx when registry lookup misses. Sibling interceptors continue; the ctx flows through unchanged. Retired with inject-cofx (EP-0017 slice A.3) — succeeded by :rf.error/unregistered-cofx (below) |
:no-recovery — the cofx injection is a no-op; subsequent interceptors run with the ctx unchanged |
:rf.cofx/id, :rf.cofx/value (only when the 2-arity inject-cofx was used), :rf.trace/event-id (the event that ran the offending interceptor chain, when available) |
: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. EP-0017 |
: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. EP-0017 |
: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. EP-0017 |
: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. EP-0017 |
: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. EP-0017 |
: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 in EP-0017 (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/world-inputs-renamed |
:error |
always-on | :rf.world/inputs was supplied in a dispatch / dispatch-sync opts map — renamed to :rf.cofx in EP-0017 (no alias, EP-0007 rule 2). A hard error naming :rf.cofx; fires in production too. Per 002 §The :rf.cofx envelope field. Lands with the dispatch-opts change (slice A.2/A.3) |
:no-recovery — the dispatch is rejected |
:rf.trace/event-id (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. Emitted from router.cljc and subs.cljc |
:replaced-with-default — dispatch / dispatch-sync recover (no-op); subscribe returns nil (the framework's built-in recovery for an invalid operation) |
: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. Promoted onto the always-on axis per EP-0008: 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) |
: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-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-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 (EP-0029 A3) 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, EP-0029 B1). 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 (EP-0029 A4) 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 (EP-0029 A4) 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 (EP-0029 A4) 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 (EP-0029 A4) 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 (EP-0029 A5) 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 (EP-0029 A5) — 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 (EP-0029 A5) 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 (EP-0029 A5) 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 (EP-0029 A5) 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 (EP-0029 A5) 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 (EP-0029 A6) 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-internal-event-reserved |
:error |
diagnostic | A machine declares a reserved :rf/* framework event in :internal-events (EP-0029 A6) — 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 (EP-0029 A6) 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. EP-0017 slice-B.9 |
: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 by design (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. EP-0017 slice-B.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. EP-0017 slice-B.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.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 — the implicit "spec-less spawn" lifecycle is REMOVED (rf2-ywv74m). 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 (EP-0008): 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.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 (previously the throw was silently swallowed, surfacing downstream only as :rf.warning/no-clock-configured with no signal that the fn itself blew up). The exception is now 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-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.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-leave-guard?) 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 EP-0025 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, EP-0012). Thrown at reg-route time (caller bug; dev and prod), BEFORE any state mutates and before the route can ever activate — the EP-0025 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 reg-frame'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/bad-app-schemas-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, rf2-7pllal) |
:no-recovery — the call throws |
:received (the offending value), :expected (the contract string) |
:rf.error/bad-app-schema-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 retired legacy 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/bad-app-schema-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. Warn-vs-reject: warn-and-proceed belongs on shared surfaces where misuse still executes as intended (the :rf.db/runtime effect seam); hard-reject belongs on category errors like this one (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/bad-app-schema-metadata |
:error |
diagnostic | reg-app-schema was called with a second argument that is not a registration-metadata map carrying the schema under :schema, or with a metadata map that has no :schema key. Per 001 §Registration grammar the schema is :schema-in-metadata (rf2-wvh95f F2) — (reg-app-schema [:user] {:schema UserSchema}), not the retired positional (reg-app-schema [:user] UserSchema) shape. 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 after the F2 grammar change is passing the bare schema where the metadata map now goes (per 010 §App-db schemas) |
:no-recovery — the call throws; the registration is rejected and nothing is stored |
:path (the registration path), :received / :metadata (the offending second arg), :rf.error/id |
:rf.error/bad-app-schemas-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 | reg-frame metadata'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/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 EP-0002 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-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 EP-0012 path segments (per 013 / 015 §Registration-owned transient classification). A flow's :sensitive / :large classify the flow's OWN output subpaths only; EP-0025 removed flow output-sensitivity propagation — a flow no longer inherits its inputs' classification, and the :rf.egress/output-sensitivity declassification key + its enum are gone (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 but the optional day8/re-frame2-routing 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-routing to deps |
:where (the calling fn), :reason |
: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-app-db! / reset-app-db! / replace-runtime-db! / 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.error/wait-until-timeout |
:error |
diagnostic | re-frame.test-helpers/wait-until exhausted its bounded deadline (:timeout-ms, default 2000) before the condition became 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. The view-test sibling of :rf.error/poll-until-timeout; like it, a test-support surface that never reaches the always-on error-emit listener. Surfaced as a thrown ex-info, not a trace. Per 008 §wait-until |
:no-recovery — the deadline elapsed; the throw propagates (JVM) / rejects the promise (CLJS) |
:where ('rf/wait-until), :elapsed-ms, :label, :reason |
:rf.warning/route-shadowed-by-equal-score |
:warning |
diagnostic | A reg-route registered a pattern whose :rf/route-rank tuple equals an already-registered pattern's; the new route shadows the old per stable sort order (per Spec-Schemas §:rf/route-rank and 012 §Route ranking algorithm) |
:warned-and-replaced — the new route registers; equal-score sibling is shadowed by stable sort |
:route-id (the new id), :shadowed (the displaced id) |
: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 (EP-0025 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, or :epoch-restored). 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 — rf2-u5kmf8), 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. 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 + 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 / :work/status :suppressed row carrying the carried (suppressed) :work/id (and, for supersession, the current/superseding :work/id). Three triggers: (1) SUPERSESSION — a fresh request with the same :request-id replaced this one; carries :stale/reason :rf.http/request-id-superseded, :recovery :superseded-by-fresh-request, and both carried (superseded) + current (superseding) :work/id (the two =-distinct via the per-request-id issuance counter); complements the legacy :rf.http/aborted :reason :request-id-superseded trace; emitted by re-frame.http.transport. (2) ACTOR-DESTROY OBSOLETE TARGET (rf2-yrrpe2) — 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 :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 (rf2-u5kmf8) — 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!. All three keep the late completion from delivering to the original :rf/reply-to target. 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) — no app target runs for the suppressed attempt |
:work/id (carried), :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), :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 | A frame's drain loop detected (:destroyed? (:lifecycle frame)) mid-cycle; remaining queued events are dropped. Per 002 §Edge cases worth pinning. Lifecycle event, not error-shaped (per the :frame/* lifecycle family) |
n/a — lifecycle event, not error-shaped. Remaining queued events are dropped silently | :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, the same :dispatched-at causal-token precedent) — 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/dispatched-at-retired |
:error |
diagnostic | A dispatch / dispatch-sync carried the retired :dispatched-at opt — RETIRED in EP-0010 disposition 5 (EP-0007 one-name-per-fact, no alias). There is no caller-supplied dispatch-time field; the durable causal-time fact is (:rf/time-ms (:rf.cofx envelope)). The guard is production-reachable (a retirement hard error fires in production too, NOT gated on interop/debug-enabled?) but 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; remove :dispatched-at and read causal time from :rf/time-ms in :rf.cofx |
:event-id, :event, :retired-key, :replacement |
: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 |
:error |
diagnostic | rf/reload-images! named a target that does not resolve to a live IMAGE-LOADED frame (a frame carrying a sealed image generation). Thrown by re-frame.live-frame; dev/tooling-time guard. Per 002 §Frame target resolution |
:target-an-image-loaded-frame-id-or-value — pass a live image-loaded frame id or frame value |
:target, :live-frame-ids |
: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 (post-EP-0023 read grammar) carried no :frame key — the implicit default-realm read it once implied is gone, so a frameless query map is an error, not a default read. Thrown by re-frame.core. Per 002 §Frame target resolution |
:no-recovery — pass {:frame f :kind k …} or use the keyword arity |
:received-keys |
: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 reg-frame 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 (EP-0012). 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 the installed adapter was torn down (destroy-adapter!) — distinct from never-installed (:rf.error/no-adapter-installed). Thrown by re-frame.substrate.adapter's require-adapter!. Per 006 §The adapter API contract |
:no-recovery — re-install an adapter (rf/init!) before using the runtime |
(none) |
: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/no-root-view |
:error |
diagnostic | A test-fixture assertion helper ran with no :root-view available (called outside the fixture body, or the fixture was created without a root view). 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 :root-view to the fixture |
(none) |
:rf.error/testid-bad-arg |
:error |
diagnostic | A find-by-testid test helper received a testid argument that is neither a keyword nor a string. 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 keyword or string testid |
:testid |
: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/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 :on-success / :on-failure reply target was neither an event vector nor nil — it cannot be dispatched. Thrown by re-frame.http-encoding's reply-event builder; dispatch-time validation. Per 014 §Reply addressing |
:no-recovery — make the reply target an event vector (or nil) |
:value |
: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/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, … — the slip after the rf2-wvh95f F2 grammar moved opts to the middle 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/invalid-route-metadata non-map guard and the reg-resource / reg-mutation metadata-slot map gate (rf2-t65lqt). 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 EP-0025 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-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 CR/LF/NUL — forbidden by RFC 7230 §3.2.4 (header-splitting injection). Thrown by re-frame.ssr.ring.cookie's serialiser. Per 011 §Trusted shell hook contract |
:remove-injection-chars-from-cookie-attr — strip CR/LF/NUL 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-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-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 in EP-0022 (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 in EP-0022 (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) |
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.
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. Per EP-0008 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. Per EP-0008 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 (previously this promise held only where the dev trace surface was live). 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 EP-0002 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 {:cascades-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 cascade 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-cascade append is the floor cost when in-cascade. Tools that want maximum dev-loop throughput can (rf/configure! {:trace-buffer {:cascades-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 cascade¶
Two cascade-wide channels ride on every trace event emitted inside a cascade — neither is scoped to errors:
-
:rf.trace/dispatch-idunder:tags(per §Dispatch correlation). Grouping raw trace events by cascade is a single-key filter —(filter #(= cascade-id (get-in % [:tags :rf.trace/dispatch-id])) events). Tools that need cascade trees walk:rf.trace/parent-dispatch-idupward across:rf.event/dispatchedevents (the inter-cascade 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 cascade, 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-cascade 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 — cascade-keyed retention¶
Resolved 2026-05-25. The trace surface is partitioned per-frame and sized by cascade count, not event count. Five resolved sub-questions:
- Cross-frame cascades. Each frame retains the traces of cascades 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 cascades 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. Cascade 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 cascades from individual events. - In-process
trace-bufferAPI. Per-frame, cascade 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). - Cascade size bound. No per-cascade trace cap by design. The operator's tuning lever is the cascade-count knob (
:rf.trace/cascades-retained, default 50, per-frame override), not trace-volume per cascade. A cascade with 50K traces takes one slot like a cascade with 5 traces takes one slot.
Single retention knob: :rf.trace/cascades-retained (frame metadata, default 50). When cascade #N+1 arrives, the oldest cascade slot (and every trace event ever emitted under its :dispatch-id) is evicted as a unit. No per-trace-type cap, no per-cascade trace cap, no other knobs. The full surface lives at §Per-frame trace rings (cascade-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, Mike ruling #6) 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 upcoming data-classification mechanism — separate spec doc; in progress.)
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 upcoming data-classification mechanism (separate spec doc; in progress) — 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 cascades visible to the pair tool configure the policy explicitly. - The Xray-MCP server (per Tool-Pair.md) MUST default-drop
:sensitive? trueevents from the cascade 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 cascade 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 cascade is intentionally lost. Selective scrubbing is unsafe because a single sensitive event can have caused later non-sensitive cascades — 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.