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, and listeners receive them one at a time, never concurrently. Delivery is two-tier: a public emit fans out synchronously, while an internal, drain-owned emit is delivered at the post-drain boundary of the operation that produced it, before that operation returns (see §Subscription / consumption).
The shape is documented below.
Core fields (required on every event)¶
{:id <int> ;; auto-incrementing trace id; unique per process
:operation <kw> ;; what's being traced — namespaced keyword identifying
;; the emit site (e.g. :rf.event/dispatched, :rf.machine/transition,
;; :rf.error/no-such-sub). The event-id / sub-id / fx-id
;; that motivates the emit rides under :tags. Per
;; Spec-Schemas §:rf/trace-event.
:op-type <kw> ;; discriminator: :rf.event, :rf.sub, :rf.fx, :rf.view,
;; :rf.frame, :rf.machine, :error, :warning, etc.
;; The full vocabulary is enumerated in §:op-type vocabulary
;; below and in Spec-Schemas §:rf/trace-event.
:time <ms> ;; emit timestamp (host clock)
:tags {...}} ;; open-ended bag for op-type-specific fields
The runtime emits each trace event at the moment of interest with the host clock time captured in :time. The shape is event-at-a-time, not span-shaped: there is no separate start/end pair, no :duration, and no :child-of parent-id. Tools that need run correlation use the dispatch-id correlation fields documented under §Dispatch correlation instead.
:op-type versus :operation. :op-type is the discriminator a consumer branches on — a small, stable vocabulary of ~20 values (enumerated in §:op-type vocabulary below and in Spec-Schemas §:rf/trace-event). Tools route on :op-type to subscribe to a slice (e.g. :rf.event, :rf.sub, :error). :operation is the specific identity of the emit site within that slice — typically a namespaced keyword like :rf.event/dispatched, :rf.machine/transition, or :rf.error/no-such-sub. A consumer subscribing to all errors filters :op-type :error; a consumer hunting one category branches further on :operation.
Re-frame2 additions (additive, optional)¶
{:source :ui ;; :ui, :after-timer, :http, :machine-action, :repl, … (full enum: Spec-Schemas §:rf/dispatch-envelope) — origin of the trigger
:recovery :no-recovery} ;; recovery disposition — always on the error path (default :no-recovery), plus any event whose producer supplies it (e.g. :rf.http/retry-attempt)
:source is hoisted to the top level of every event whose tags carry it; :recovery likewise hoists to the top level of every event whose producer supplies it (e.g. the :info :rf.http/retry-attempt marker), and is additionally always present on the error path (defaulting to :no-recovery). Both are top-level, not under :tags. The :frame field — present on most events — rides under :tags (every emit site that knows the frame includes it there).
Frame identity on the raw event: [:tags :frame], read via the canonical accessor¶
A raw trace event carries frame identity only under [:tags :frame]. There is no public top-level :frame on the raw trace-event shape — :source and :recovery hoist to the top level (above), but :frame does not. This is the single supported wire shape; the reference producer (re-frame.trace/build-event and every router / cofx / error emit site) stamps :frame under :tags, never at the top level.
Derived / projection records carry :frame at the top level instead. The records the trace surface projects from the raw stream — event bundles ((rf/trace-buffer frame-id), §Event-bundle projection), :rf/epoch-records, dispatch consequences, and cursor / summary records — expose frame identity at top-level :frame (the bare record/projection vocabulary, per Tool-Pair §Identity spellings). The two layers are deliberate: a tool reading a raw trace event reads [:tags :frame]; a tool reading a projected record reads top-level :frame.
The canonical reader. Consumers read a raw trace event's frame through the trace contract's one canonical accessor — re-frame.trace/trace-event-frame (alias frame-of), whose implementation is (get-in trace-event [:tags :frame]) — rather than hardcoding the [:tags :frame] path (or a dual (or (get-in ev [:tags :frame]) (:frame ev)) read) at each call site. It returns the frame-id, or nil for an event emitted outside any frame-qualified run (registry-time / boot-time). This accessor is the one supported way to read frame off the raw event shape (see also §Canonical per-frame routing key, Conventions §The single-root reserved set — :frame is the deliberate bare carve-out — and Tool-Pair §Identity spellings). Pre-alpha posture: one shape, one reader, no compatibility ambiguity.
Dispatch correlation: :rf.trace/dispatch-id / :rf.trace/parent-dispatch-id¶
Pair-shaped tools and per-event diagnostics need to correlate the run a dispatch belongs to — "this trace event fired inside the run started by that dispatch." The runtime maintains two distinct correlation channels. They are cross-cutting — stamped across every domino family — so they live under the trace-channel namespace :rf.trace/*, not under any single domino's :rf.<family>/* (per Conventions §:rf.trace/*):
{:tags {:rf.trace/dispatch-id <uuid-or-counter> ;; the run this event belongs to
:rf.trace/parent-dispatch-id <uuid-or-counter>} ;; on :rf.event/dispatched only — the run
;; that caused THIS dispatch
...}
Semantics:
:rf.trace/dispatch-idis per dequeued event — the run of one event, not the whole drain. It is allocated by the runtime when a dispatch is enqueued (before routing) — once perdispatchcall, so a UIdispatch, each:fx [[:dispatch …]]child, and each frame-creation:initial-eventssetup step each receive their own — and rides on every trace event emitted inside that one event's pipeline run —:rf.event/dispatcheditself,:rf.event/db-changed,:rf.fx/handled,:rf.sub/run,:rf.machine/transition,:rf.flow/*, every:rf.error/*, and any future op-type the runtime adds. It does not span sibling events that merely happen to drain in the same turn: when a handler:fx-dispatches a child, the child is a separate dequeued event with its own:rf.trace/dispatch-id(and its:rf.trace/parent-dispatch-idpoints back at the parent — see below). The:rf.trace/dispatch-idis therefore the trace-stream face of the epoch unit: one:rf.trace/dispatch-id= one dequeued event = one:rf/epoch-record(per 002 §Drain versus event). A machine's:raisesub-events and:alwaysmicrosteps are not separate dequeues — they are in-memory microsteps inside the triggering event's macrostep (per 005 §Drain semantics), so every trace they emit carries the triggering event's:rf.trace/dispatch-idand rides its epoch; they do not allocate a new one. Consumers (Storygroup-by-event, Xray's causality graph, re-frame2-pair'scascade-of, schema-timeline correlation) group raw trace events by:rf.trace/dispatch-iddirectly — no inference from sequence required. The runtime carries the in-flight run's id through the:rf.trace/dispatch-idslot of the handler-scope record (re-frame.trace/*handler-scope*, per §Handler-scope), bound byrouter.cljcaround each event's processing;emit!reads the slot and merges it into the event's:tagswhen bound and not already present. Implementations may use a process-monotonic counter, a UUID, or any opaque value with the same uniqueness contract: distinct within a single process for the lifetime of the trace surface. Tools treat it as opaque. Trace events emitted outside any in-flight run (handler registration before any dispatch, REPL evals that don't dispatch) carry no:rf.trace/dispatch-id. (The frame-creation initial event is itself a dequeued event and carries its own:rf.trace/dispatch-id; only emits genuinely outside any event — registry-time, REPL — go uncorrelated.):rf.trace/parent-dispatch-idis scoped to:rf.event/dispatchedonly. It documents run-from-run lineage — "this dispatch was emitted as a side-effect of another event's processing" — which is a per-event-dispatch fact, not a per-trace-event fact. Concretely: when anfxhandler running inside the do-fx phase of dispatch D₁ invokes(rf/dispatch ...), the runtime records the new dispatch's:rf.trace/parent-dispatch-idas D₁'s:rf.trace/dispatch-idon the new dispatch's:rf.event/dispatchedevent. If the dispatch was initiated outside any in-flight event (a timer, a UI handler, the REPL, the SSR boot path),:rf.trace/parent-dispatch-idis absent from:rf.event/dispatched. Non-:rf.event/dispatchedtrace events never carry:rf.trace/parent-dispatch-id— they belong to a single run (their:rf.trace/dispatch-id) and the inter-run lineage hangs off the run's root.- Top-level dispatch. An
:rf.event/dispatchedevent with no:rf.trace/parent-dispatch-idis a root of a run tree. Pair-shaped tools draw run trees by walking:rf.trace/parent-dispatch-idupward across:rf.event/dispatchedevents; the per-run body (every other trace event in that run) is the slice of the trace stream sharing the run's:rf.trace/dispatch-id. - The run-correlation primitive. Because the runtime emits event-at-a-time (no
:child-ofspan field),:rf.trace/dispatch-idis the only intra-run correlation channel and:rf.trace/parent-dispatch-idis the only inter-run correlation channel. The pair lets tools both (a) group raw spans by run and (b) walk lineage between runs, without consulting the:rf/epoch-recordprojection. Tools that prefer structured per-run slices read the assembled:rf/epoch-record(per Tool-Pair §Time-travel) — the raw:rf.trace/dispatch-idchannel is the lower-level primitive. - Production elision. Both fields ride the trace stream and are elided in production with the rest of the trace surface. The dispatch-id allocation counter and the
*handler-scope*Var read sit inside theinterop/debug-enabled?gate inemit!, so the whole machinery compiles out.
Tools consume these two channels to build run views: "show me every fx that ran in this run" is a filter on :rf.trace/dispatch-id over the raw stream; "show me all dispatches descended from [:user/login ...]" is a transitive walk over :rf.trace/parent-dispatch-id across :rf.event/dispatched events.
Origin tagging: :rf.event/origin¶
When a tool (the pair tool, a story runner, the REPL, the SSR boot path) needs its own dispatches distinguishable from application dispatches, it can tag them with an :origin opt at dispatch time (per 002 §Dispatch origin tagging). The runtime lifts the value onto every :rf.event/dispatched trace event under :tags :rf.event/origin (the dispatch opt keeps its ergonomic short name :origin; the trace tag it lifts onto is namespaced to the event family):
{:tags {:rf.event/origin :pair ;; tag set by the dispatching tool; default :app
:rf.trace/dispatch-id ...
...}
...}
:rf.event/origin is unconstrained at the framework level — tools and applications agree on values (:pair, :claude, :story, :test, etc.). The default is :app. User application code typically omits the opt; tool surfaces set it so post-mortem filters like "show me only the dispatches I (the pair tool) issued during this session" become a one-key filter on the trace stream.
:rf.event/origin is distinct from :source: :source describes the trigger kind — the closed-enum "what woke the runtime?" axis (the canonical value set is the :source row of :rf/dispatch-envelope in Spec-Schemas, the SSOT — this section does not re-enumerate it); :rf.event/origin describes the actor identity (which tool or app subsystem emitted the dispatch) and is used for filtering. Tools may set both. The default :source is :unknown; substrate-internal dispatch sites (machine :after timer, machine spawn fx, :dispatch / :dispatch-later fx — discriminating machine vs ordinary parent, routing-internal dispatches, HTTP reply settle, …) stamp the matching specific value.
Dispatch source as the functional-origin axis (:source)¶
The framework carries one closed-enum axis classifying every dispatch's trigger kind / functional origin: :source on the dispatch envelope. The canonical value set is the :source row of :rf/dispatch-envelope — the single source of the enum. There is no parallel origin axis (no :rf/dispatch-origin).
Substrate-internal stamp sites (canonical inventory):
:source value |
Stamped by | When |
|---|---|---|
:ui |
UI handler call-site | a user button / input handler dispatches |
:frame-init |
make-frame's :initial-events fire site |
a frame's lifecycle init dispatch |
:machine-spawn |
re-frame.machines.lifecycle_fx/spawn |
actor bootstrap — the spawned machine's :start (or synthetic [:rf.machine.spawn/spawned]) trigger |
:machine-action |
:dispatch / :dispatch-later fx handler when the emitting handler is a machine (its registration carries :rf/machine? true) |
machine-handler-issued dispatch — the actor-message path. Carries :source-detail {:ms <ms>} for the -later variant |
:always |
re-frame.machines.transition :always microstep |
per-microstep marker on :rf.machine.microstep/transition; reserved closed-set value (intra-macrostep — no envelope) |
:after-timer |
re-frame.machines.timer :after fire site |
a state-machine :after timer firing |
:fx-dispatch |
:dispatch fx handler (non-machine parent) |
the :dispatch fx executes — child of an ordinary handler's do-fx |
:fx-dispatch-later |
:dispatch-later fx handler (non-machine parent) |
the :dispatch-later fx fires after delay — child of an ordinary handler. Carries :source-detail {:ms <ms>} |
:http |
re-frame.http_encoding/dispatch-reply-via-late-bind! |
managed-HTTP reply settle — :on-success / :on-failure cascade entry |
:router |
re-frame.routing internal dispatches (:route/link click handler, the browser URL-change feed) |
URL events, route-link clicks |
:ssr-hydration |
the user's hydration boot site (the framework does not auto-detect — see Spec 011) | :rf/hydrate cascade or any other SSR-boot-time dispatch |
:test |
test fixtures / harness dispatches (opt-in {:source :test}) |
test-harness opt-in |
:tool |
tooling adapters (Xray controls, Story play scripts, the pair-MCP write surface) — self-tag at their dispatch site | tool-issued dispatch |
:websocket |
application-level websocket adapters (the framework does not ship one) | a websocket-frame-arrived dispatch. The closed-enum slot is reserved; apps opt in |
:repl |
REPL eval | tests + REPL bodies that want the discriminator |
:unknown |
default — un-stamped dispatch | UI / app code that did not opt in. Unstamped paths don't silently misattribute |
:other |
escape hatch | reserved for cases the closed set doesn't cover |
The default — for both the macro form ((rf/dispatch event) / (rf/dispatch-sync event)) and the plain-fn form (the CLJS value-alias, or re-frame.router/dispatch! directly) — is :unknown. UI handlers stamp :source :ui explicitly; internal callers thread :source into the opts map at their emit site to override the default. The canonical UI call-site path stamps it automatically: the reg-view macro injects {:source :ui …} into the lexically-bound dispatch noun (per §:rf.trace/call-site), so a view's on-click #(dispatch [...]) classifies as :ui without the app having to thread the opt by hand. :source :ui is not dev-only — dispatch! reads it unconditionally — so it survives production elision even where the dev call-site coord does not.
;; default :unknown
(rf/dispatch [:cart/add {:sku "abc"}])
;; explicit UI stamp
(rf/dispatch [:cart/add {:sku "abc"}] {:source :ui})
;; tool-issued dispatch (per-call opt-in)
(rf/dispatch [:order/submit] {:source :tool})
:source is distinct from :origin:
:sourceis the closed-enum trigger kind / functional origin — what woke the runtime — see Spec-Schemas §:rf/dispatch-envelopefor the canonical 17-value enum. Tools branch on it to render the Epoch panel's DISPATCH chrome, the L2 row prefix, and per-source filter pills.:origin(:app/:pair/:story/:test/ …) is the actor identity — which tool or app subsystem emitted the dispatch — and is unconstrained at the framework level.
The closed enum is closed at the spec level. Adding a new value is a framework-level change with substrate-side consumer impact; it is not a per-app extension point.
:source is not inherited through :fx [[:dispatch ...]] child dispatches — each child dispatch's :source reflects its immediate trigger (:fx-dispatch / :fx-dispatch-later / :machine-action), not the originating user event's. Inheritance still applies to :fx-overrides, :interceptor-overrides, :trace-id, :origin, and :frame.
The dispatch envelope's :source slot rides onto every :rf.event/dispatched trace event under the :source tag. Production builds elide the trace surface entirely; the envelope's :source slot remains in the production build (it is plain envelope data, not gated trace tooling) but consumers that read it sit on the dev-only trace stream and DCE alongside the rest of the trace machinery.
The dispatch envelope's :rf.cofx map — the recordable-coeffect record: the framework-stamped :rf/time-ms plus any caller-supplied or generated owner-qualified facts (per 002 §Recordable coeffects) — also rides onto every :rf.event/dispatched trace event, under :tags :rf.cofx. This is the trace-stream face of the causal token the lens-side COEFFECTS lens renders. The lens filters the framework-internal coeffects (:db, :event, :rf.frame/id, :rf.db/runtime) and shows the declared recordable leaves — the handler's declared inputs, the most user-relevant facts on the token (:rf/time-ms is always among them; every other leaf follows per-leaf projection). Unlike the envelope's :rf.cofx itself — which is durable causal data stamped unconditionally in build-envelope and present in production — the trace stamp is dev-gated: it is co-located with the dispatched trace via the canonical outermost (if interop/debug-enabled? <stamped> <plain>) shape, so production CLJS bundles DCE it along with the rest of the :rf.event/dispatched emit. It is a diagnostic-surface stamp, not an always-on one.
:op-type vocabulary¶
Every trace event carries an :op-type — the coarse discriminator a consumer filters on — and a finer :operation. The :op-type values are a small closed set; the :operation values are the open, per-concern vocabulary catalogued below. The core :op-type values are the :rf.<family> domino discriminators plus the three bare severity discriminators:
:op-type |
Kind | Covers |
|---|---|---|
:rf.event |
domino family | event-dispatch + per-event commit signals (the :db-pending pair, the partition-commit signals, the no-op signal, drain-interrupt). |
:rf.sub |
domino family | subscription recompute / memo-skip / dispose. |
:rf.fx |
domino family | effect dispatch — :rf.fx/handled and the effects-pass marker do-fx (operation :rf.fx/do-fx; it folds into the fx family, it is not a standalone op-type). |
:rf.cofx |
domino family | coeffect supplier run (:rf.cofx/run) + the recordable-generation op (:rf.cofx/generated). The cofx skip / error events ride the severity discriminators instead (:rf.cofx/skipped-on-platform → :warning; the cofx error family :rf.error/unregistered-cofx / :rf.error/missing-required-cofx / :rf.error/cofx-value-invalid / :rf.error/cofx-name-collision / :rf.error/cofx-registration-invalid / :rf.error/cofx-request-invalid / :rf.error/inject-cofx-removed → :error). |
:rf.view |
domino family | view render / post-render / unmount. |
:rf.registry |
family | registration changes (hot reload). |
:rf.frame |
family | frame lifecycle + drain-interrupt. |
:rf.machine |
family | state-machine activity (lifecycle, transition, timers, spawn, history, …) and the :rf.machine.* sub-families. |
:rf.epoch / :rf.epoch.cb |
family | epoch-history operations + listener-exception notification. |
: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 |
At an event boundary, the ordinary drain observed that its exact frame incarnation had been claimed for destruction, and reported every queued event the destroy dropped — the destroying event's own descendants included. |
: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 whole-machine finality: a spawned actor is about to be torn down, and a singleton rests. |
: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.fx/dispatch-later-cancelled |
:rf.fx |
A pending :dispatch-later timer armed under an :id was replaced by a re-arm of that :id or cancelled by {:id k :cancel? true}. |
:rf.machine.spawn-all/started / :rf.machine.spawn-all/all-completed / :rf.machine.spawn-all/some-completed / :rf.machine.spawn-all/any-failed / :rf.machine.spawn-all/child-completed / :rf.machine.spawn-all/stale-completion / :rf.machine.spawn-all/late-completion |
:rf.machine |
:spawn-all spawn-and-join lifecycle. child-completed is a NON-DECISIVE child's fold terminal; stale-completion is the exact-attempt / already-closed-attempt suppression — its exact-attempt fence classifies attempt-unverified / attempt-superseded BEFORE the :resolved? check, so those two fire on BOTH sides of resolution, while duplicate-completion is checked AFTER :resolved?, so it fires only on the unresolved side (an exact-current already-closed child in a still-live join) — and late-completion is ONLY the exact-current :resolved?-latched post-resolution straggler (all classified by :rf.reply/stale-reason). |
:rf.machine.spawn/cancelled-on-join-resolution |
:rf.machine |
A sibling cancelled when a :spawn-all join resolved. |
:rf.machine.spawn/stale-completion |
:rf.machine |
A single-:spawn completion carrier dropped because the parent left or re-entered the spawning state before it arrived (classified by :rf.reply/stale-reason). |
:rf.route.nav-token/allocated (op-type :rf.event) / :rf.route.nav-token/stale-suppressed (op-type :error — the suppression is the failure mode the consumer needs to see) |
— | Navigation-token lifecycle (stale-result suppression). |
:rf.route/fragment-changed / :rf.route/navigation-blocked / :rf.route/entry-denied |
:rf.route / :rf.event |
Fragment-only URL change emission / leave block / terminal entry denial (both decision traces ride :rf.event). |
:rf.route/planned (op-type :rf.event) |
— | One per full-transition commit of the one navigation pipeline every door runs (navigate*) — the route-plan diagnostic projection, emitted just before the commit. |
:rf.route/prefetched (op-type :rf.event) |
— | The single summary trace a warm-mode intent preload emits. NOT an activation trace — no :rf.route/planned / nav-token-allocated / :rf.route/activated accompanies it. |
:rf.route/registered / :rf.route/cleared / :rf.route/activated / :rf.route/deactivated |
:rf.route |
Route lifecycle. |
:rf.registry/handler-registered / :rf.registry/handler-cleared / :rf.registry/handler-replaced |
:rf.registry |
Registration changes (hot reload). |
:rf.flow/* (:flow stream) |
:flow |
Flow lifecycle + evaluation (:rf.flow/registered / -computed / -skip / -cleared / -failed). |
:rf.sub/create |
:rf.sub |
A sub was registered into the reactive graph (emitted at registration time, not first reference). |
:rf.sub/run |
:rf.sub |
A sub recompute (input not = last-seen) — carries value-change + cascade attribution. |
:rf.sub/skip |
:rf.sub |
A sub memo-hit (input = last-seen, body did not re-run). |
:rf.sub/dispose |
:rf.sub |
A sub cache slot was evicted (:reason enum). |
:rf.cofx/run |
:rf.cofx |
An ambient coeffect supplier delivered during context assembly. |
:rf.cofx/generated |
:rf.cofx |
A generator-backed recordable fact was generated at processing-start. |
:rf.view/render |
:rf.view |
Render START of a registered view. |
:rf.view/rendered |
:rf.view |
Post-render (capped at 100/run) — cause + per-view ACTION/REASON data. |
:rf.view/unmounted |
:rf.view |
A registered-view instance tore down. |
: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/restored / :rf.epoch/db-replaced |
:rf.epoch |
Epoch-history operations (snapshot cause + summary, restore, db-replace). |
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-fx: 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 classification policy — the SAME normative site the epoch off-box arm ofrf/project-egressuses for:db-before/:db-after— so declared sensitive paths egress as:rf/redactedand declared large paths get the:rf.size/large-elidedmarker before the snapshot reaches any trace listener or epoch-capture sink. The walk is gated on the frame having declarations, so a frame with no marks keeps the reference-identity (copy-free) the slot promises. Consumers (Xray's Handler panel, re-frame2-pair'scascade-of) read t1 to render the handler's returned:dbvalue and read (t1, t2) together to render the t1→t2 reshape — the framework does NOT precompute a diff. On a flow-throw abort (Spec 013 §Failure semantics) t1 still fires (it ran before the throw) but t2 does NOT (the pending value was discarded). Both ridesinterop/debug-enabled?so production CLJS bundles DCE them.:rf.event/db-changed/:rf.event/frame-state-changed— the two partition-commit signals. Both under op-type:rf.event.:rf.event/db-changedis APP-DB-ONLY — it fires only when the app-db partition changed (the app-db-commit signal; consumers that watch app-db rely on it not firing for framework-only commits).:rf.event/frame-state-changedis the frame-level signal: it fires when either partition changed, and carries:tags :rf.event/partitions— a set drawn from#{:app-db :runtime-db}naming which partition(s) this commit touched. So a runtime-only commit (a machine snapshot or route-slice write) emits:rf.event/frame-state-changedwith:rf.event/partitions #{:runtime-db}and does not emit:rf.event/db-changed; an app-only commit emits both (:rf.event/db-changedplus:rf.event/frame-state-changed#{:app-db}); a commit touching both emits both with#{:app-db :runtime-db}. This keeps a runtime-only change visible to framework route/machine subs and to Xray / pair tooling even when app-db is unchanged, without forcing those tools to infer runtime changes from:rf.event/db-changedalone. Both rideinterop/debug-enabled?so production CLJS bundles DCE them.:rf.event/db-noop— the commit-level app-db no-op signal. Op-type:rf.event, APP-DB-ONLY. Fires when a:dbeffect was present but the app-db partition did NOT change — the handler returned an unchanged db (the common(if cond (assoc db …) db)else-arm), so the commit was a genuine no-op: theidentical?-noop short-circuit incommit-frame-transition!skipped the container write entirely rather than re-installing an equal value (identical?is the cheap fast-path for the common no-change branch;=stays the deeper change-detection, so a distinct-object-but-=-value commit still writes and collapses to no change, which also emits:rf.event/db-noop). It is the complement of:rf.event/db-changed: for a:db-bearing commit exactly one of the two fires (changed →db-changed; unchanged →db-noop). Suppressed when no:dbeffect was returned at all (an:fx-only / runtime-only commit emits neither). Carries:tags {:rf.trace/event-id <id> :rf.event/v <event-vec> :frame <id>}— same routing slots as:rf.event/db-changed, no value payload (the no-op committed nothing). Xray's event / run view renders it as "event returned an unchanged db — nothing committed," so a developer can see an event ran but changed nothing rather than the no-op being silent. Ridesinterop/debug-enabled?so production CLJS bundles DCE it.:rf.frame/created/:rf.frame/re-registered/:rf.frame/destroyed— frame lifecycle (all under op-type:rf.frame).:rf.machine.lifecycle/created/:rf.machine.lifecycle/spawned/:rf.machine.lifecycle/destroyed— machine instance lifecycle, the registrar-substrate triple (see §Two-axis machine observation below).createdfires when a machine handler is registered;spawnedfires when a spawned actor's snapshot lands in the registrar (the registrar-substrate partner of the fx-substrate:rf.machine.spawn/spawned);destroyedfires when the frame-exit cascade reaps a handler / snapshot — its sole trigger, always:reason :parent-frame-destroyed; non-frame-exit teardowns signal on the fx-substrate:rf.machine/destroyedinstead (see §the channel/reason matrix below).:rf.machine.lifecycle/spawnedcarries:tags {:frame <id> :machine-id <type-id> :spawned-id <gensym-instance-id> :invoke-id <declarative-invocation-path-or-nil> :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,: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).: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 :reset}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/runtime :bootstrap-pending?]by a spawn fx;:reset= the runtime replaced a snapshot its definition cannot run with a fresh initial (singleton or spawned actor), beside the:rf.error/machine-state-not-in-definitionor:rf.error/machine-snapshot-version-mismatchtrace naming which. 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 whole-machine finality: a spawned actor is then torn down synchronously, and a singleton rests.):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 is the per-microstep marker), so an app needs no:data :trailworkaround to explain a transition. The snapshot-shaped:before/:afterslots of:rf.machine/transitionare redacted per the machine's own projection-relative:sensitive/:large:datadeclaration on thereg-machinespec, applied at egress to each actor snapshot present — not per the[:schemas :data]schema's per-slot props, which validate:dataand redact only the validator's own failure trace (see 005 §Privacy and 015 §Subsystem projection-relative classification);: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 has nostrictflag, so an unhandled event is ignored, not an error. At6.0.0-alpha.59xstate emits no dedicated signal for it — its inspection stream carries only a zero-microstep@xstate.transition, the same shape a guard-blocked event produces — whereas re-frame2 emits this dedicated benign 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), so the carve-out adds no 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).: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 runtime-db) — see §Two-axis machine observation. Tools that just want "did a machine appear?" can subscribe to either spawn axis (the spawn pair is symmetric — both fire per spawn); tools that want "did a machine disappear?" must subscribe to BOTH destroy channels, which are disjoint (see §the channel/reason matrix below). Tools building causal graphs subscribe to both axes throughout and disambiguate by the naming axis (:rf.machine.spawn/*/:rf.machine/*= fx-substrate;:rf.machine.lifecycle/*= registrar-substrate). Payload:tagscarry:frame,:machine-id(the spec-time registered TYPE — xor an inline:definition),:spawned-id(the gensym'd live actor-instance address),:parent-id(the parent machine's registration-id, when the spawn came from declarative:spawn), and:invoke-id(the declarative spawn invocation path — the absolute prefix-path of the:spawn-bearing state node, when applicable) — together:parent-id+:invoke-idaddress the runtime spawn registry slot at[:rf.runtime/machines :spawned <parent-id> <invoke-id>], so tools can map the registry without re-deriving from app-db. The:rf.machine/destroyedevent carries the reaped actor's live INSTANCE address under:actor-id(:machine-idreserved for the registered TYPE) and is enriched with a:reasontag — either:rf.machine/finished(a spawned actor entered a:final?state and was torn down; see:rf.machine/donebelow) or:explicit(a parent state-exit cascade, a:spawn-allcancel-on-decision, an imperative[:rf.machine/destroy <id>], or any other runtime-initiated teardown). These two are the fx-substrate's complete:reasonvocabulary; the frame-exit cause:parent-frame-destroyednever rides this channel — it is registrar-substrate only (:rf.machine.lifecycle/destroyed, below). Observers that filter on:tagssee:reasonadditively. An:explicitdestroy is a CANCELLATION of an in-progress actor work attempt (the actor was torn down before reaching a:final?leaf), so it carries the reply-envelope cancellation facts (cancellation as DATA — Managed-Effects §Cancellation)::rf.reply/work-id(the canonical machine work-id[:rf.work/machine <actor-id> <invoke-id> <generation>]),:rf.reply/work-kind :machine,:rf.reply/status :cancelled,:rf.reply/work-status :cancelled,:rf.reply/cancelled? true, and:rf.reply/cancel-reason :explicit. For an authenticated current in-progress:spawn-allchild, the runtime first writes its logical id into the join attempt's durable:cancelledset; an already-queued/delayed exact completion is then suppressed as:rf.machine.spawn-all/duplicate-completioninstead of publishing a second terminal. 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. That covers a:spawn-alljoin child too: completion is finality, so a child that folds into a join tore itself down at that finality, and only survivors are cancelled at resolution.:rf.machine/done— machine entered whole-machine finality; a spawned actor is about to be torn down synchronously and its completion carried to its parent, while a singleton rests in its final state. One event per entry into finality.: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?(a singleton rests, per D7 — see 005 §Final states). For a spawned actor it pairs with the immediately-following:rf.machine/destroyedevent whose:tags :reasonis:rf.machine/finished; a resting singleton emits no destroy.: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). Every row carries the timer's milliseconds under:delayand its declared:afterkey under:delay-key(per 005 §Trace events), and names the node that declared the timer::decl-pathis that node's path, and for a timer declared inside a parallel region:regionnames the region and the path is region-relative — the pair:rf.machine/action-rancarries. A consumer pairing a timer's rows keys them on the actor,:decl-pathwith:region, the epoch and:delay-key: two regions can enter same-named leaves carrying the same:afterkey, so the leaf:statealone does not identify a timer.:scheduledfires when the timer is armed, by the handler that resolved its delay; its:tagscarry:delay-source <:literal | :fn | :named>to discriminate a literal delay, a fn delay and a:delaysname (per 005 §Value shape).:firedcarries:fired? <bool>(false ⇒ guard suppressed the transition; sibling timers continue).:cancelledfires on every cancellation path;:reasondiscriminates the closed set:on-exit / :on-destroy / :on-supersede / :on-frame-destroy / :on-restore(:on-restore= epoch restore unwound the bearing epoch and the in-flight host-clock handle was released eagerly; per 005 §Trace events). The:cancelledrow additionally carries the reply-envelope cancellation facts (cancellation as DATA — Managed-Effects §Cancellation)::rf.reply/work-id(the canonical timer work-id[:rf.work/timer [<actor-id> <declaring-path>… <delay-key>] <epoch>], the declaring path region-qualified inside a parallel region and followed by the declared:afterkey, so neither two regions' timers nor two entries of one node's:aftermap share one, matching the:fired/:stale-afterrows so the cancel joins the same scheduling attempt),:rf.reply/work-kind :timer,:rf.reply/status :cancelled,:rf.reply/work-status :cancelled,:rf.reply/cancelled? true, and:rf.reply/cancel-reason(the closed:reasonset). The:firedand:stale-afterrows additionally carry the firing dispatch's causal completion timestamp under:rf.reply/completed-atwhen the synthetic:after-elapsed dispatch supplied a:rf/time-mstoken. (Per Conventions §The naming rules the work identity and completion time ride ONLY under their namespaced:rf.reply/*spelling on these reply-envelope rows — the reply-envelope row carries no bare:work/id/:completed-atduplicate; the bare:work/idis the durable identity on the attempt record (not the reply map, which single-roots it as:rf.reply/work-id) and on non-reply resource-lifecycle rows.) Every:rf.machine.timer/*row — including:firedand:stale-after— carries the timer's owning actor under:actor-id(its live INSTANCE address), NOT:machine-id(reserved for the registered TYPE). 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.fx/dispatch-later-cancelled— emitted byre-frame.fxwhen a pending:dispatch-latertimer armed under an:idis removed before it fires (per 002 §:fxordering and atomicity guarantees). Op-type:rf.fx.:tagscarry:frame(the frame that owns the timer),:id(the timer's:id) and:reasonfrom the closed set:on-replace(a later:dispatch-laterre-armed the same:id, so the later event replaces the pending one — debounce) /:on-cancel(a{:id k :cancel? true}cancel). One row per replace or cancel that found a pending timer; a cancel naming no pending timer emits nothing. Frame destroy cancels pending timers without this row —:rf.frame/destroyedcovers the teardown. An ordinary dev trace, not a warning: replacing a pending timer is the feature working.: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-all).*/startedfires after all N children have been spawned on entry to the:spawn-all-bearing state.*/all-completedfires when:join :allresolves;*/some-completedfires when:join :anyresolves on the success-side;*/any-failedfires when:on-any-failedresolves, on a child error or an explicit destroy of a child.:tags {:actor-id <parent-instance-id> :invoke-id <prefix-path> :child-ids ... :done ... :failed ...}, and*/any-failedadds:failed-id(the decisive child),:reason(its result) and:cancelled(the destroyed child-id set) (the specific subset of tags depends on which event fires; common to all is:actor-id+:invoke-id). The parent's live INSTANCE address rides under:actor-idand the declarative invocation path under:invoke-id. The resolution traces additionally carry the DECISIVE child completion's reply-envelope facts (the child completion that drove the resolution — Managed-Effects §Status taxonomy)::rf.reply/work-id(the child's canonical machine work-id[:rf.work/machine <spawned-id> <invoke-id> <generation>]),:rf.reply/work-kind :machine, and:rf.reply/status(:okfor the*/all-completed/*/some-completedsuccess-side resolutions,:errorfor*/any-failed) /:rf.reply/work-status, plus the causal:rf.reply/completed-atwhen present — so the join-resolving child completion classifies the same way the single-:spawnpath does. A*/any-failedwhose decisive child was explicitly destroyed carries none of these: that child's:rf.machine/destroyedrow already closed its attempt as:cancelled. The companion:rf.machine.spawn-all/late-completiontrace (a genuinely stale straggler completing after the join already latched:resolved?, before the survivor's teardown fully propagated — sibling cancellation on the join decision is unconditional, per 005 §Cancel-on-decision) carries the canonical:status :stale/:rf.reply/work-status :suppressedreply facts (:rf.reply/stale-reason :rf.machine.spawn-all/join-resolved). The straggler fires no further parent event, does not fold into the frozen:done/:failedrecord, and triggers no re-resolution / cancellation; the:resolved?latch stays true.:rf.machine.spawn-all/child-completed— the canonical work terminal for a NON-DECISIVE:spawn-allchild fold. Fires once per accepted fold that did NOT resolve the join — in an:alljoin, every child but the decisive one. Without it a non-decisive child's work attempt would end with no terminal status at all (the resolution trace carries only the decisive child's terminal), stranding attempt records / Xray projections on an open attempt and then reaping it without cancellation.:tags {:actor-id <parent-instance-id> :invoke-id <prefix-path> :child-id <user-id> :spawned-id <gensym'd-id> :kind <:done | :failed> :done #{...} :failed #{...}}plus the child's reply-envelope facts (:rf.reply/work-id[:rf.work/machine <spawned-id> <invoke-id> <generation>],:rf.reply/work-kind :machine,:rf.reply/status:okfor a:donefold /:errorfor a:failedfold,:rf.reply/work-status, the emitted:rf.reply/correlation{:parent-id … :invoke-id … :child-id … :spawned-id …}— the fold's parent/invoke identity, logical child id, and spawned instance address — and the causal:rf.reply/completed-atwhen present). The decisive fold's terminal rides the resolution trace (:rf.machine.spawn-all/all-completed/*/some-completed/*/any-failed) instead — the two emits sit on opposite arms of the fold's:resolved?split, so each child attempt has exactly ONE terminal authority. Per 005 §:spawn-alljoin-child completion.:rf.machine.spawn-all/stale-completion— the exact-attempt / closed-attempt suppression. Before ANY fold, a completion carrier's exact-attempt coordinate must EQUAL THIS join attempt: the coordinate the runtime copied off the finishing child's[:rf/runtime :join-child]membership record must equal the current join's parent/invoke identity, logical child id, exact current actor id, AND the exact:rf/attempttoken minted for this seed. This is a fail-closed fence against accidents, not authentication (single-trust-domain; see 005 §Exact-attempt fold fence). A carrier that fails the fence folds NOTHING — a zero-mutation fail-closed drop — and this trace fires once carrying the:status :stale/:rf.reply/work-status :suppressedreply facts with a precise:rf.reply/stale-reason::rf.machine.spawn-all/attempt-unverified(no coordinate — a hand-authored dispatch that never came from a child's finality),:rf.machine.spawn-all/attempt-superseded(a coordinate bound to a PRIOR attempt / wrong actor after parent re-entry or child respawn — including a:fixed-actor-idrespawn where the actor id alone cannot discriminate attempts), or:rf.machine.spawn-all/duplicate-completion(an exact completion of an already-closed child, either already folded into:done/:failedor durably tombstoned in:cancelledby a membership-verified explicit teardown).:tags {:actor-id <parent-instance-id> :invoke-id <prefix-path> :child-id <user-id> :kind <:done | :failed>}plus the reply-envelope facts (:rf.reply/work-id,:rf.reply/work-kind :machine,:rf.reply/status :stale,:rf.reply/work-status :suppressed,:rf.reply/stale-reason,:rf.reply/correlation, and:rf.reply/completed-atwhen present). The exact-attempt fence runs BEFORE the resolved-vs-unresolved classification, so anattempt-unverified/attempt-supersededcarrier is suppressed by this op on BOTH sides of resolution — it never reaches the post-resolution path — whileduplicate-completioncatches an exact-current re-completion of an already-closed child in a still-live (unresolved) join. ONLY an exact-current straggler arriving after the:resolved?latch flipped keeps the separate:rf.machine.spawn-all/late-completiontrace (below,:rf.reply/stale-reason :rf.machine.spawn-all/join-resolved). Per 005 §:spawn-alljoin-child completion.:rf.machine.spawn/cancelled-on-join-resolution— fires once per surviving sibling unconditionally cancelled when a:spawn-alljoin condition resolves and siblings still in flight are torn down (per 005 §Cancel-on-decision).:tags {:actor-id <parent-instance-id> :invoke-id <prefix-path> :child-id <user-id> :spawned-id <gensym'd-id> :join-event <:on-all-complete | :on-some-complete | :on-any-failed>}. The parent's live INSTANCE address rides under:actor-idand the invocation path under:invoke-id;:spawned-idis the cancelled sibling's instance address. The trace fires per cancelled actor; observers needing one event per join resolution use:rf.machine.spawn-all/*-completed/*/any-failedinstead. The trace additionally carries the reply-envelope cancellation facts (cancellation as DATA — Managed-Effects §Cancellation)::rf.reply/work-id(the survivor's canonical machine work-id[:rf.work/machine <spawned-id> <invoke-id> <generation>]),:rf.reply/work-kind :machine,:rf.reply/status :cancelled,:rf.reply/work-status :cancelled,:rf.reply/cancelled? true, and:rf.reply/cancel-reason :on-join-resolution, so the survivor cancellation joins the same uniform work/reply row its spawn started.:rf.machine.spawn/stale-completion— the single-:spawnattempt fence. A single-:spawnchild's completion carrier —[:rf.machine.spawn/done …], or[:rf.machine.spawn/error …]from either failure trigger — is queued, and it carries the child's recorded:invoke-attempt. At the parent's handler boundary, before the:on-donefold and before the engine step, a carrier is delivered only while the:spawn-bearing state is still active AND the carried attempt equals the parent's current[:rf/runtime :spawn-attempts <invoke-id>]. Otherwise it is dropped with zero mutation — no:on-done, no:on-error, no ancestor or root:on— and this trace fires once.:tags {:actor-id <parent-instance-id> :invoke-id <invoke-id> :kind <:done | :error>}plus the reply-envelope facts:rf.reply/work-kind :machine,:rf.reply/status :stale,:rf.reply/work-status :suppressed,:rf.reply/stale-reason—:rf.machine.spawn/state-exited(the spawning state is no longer active) or:rf.machine.spawn/attempt-superseded(it was re-entered since, so a newer attempt owns it) —:rf.reply/correlation {:parent-id … :invoke-id … :attempt {:carried <n> :current <m>}}, and:rf.reply/completed-aton a done carrier when present. A carrier with no attempt (hand-dispatched) is not gated. Per 005 §Stale suppression.- There is no
:rf.machine.spawn/timed-outtrace::spawn/:spawn-allcarry no:timeout-msslot, and a:spawn-bearing state's wall-clock guard is its own:after. Observers wanting "this:spawn-bearing state's wall-clock guard fired" consume:rf.machine.timer/firedon that state's:afterentry. 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 event: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-blocked(a:can-leaverejection) and:rf.route/entry-denied(a terminal:can-enterrejection) each carry:tags {:requested-url <url> :rejecting-route <id> :rejecting-guard <sub-id> :cause <door> :phase <:can-leave|:can-enter> :frame <navigating-frame>}— the:framelets a multi-frame app filter a decision to the frame that caused it (and admits the trace into epoch capture / past the frame trace-disable gate), and the:phasetag is the route-phase taxonomy Xray's routing panel reads.:rf.route/entry-deniedfires at most once per navigation attempt.:rf.route/planned— the route-plan diagnostic projection, one per full-transition commit of the one navigation pipeline every door's handler runs (navigate*inrouting/url_change.cljc; per 012 §Resolved target and the plan diagnostic projection). Emitted just before the commit, so a drain reads:rf.route/planned→:rf.route.nav-token/allocated→:rf.route/deactivated→:rf.route/activated; the non-commit branches (an exact no-op, a fragment-only anchor change) commit no plan and emit none. Carries:tags {:cause <:link|:navigate|:popstate|:initial|:ssr> :route-id <id> :url <redacted-url> :param-keys [<keys>] :query-keys [<keys>] :branch [parent-most … leaf] :leaf-plan-ids [<event-ids>] :frame <navigating-frame>}, plus one CONDITIONAL tag::branch-error {:kind <:unknown-parent|:parent-cycle> :route-id* <offending id>}rides only when the:parentchain failed to resolve, and is absent on every healthy plan — it is what tells a tool that an empty:branchis a failed chain rather than a root route. Both of its slots are registration-time identifiers, so neither is a carrier; a:parent-cycle's:chainis deliberately left OFF the bus, because the walk builds it out of route-metadata maps and metadata on a trace tag is bulk no consumer branches on. Every door runs that same pipeline, so the:causeis what tells them apart::navigatefor the programmatic door, and:link/:popstate/:initial/:ssrfor the four URL-driven ones. The tag map is deliberately not a carrier: a trace tag is an egress surface the route's:sensitiveclassification cannot reach (that classification is lowered against runtime-db slice paths), so the:urlrides the same URL-carrier redaction as the route-miss diagnostics (path kept, query-string and#fragmentvalues redacted), the resolved:params/:querycontribute their key sets rather than their values, and the plan's source address is not carried at all. 012 owns the full carrier rationale and the rejected alternatives.:rf.route/prefetched— the single summary trace a warm-mode intent preload emits, one per[:rf.route/prefetch {address}]that clears both pre-planning gates (per 012 §Route-plan prefetch and 016 §Route-plan prefetch — warm-mode). Op-type:rf.event, emitted byre-frame.routing.prefetch(routing/prefetch.cljc). Carries:tags {:route-id <destination id> :warmed <count> :plan-error <true, when present> :frame <prefetching frame>}.:route-idis the destination as lowered through the ONEResolvedTargetseam every navigation door lowers to, so the warmed identity is the one an activation would commit (the route's:query-defaultsincluded).:warmedis the count of unique effective-plan requirements handed to Resources in warm mode —0when the Resources artefact is absent or the branch plan is empty, which is not an error (prefetch does not make Resources a mandatory routing dependency).:plan-error truerides only when the warm plan could not be built, and is a companion to, not a substitute for, the:rf.error/resource-route-plandiagnostic that fired with:plan-cause :prefetch.:framealways rides: the handler asserts its cascade frame stamp first, and a missing stamp throws the always-on:rf.error/no-frame-contextbefore any planning, so the summary always lands in the emitting frame's epoch. This is NOT an activation trace — warmup is not activation, so no:rf.route/planned,:rf.route.nav-token/allocated,:rf.route/deactivated, or:rf.route/activatedaccompanies it, and no:can-leave/:can-enter/:on-matchruns. It is also the op that does NOT fire on a rejected request: both pre-planning gates (the structural closed-:rf/route-addresscheck and the named-destination resolution check) emit:rf.error/prefetch-bad-addressand no summary trace at all, so a consumer counting preloads counts only preloads that planned. The tag map carries no URL and no:params/:query— not even their key sets (contrast:rf.route/plannedabove, which carries a redacted:urlplus both key sets): a preload's diagnostic value is which destination was warmed and how much, so the carrier question does not arise here. The warm plan's own rows ride the ordinary channels beneath it — one:rf.resource/route-planwith:plan-cause :prefetch, and a per-requirement:rf.resource/*ensure family with:cause [:route-prefetch <route-id>].:rf.route/registered/:rf.route/cleared/:rf.route/activated/:rf.route/deactivated— route lifecycle (per 012 §Trace events).: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 a removal (an explicitclear, or the hot-reload sweep forgetting a namespace's only registration of an id it stopped making),-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).: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>}—:rf.sub/query-vis RAW BY DESIGN and is never redacted at the classification chokepoint. A query vector is identity (therf=sub-cache key, the skip-dedup key, the reactive-graph edge endpoint), and a cache key is structurally public to every layer that touches the cache, so single-projection redaction could not contain it; a secret placed in a query argument is the documented positional fail-open one surface over, and it egresses raw here and on every other query-vector-bearing slot (:rf.sub/skip/:rf.sub/dispose, downstream:rf.sub/inputs/:rf.sub/cause-sub, the always-on error:query-v/:eventslots, epoch capture rows). Pass IDENTIFIERS, not secrets — see Spec 015 §A subscription's query vector is identity. (An off-box epoch projection keeps only the head id of a query vector in its:trace-events, per Security §Epoch privacy posture; that is hygiene on one carrier, not containment.) (The one narrower backstop is the SCHEMA axis: a:sensitive?-schema'd sub's validation-failure trace whole-slot scrubs:rf.sub/query-v— Spec 010.) 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; whole-output :large?elided at off-box egress:rf.sub/valueany the freshly-computed value. redacted at the classification chokepoint; whole-output :large?elided at off-box egress: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 declared 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 :inputslist for a:staticsub, the(input-fn query-v)result for a:parametricsub,[]for a layer-1 reader (per Spec 006 §Subscription input producers). The runtime counterpart to the staticsub-topology's:inputs :parametricsentinel: it surfaces the concrete parametric edges the static surface cannot enumerate, so the Xray live/run view renders realized parametric edges without fabricating un-materialized ones. Query-vectors (sub-id + args), not computed values — rides raw alongside:rf.sub/query-v/:rf.sub/cause-subfor the identity reason stated on:rf.sub/query-vabove; only computed-value slots are redacted at the classification chokepoint.plain :rf.sub/cause-event-idevent-id keyword (when in-run) the dispatching run's event-id — the head of the event vector that kicked off the in-flight drain ( (first event)). Names which event invalidated this sub's reactive input, so the Xray Epoch panel's SUBSCRIPTIONS section can credit each sub-run to the right epoch even when the physical reactive flush deferred into a chained sibling event's drain (the navigate → handle-url-change pattern). Absent outside a run (a post-settle reactive flush against no live drain). Sourced from epoch's in-flight run buffer (re-frame.epoch.capture/run-cause) — same source:rf.view/cause-event-iduses (per:rf.view/renderedabove). Mirrors the views-side attribution slot; the two together let consumers reconstruct cause→effect graphs across the run. Not wire-sensitive (an event-id keyword). The slot is attribution only: the physical reactive flush is batched at end-of-tick.plain :rf.sub/epoch-window[first-epoch-id last-epoch-id]on a run recorded after its cascade settled, the epoch layer stamps this onto the copy it files into the last-settled epoch ( last-epoch-id), naming the oldest and newest settled epochs since this sub's previous recorded run, any of which may have caused the recompute; it is absent on the live emit and on a run recorded inside its cascade.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 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 per-path:sensitivemark egresses BOTH slots as:rf/redacted, and a per-path:largemark substitutes the:rf.size/large-elidedmarker into the value at those paths. (A sub does not inherit its inputs' classification; classify the sub's own output path.) The framework[:rf/machine <id>]sub, which no app can classify, carries its machine's declared:sensitive/:large:dataclassification instead, since its value is that machine's snapshot (005 §Privacy). The WHOLE-OUTPUT{:large? true}registration marker is honoured one seam later, at off-box egress: the chokepoint stamps a bare:large?on the tags and DELIBERATELY leaves the value in place, because the on-box ring must keep the exact value (Xray's diff andrestore-epoch!read it). The off-box epoch projector — the:kind :rf/epoch-recordarm ofrf/project-egress, late-bound intore-frame.epochand never public in its own right — then substitutes the:rf.size/large-elidedmarker into:rf.sub/value/:rf.sub/prev-valueand strips the spent flag — through the same rule that elides the structured:sub-runsrow's:value/:prev-value, so the two egress copies of one sub value cannot drift. A marker, never a silent drop: a tool must be able to tell that a value existed and was withheld.:rf.egress/include-large? trueis the trusted-local opt-in that keeps the raw value in both. This axis is TOKEN BUDGET, not privacy: sub-output sensitivity is per-path and substituted at emit.:rf.sub/value-changed?is a plain boolean and is always observable. Why notelide-wire-valueat the emit site? The path walker reads the frame's runtime-db, whose instances its subsystem declarations apply to, by dereferencing a 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 production carries the same op-type vocabulary. 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).: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>}. A reactive-DAG view such as Xray's Reactive panel reads this from the epoch record's:trace-eventsto 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).: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 sub's definition moved under a live cache, so the cache evicted that sub-id's entries plus their transitive declared-input dependents. Three causes emit it: a :subre-registration and the hot-reload sweep deleting a:sub(per Spec 001 §Hot-reload semantics) each evict across every frame; a frame's resolved image generation changing evicts in that one frame, for the:subids the two generations differ on. Fires regardless of ref-count — the cached reaction holds the OLD or deleted body via closure and MUST be replaced or dropped.re-frame.subs.cache/invalidate-frame-subs!, reached throughinvalidate-sub-on-replace!(the registrar replacement hook, which a re-registration and the sweep's deletion both fire) and throughre-frame.live-frame/invalidate-subs-for-generation-change!:cache-clearAn explicit clear-sub-cache!call (test fixture, REPL teardown) walked the cache and disposed every slot. Per-slot emit; fires regardless of ref-count.re-frame.subs.cache/clear-sub-cache!:frame-destroyre-frame.frame/destroy-frame!tore down the destroyed frame's whole sub-cache as one of its ordered teardown steps (per Spec 002 §Destroy and Spec 006 §Disposal on frame destroy). Per-slot emit; fires regardless of ref-count; carries the destroyed:frame. Discriminates frame teardown from explicit test/REPL:cache-clear.re-frame.subs.cache/dispose-all-for-frame-destroy!(invoked fromdestroy-frame!via the:subs.cache/dispose-all-for-frame-destroy!late-bind hook)The emit is single-fire per actual eviction (gated on the CAS-winner check that already serialises
interop/dispose!), so a concurrent sync-dispose + invalidate race emits exactly one:rf.sub/disposefor the winning evictor — not two. Disposal is same-tick as the run that drove the unmount/condition-flip (per Spec 006 §Reference counting and disposal — synchronous on derefer-count → 0): the:rf.trace/dispatch-idrides via*handler-scope*for any eviction that fires inside a run's drain. Eviction fires that land OUTSIDE a run (an explicitclear-sub-cache!from a test) carry no:rf.trace/dispatch-id— the per-frame ring still anchors the event by:frameand consumers fall back to wall-clock ordering. The whole emit sits insideinterop/debug-enabled?so production CLJS bundles DCE it.Consumers: the Xray Epoch panel's SUBSCRIPTIONS section consumes
:rf.sub/disposeto surface the "disposed subs" branch alongside the per-epoch:rf.sub/create/:rf.sub/run/:rf.sub/skipevents — the full sub-lifecycle answer to "what happened to my subs this run?". Pair / Story consumers use:rf.sub/reasonto distinguish "last view unmounted" from "hot-reload" from "test teardown" without inferring from sequence. -
:rf.cofx/run— emitted byre-frame.cofxon the success branch of an ambient supplier that ran during context assembly — the supplier returned a value (and post-validation, if any, passed).:op-type :rf.cofx,:operation :rf.cofx/run. One event per ambient supplier that delivered during context assembly (recordable facts are delivered verbatim from the token and emit no run op). The emit rides inside the supplier'swith-handler-scopebinding, so its source-coord /:rf.trace/trigger-handler/:rf.trace/call-siteride the trace per §Handler-scope.:tags {:frame <id> :rf.cofx/id <cofx-id> :rf.cofx/value <produced-value> :rf.cofx/arg <requirement-arg> :rf.cofx/elapsed-ms <number>}.:rf.cofx/valueis the supplier's PRODUCED value — the coeffect that actually egresses into:coeffects— and is redacted at the classification chokepoint (re-frame.classification/project-cofx-run-tags) against the cofx's declared:sensitive/:largemarks (mirroring:rf.fx/handled's:rf.fx/argsredaction): the redaction is wired to the value that egresses, so a declared-sensitive produced value never surfaces in trace.:rf.cofx/argis the requirement-arg — present only for a parameterized[id arg]requirement (the supplier's(supplier arg)input, e.g. a localStorage key); it is omitted on the bare no-arg path. (The produced value is stamped under:rf.cofx/valueand the arg under:rf.cofx/arg, so the marks redaction — which targets:rf.cofx/value— acts on the produced value.):rf.cofx/elapsed-msis the dev-only wall-clock of the supplier invoke. The whole emit (tag-map +emit!) sits inside theinterop/debug-enabled?gate so production DCEs it. Distinct from:rf.cofx/skipped-on-platform(the platform-gate skip — the supplier did NOT run). :rf.cofx/generated— emitted by the recordable-generation step when a declared-absent generator-backed recordable fact's generator runs at processing-start to fill it (before the fold consumes it). Also emitted when a machine's sub-valued recordable source ({:rf/sub query-v :as fact-id}, Spec 005 §Consumer attachment) is evaluated live at ensure-time to fill its absent fact::rf.cofx/idcarries the:asfact-id and:rf.cofx/argis omitted, and since that fact has no cofx registration, its:rf.cofx/valueis projected by the sub's own classification — the value:rf.sub/runcarries for the same query — rather than by cofx marks. Epoch capture folds every:rf.cofx/generatedinto the record's replay token, which is how a fact produced mid-run reaches 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, or by the sub's classification for a sub-valued source),: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.:op-type :rf.view,:operation :rf.view/render.:tags {:frame <id> :rf.view/render-key [<view-id> <instance-token>]}. One event per render. The substrate-agnostic wrapper composes around every adapter's user render-fn, so this rides Reagent / UIx renders uniformly. Tools consuming render-count metrics subscribe to this op.-
:rf.view/rendered— emitted by the same wrapper AFTER the user render-fn returns (so the per-render deref sink is fully populated), carrying run-attribution + per-view ACTION/REASON data for Xray's Reactive / Views panels.:op-type :rf.view,:operation :rf.view/rendered. One event per render (capped — see below).:tags:tag shape meaning :rf.view/render-key[view-id instance-token]the rendering instance (parity with :rf.view/render).:rf.view/idkeyword the registered view id. :framekeyword the frame the render landed in. :rf.view/mount?booltrueon the instance's FIRST render,falseon every subsequent re-render — the mount-vs-rerender discriminator (keyed off:rf.view/render-key, which is stable across an instance's re-renders and fresh per new instance). Always present.:rf.view/deref-subs[[query-id args] …]the subscription query-vectors THIS view deref'd during the render — its OWN read-set (first-seen order, captured for EVERY deref incl. memo-hits). Absent when the view derefs no subs (a pure structural render). This is the precise PER-VIEW reactive reason; distinct from :rf.view/cause-subs(run-wide, over-reports — lists every sub that ran in the run regardless of whether this view reads it).:rf.view/render-args[arg …]the vector of POSITIONAL render args/props passed to THIS render (captured by the substrate-agnostic views.cljsframe-aware-view wrapper, so it rides Reagent / UIx uniformly). Absent on a no-arg render. 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.:rf.view/triggered-byquery-idthe SINGLE sub-id that caused THIS view to re-render — the first sub in :rf.view/deref-subs(the view's own read-set) whose value changed in the run (intersection resolved at emit time against the in-flight run buffer's value-changed:rf.sub/runset). The pre-computed per-view re-render cause Xray's Views panel shows directly (no consumer-side intersection needed). Absent on a structural re-render (none of the view's own subs changed value —← parent re-render) and outside a run. Narrower than:rf.view/deref-subs(full read-set, changed-or-not) and:rf.view/cause-subs(run-wide).: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).:rf.view/cause-event-idevent-id (when in-run) the dispatching run's :rf.event/run-startevent-id. Absent outside a run.:rf.view/cause-subs[query-id …](when in-run) distinct sub-ids that ran in the run, first-seen order, capped at 100. Absent outside a run. The per-view "reason" classifier (the consumer's by-elimination rule). A re-render is reactive when at least one sub in
:rf.view/deref-subschanged value this run (intersect:rf.view/deref-subswith the run's value-changed:rf.sub/runset) → show those subs. The runtime pre-computes the FIRST such sub as:rf.view/triggered-by, so a consumer can name the cause directly off the op without re-deriving the intersection. Otherwise the render is structural — the view re-rendered because its parent did (new props), with none of its own subs changed →:rf.view/triggered-byis absent → label it← parent re-render, UNNAMED (no component-tree / props-diff capture exists; the structural parent is never named).:rf.view/deref-subsis what makes the reason PER-VIEW rather than run-wide.:rf.view/render-argsprivacy. 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 classification policy — paths classified:sensitive(areg-classification/ subsystem declaration,:source :classification/ subsystem) inside an arg elide to:rf/redacted;:large-classified or over-threshold leaves elide to:rf.size/large-elided— BEFORE the event reaches any listener, the epoch-capture sink, or the AI/MCP wire. A frame with no declarations leaves the args reference-identical (no walk). This is the same emission site the epoch off-box record uses for:db-before/:db-after.Capped at 100
:rf.view/renderedper run with a one-shot:rf.view/rendered-cap-reachedmarker (:tags {:frame <id> :rf.view/dropped-after 100}) to bound the per-run buffer's heap budget for full-page re-render storms. The count is kept per frame on core's own run state, so the cap does not depend on epoch's capture buffer: a frame's count resets when a run starts in it, and renders after the run settles — React's post-settle batch, the common browser case — count against that run until the next run starts. 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:- Ratom family (Reagent). Rides the per-render-instance reaction-dispose mechanism (the same one
r/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). That substrate runs the
views.cljswrapper inside a function component with no tracked render reaction (it doesn't publish:adapter/make-reaction, so the reaction-dispose arm above no-ops), so the shared React-hook spine'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.
- Ratom family (Reagent). Rides the per-render-instance reaction-dispose mechanism (the same one
-
:error/:warning— universal severity discriminators for failure events. The category-specific identity lives in:operation(e.g.:rf.error/handler-exception); see §Error contract for the authoritative model. :info— informational advisories the runtime emits without warning or error severity (e.g.:rf.http/retry-attemptper 014 §Retry and backoff). Tools that filter for issues subscribe to:warning/:error; tools that surface activity timelines subscribe to:infoas well.-
Frame-exit machine teardown — single emit on the lifecycle channel. When a frame's destroy walks each surviving machine snapshot,
frame.cljcemits one trace event per destroyed machine instance on the unified lifecycle channel::op-type :rf.machine.lifecycle/destroyed,:operation :rf.machine.lifecycle/destroyed.:tags {:frame <id> :actor-id <live-instance-id> :last-state <state> :reason :parent-frame-destroyed}. The reaped actor's live INSTANCE address rides under:actor-id(:machine-idis reserved for the registered TYPE) — symmetric with the fx-substrate:rf.machine/destroyed. The:reasontag discriminates why the actor went away —:parent-frame-destroyedis the ONLY reason this channel carries (frame-exit reaping is its sole trigger); the fx-substrate's:rf.machine/destroyedemit sites (lifecycle_fx/finalize.cljc+lifecycle_fx/destroy.cljc) carry every non-frame-exit reason under the same:reasonslot (:rf.machine/finishedfor natural termination,:explicitfor cancellations — imperative destroy and parent-cascade teardown alike; see the matrix below). Tools that want "an actor instance went away" subscribe to BOTH destroy channels — this one carries only the frame-exit cause, so alone it under-reports every other teardown — and branch on:reasononly when they need cause-specific routing.The
:reasonenum — the canonical channel/reason matrix. Each reason belongs to exactly ONE channel: the registrar-substrate:rf.machine.lifecycle/destroyedcarries only the frame-exit cause; every non-frame-exit cause rides the fx-substrate:rf.machine/destroyed. (Spec-Schemas §:rf/trace-eventand 005 §Final states D6 restate this matrix; a machines-artefact conformance test (re-frame.destroyed-reason-channel-conformance-test) pins the three surfaces against each other and against the emit sites, so a reason added to one surface without the others goes red.):reasonChannel Emitted by Meaning :parent-frame-destroyed:rf.machine.lifecycle/destroyedlifecycle_fx/frame_destroy.cljc(machines-artefact orchestrator);frame.cljc(fallback when the machines artefact is absent)The actor's owning frame was destroyed; its snapshot was reaped as part of the frame-exit cascade. :rf.machine/finished:rf.machine/destroyedlifecycle_fx/finalize.cljcA spawned actor reached a :final?state and the runtime tore it down after firing the parent's:on-done. A singleton reaching:final?rests, so it never carries this reason.:explicit:rf.machine/destroyedlifecycle_fx/destroy.cljc(every other destroy shape)The actor was torn down before reaching a :final?leaf — an imperative[:rf.machine/destroy <id>]fx, a parent state-exit cascade (including the exit a view-unmount:canceldispatch drives), or a:spawn-allcancel-on-decision. The cancellation discriminator. For an authenticated current in-progress join child, its logical id is durably tombstoned in:cancelledbefore teardown callbacks / trace emission, closing the attempt against already-queued or delayed exact completions.The enum is open per §
:tagsis the open-ended bag; future causes are additive — but a new cause must be added to BOTH this matrix and the surfaces above, and assigned to exactly one channel (the conformance test enforces the co-edit).Two-channel teardown — what each channel sees. The runtime emits machine-destroy traces on two parallel channels with distinct purposes; the channel name carries the source-of-emit, the
:reasonslot carries the cause:Channel Source-of-emit What it observes Typical consumer :rf.machine.lifecycle/destroyedlifecycle_fx/frame_destroy.cljc(machines-artefact orchestrator);frame.cljc(no-machines fallback)The registrar-substrate observation: the actor's snapshot was reaped from the frame's runtime-db on frame exit. Fires ONLY from the frame-destroy cascade, one event per reaped instance, always with :reason :parent-frame-destroyed(the channel's sole reason)."Did a machine appear/disappear?" — observers building a live list of running actors. :rf.machine/destroyedlifecycle_fx/finalize.cljc+lifecycle_fx/destroy.cljcThe fx-substrate observation: a destroy fx ran on the spawn / destroy fx-id path. One event per fx-driven teardown. Does NOT fire for frame-exit reaping (that is registrar-substrate only). Causal-graph builders ("which fx caused this teardown?") — observers correlating fx emission against actor lifecycle. Because the channels are disjoint, tools that "just want did a machine disappear?" subscribe to BOTH and treat either as the disappearance — picking one channel silently drops the causes the other owns. Tools building causal graphs (Pair, Xray, Story) subscribe to both for the same reason. No extra disambiguator is needed, or emitted: the naming axis itself (
:rf.machine.lifecycle/*= registrar-substrate,:rf.machine/*= fx-substrate) carries the source-of-emit distinction, and the:reasonslot carries the cause. Since each reason belongs to exactly one channel,:operationand:reasonare mutually redundant on this pair — a consumer may branch on whichever it already has in hand, and a (channel, reason) tuple absent from the matrix above is malformed input, not a shape to render. The names follow 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). -
:rf.frame/drain-interrupted— lifecycle event emitted byrouter.cljcwhen an ordinary drain, at an event boundary (immediately before dequeue), observes that its exact frame incarnation has been claimed for destruction or is already dead/absent. A destroy requested while the drain is on the stack is a pending destroy: the event in flight settles as an ordinary event — its commit, flows, effects and:okepoch land — and the next boundary is where the drain stops, removing every envelope still queued in both lanes (the destroying event's own descendants included) before the frame's teardown runs. An immediate destroy's claim atomically removes the then-pending ordinary queue; an external ordinary dispatch that linearizes while the claimed incarnation remains lifecycle-live may enter its real queue, and the next exact-incarnation drain check removes it before handler, effects, or child dispatch. Dispatch after lifecycle-dead/absence is rejected/no-op with:rf.error/frame-destroyed. The destroy-owned private exact-token:on-destroycascade runs on its own router and is not counted. The trace is emitted only when a drain observes the claim — always, for a pending destroy; an immediate destroy before any drain begins is quiet — and:dropped-countis the combined total of events removed at claim time plus events removed by that check.:op-type :rf.frame(the frame-lifecycle family — see:rf.frame/created/:rf.frame/destroyedsiblings; not:op-type :rf.event, which is reserved for "an event was dispatched").:tags {:frame <id> :dropped-count <int>}. Per 002 §Edge cases worth pinning. -
:rf.epoch/snapshotted/:rf.epoch/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). The same trace carries the consumer-facing summary:summaryenum (:ok/:blocked/:error) — the coarse three-tier projection of that cause, which the Trace panel's epoch row (tools/xray/spec/023-Trace-Panel.md §13) reads directly. It is the one trace per committed epoch: tools that want the cause read its:outcome, tools that want the summary read its:summary.-restoredfires after a successfulrestore-epoch!;-db-replacedfires after a successful pair-tool injection (replace-frame-state!, the ONE partial-map mutator — the pair-tool write surface, see Tool-Pair §Pair-tool writes). Per Tool-Pair §Time-travel.:tags {:frame <id> :rf.epoch/id <id> :rf.trace/event-id <id>? :outcome <enum>? :summary <enum>?}(:outcomeand:summaryare required on-snapshottedand absent on the other two).The consumer-facing
:summarymapping (pinned inimplementation/core/test/re_frame/epoch_test.cljoutcome-enum-projection-pins-mapping; load-bearing — devtools and trace-stream consumers depend on it)::outcome(cause):summaryRationale :ok:okThe drain ran to completion without halting. A throw inside it — a handler, an effect — is reported by its own error record, not by this outcome. :halted-depth:blockedThe drain hit the configured depth limit; the halting event never ran. A drain-shape stop, not an error. :halted-destroy:blockedA destroy dropped queued work at an event boundary; that work never ran. A deliberate lifecycle stop — the destroying event itself settled :ok.: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, and the work still queued at the destroying event's boundary was dropped) 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). Both halt at an event boundary and mark work that never ran, and neither involves a thrown exception; surfacing both as:blockeddistinguishes them from genuine errors that consumers want to flag.
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 runtime-db) |
:rf.machine.spawn/spawned (the :rf.machine/spawn fx ran) |
| Actor destroyed | :rf.machine.lifecycle/destroyed (frame-exit reaping ONLY — always :reason :parent-frame-destroyed) |
:rf.machine/destroyed (a destroy fx ran; every non-frame-exit reason; does NOT fire for frame-exit reaping) |
Which axis to subscribe to. The two moments differ, and the difference is load-bearing:
- Spawn is symmetric. Both axes fire for every spawn — the same instant observed twice, once as "the spawn fx ran" (
:rf.machine.spawn/spawned) and once as "the actor's snapshot landed in runtime-db" (:rf.machine.lifecycle/spawned). A tool that just wants "did an actor appear?" picks either axis. - Destroy is disjoint. The two destroy channels partition the teardown causes between them (see the channel/reason matrix above): frame-exit reaping is registrar-substrate only, every other teardown is fx-substrate only. Neither channel alone is a complete record of actors going away. A tool that wants "did an actor disappear?" must subscribe to BOTH
:rf.machine.lifecycle/destroyedand:rf.machine/destroyed— a lifecycle-only observer misses every normal teardown (:explicit,:rf.machine/finished); an fx-only observer misses every frame-exit reap.
A tool building a causal graph ("which fx caused this spawn / teardown?") subscribes to both axes on both moments and correlates by :spawned-id (spawn) / :actor-id (destroy) plus the cascade :rf.trace/dispatch-id. Because the destroy channels are disjoint there is no de-duplication to do on teardown: an actor's disappearance produces exactly ONE destroy event, and which channel carried it already tells you the cause family. On spawn — where the axes genuinely do double-report — correlate the pair by :spawned-id rather than counting events.
The two-substrate naming. The names align with how the rest of the spec namespaces the two substrates: :rf.machine.lifecycle/* is the registrar-lifecycle family; :rf.machine.spawn/* / :rf.machine/* are the fx-substrate family. See 005 §Spawn lifecycle — ordering for where each emit sits in the spawn cascade.
History trace events (:rf.machine.history/*)¶
History pseudo-states (per 005 §History states) record a compound's last-active configuration on exit and restore it on re-entry. Two trace events make the record/restore observable so tooling renders why a re-entry landed where it did rather than only {from}→{to}. They live under the reserved :rf.machine.history/* sub-family of :rf.machine/* (reserved in Conventions §Reserved namespaces, consistent with the :rf.machine.lifecycle/* / :rf.machine.timer/* / :rf.machine.event/* / :rf.machine.microstep/* carve-outs and with the reserved-:rf/* exemption rationale). Both are machine-activity traces (:op-type :rf.machine), NOT severity discriminators — a history restore/record is benign observability, never an issue, so it never washes a cascade pink nor enters an issues ribbon (same posture as :rf.machine.event/unhandled-no-op).
Both events are EDN-clean — every payload slot is keywords and vectors-of-keywords only (the :rf/history slot's own shape per Spec-Schemas §:rf/machine-snapshot), so they round-trip through pr-str / read-string and survive SSR serialisation with no elision concern. The configs are small (state paths), so there is no large-payload leak and no per-slot redaction concern beyond the machine-declared :sensitive / :large :data classification 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; under :type :parallel, the leaf path inside the region) 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 (under :type :parallel, the leaf path inside the region) — 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 also 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, which under :type :parallel is the leaf path inside the region (:compound-path carries the region name, the value does not); for a shallow compound, the recorded direct-child keyword. Reads =-equal to what a later :rf.machine.history/restored for the same :compound-path reports as :restored-config. |
:prev-config |
[keyword …] | keyword | absent |
the value previously stored at :compound-path (overwritten by this write); absent on the first-ever recording for the compound (the slot was previously unallocated). Lets a consumer show "history advanced from X to Y" without re-folding the trace stream. |
A compound that owns no history pseudo-state records nothing and emits no :rf.machine.history/recorded (the runtime only writes the slot for history-bearing compounds). The -recorded event pairs naturally with the headline :rf.machine/transition of the macrostep that exited the compound — they share the cascade's :rf.trace/dispatch-id — so a consumer correlates "this transition exited :player, and here is what got recorded" without threading.
No error for a dangling recorded path. A recorded configuration that a hot-reloaded definition later invalidated is a benign, expected consequence of hot reload, not a grammar violation (per 005 §Dangling recorded paths after hot reload) — no :rf.error/* is raised. It is observable purely as a :source :default (with :fallback) on the next :rf.machine.history/restored for that compound; the malformed-grammar :rf.error/machine-history-* catalogue (see §Error event catalogue) is registration-time only and never fires for a dangling-at-runtime recording.
:tags is the open-ended bag¶
Variable per-event data goes in :tags. New tags can be added without breaking consumers. Use :tags for op-type-specific data; reserve top-level keys for fields universal across all events.
Every framework-owned top-level :tags key is namespaced under its domino family or — for the cross-cutting correlation spine — under :rf.trace/*, per the single-root convention (Conventions §Reserved namespaces). The one deliberate carve-out is :frame (see the canonical-routing note below). The full key scheme:
:tags key |
Family | Notes |
|---|---|---|
:frame |
universal routing | Bare carve-out (see below) |
:rf.trace/dispatch-id, :rf.trace/parent-dispatch-id, :rf.trace/event-id, :rf.trace/trace-id, :rf.trace/phase |
cross-cutting correlation | Stamped across every domino family — the trace channel's own correlation spine; no single domino home, so they live under :rf.trace/* (per Conventions §:rf.trace/*). |
:rf.event/v (the dispatched event vector), :rf.event/origin, :rf.event/sync?, :rf.event/fx, :rf.event/db-present?, :rf.event/db, :rf.event/coeffects, :rf.event/cofx, :rf.event/fx-overrides, :rf.event/interceptor-overrides, :rf.event/elapsed-ms, :rf.event/after-deltas |
event | The closed-enum functional-origin discriminator rides on the bare :source tag (a bare carve-out under the trigger-kind axis). :rf.event/elapsed-ms is the HANDLER-BODY-only wall-clock on :rf.event/run-end. :rf.event/after-deltas is the dev-only per-user-:after context diff on the same :rf.event/run-end emit, read through generic trace inspection: a vector with one record per user-registered :after interceptor that changed the context (framework defaults skipped; the tag is absent when none did), each carrying exactly the two :rf.interceptor.delta/* keys — :rf.interceptor.delta/id (the interceptor's id) and :rf.interceptor.delta/ctx-delta (an EDN diff {:coeffects … :effects …} whose :added / :changed {:before :after} / :removed entries carry the values), per Conventions. Its :db values take the same classification as :rf.event/db, walked against the frame's classification policy at the chokepoint — each at its TRUE app-db focus, since under a :rf.interceptor/path focus a value is the slice at that path, and a focus that cannot be determined fails closed to :rf/redacted on a classified frame — while its :fx values redact through each fx-id's registration, as :rf.event/fx does. :rf.event/db is the FULL :db value stamped on the :rf.event/db-pending (t1) and :rf.event/db-pending-post-flow (t2) trace events; PDS structural sharing keeps the cost pointer-sized, the day8/de-dupe wire layer collapses repeated subtrees on egress. :rf.event/cofx is the dev-only POST-GENERATION flat :rf.cofx replay token on the :rf.event/run-start emit — the causal cofx map as it was AFTER the router's declared-only delivery ran (generator-backed recordable facts minted at processing-start written back, plus the framework :rf/time-ms); the epoch surface pins it as the record's :rf.cofx replay slot. Each fact value is redacted against the cofx-id's declared :sensitive / :large marks at the chokepoint (re-frame.classification/project-trace-event, the same per-cofx-id rule :rf.event/coeffects uses) before any off-box egress. :rf.event/fx-overrides / :rf.event/interceptor-overrides are the dev-only envelope override captures on the SAME :rf.event/run-start emit — the envelope's own per-call (+ lexical, for :fx-overrides) :fx-overrides / :interceptor-overrides (never the per-frame tier), marker-ized (:rf/fn-override) for a fn-valued :fx-overrides entry at the emission site; the epoch surface pins them as the record's bare :fx-overrides / :interceptor-overrides replay slots beside :rf.cofx (per Spec-Schemas §:rf/epoch-record and Tool-Pair §Replay). :rf.event/fx — the handler's WHOLE returned effect vector, stamped on the :rf.fx/do-fx trace — is registration-classified at egress: each [fx-id args] entry's args are redacted through THAT fx-id's reg-fx :sensitive / :large at the chokepoint (re-frame.classification/project-trace-event), mirroring the sibling :rf.event/db walk. |
: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 carries the view's positional render args/props — arbitrary user data, so it is elided at emit time through the same chokepoint as :rf.event/db before delivery. |
:rf.fx/id, :rf.fx/args, :rf.fx/from, :rf.fx/to, :rf.fx/platform, :rf.fx/registered-platforms, :rf.fx/elapsed-ms |
fx | :rf.fx/elapsed-ms is the per-fx-handler-invoke wall-clock on :rf.fx/handled. :rf.fx/args is registration-classified at egress against the fx's reg-fx :sensitive / :large on every slot that stamps the [:rf.fx/id :rf.fx/args] pair — keyed off the slot SHAPE, not op :rf.fx/handled — so the per-effect :rf.fx/handled success trace AND the always-on fx error traces (:rf.error/fx-handler-exception + siblings) and :rf.fx/skipped-on-platform all redact through the same declaration at the chokepoint (re-frame.classification/project-trace-event); an unregistered fx-id (:rf.error/no-such-fx) has no registration to read and is the documented fail-open. |
:rf.interceptor/override-summary |
interceptor | Dev-only, on the :rf.event/run-start trace emit (rides the dev-only trace stream, NOT the always-on flat event-emit record — see the substrate note under §Emit-gate summary). Present ONLY when this dispatch's merged per-frame + per-call :interceptor-overrides actually acted on the resolved chain (omitted entirely on the override-free hot path). The value is strictly id/count-only — a map {:matched [<ref-id>…] :replaced [<ref-id>…] :removed [<ref-id>…] :count N} where each <ref-id> is an authored interceptor reference (a bare keyword id or an [id arg] 2-vector head id). It carries NO interceptor values, executable maps, fns, raw factory args, or raw replacement values: a parameterized ref is EDN-serializable but its arg is not proven privacy-safe, so this surface egresses ids/counts only, enforced fail-closed at the classification chokepoint (re-frame.classification/project-trace-event reduces an [id arg] ref to its head id and collapses any non-ref payload to :rf/redacted). :matched is the union of :replaced (override with a ref replacement) and :removed (override with a nil replacement); :count is (count :matched). Rides the dev-only trace stream — see the substrate note below. |
:rf.epoch/id |
epoch | |
:rf.cofx/id, :rf.cofx/value, :rf.cofx/arg, :rf.cofx/elapsed-ms |
cofx | On :rf.cofx/run: :rf.cofx/value is the supplier's PRODUCED value (redacted by marks — see the op above), :rf.cofx/arg is the requirement-arg of a parameterized [id arg] requirement (omitted on the bare path), :rf.cofx/elapsed-ms is the per-supplier-invoke wall-clock. The :rf.cofx/generated op reuses :rf.cofx/id for the generated fact's name + supplier id, :rf.cofx/value for the produced value (redacted by the same classification chokepoint), and :rf.cofx/arg for a parameterized requirement-arg. |
Nested record-map keys are NOT renamed. Where a tag value is a structured map or vector-of-maps (e.g. :rf.interceptor/override-summary's {:matched […] :replaced […] :removed […] :count N} map), the inner keys are internal value shape, not top-level :tags keys — they carry no collision surface and stay as-is. The namespace scheme governs top-level :tags keys only.
Canonical per-frame routing key — the deliberate bare carve-out. Every trace event that names a frame uses :frame under :tags. The framework MUST NOT emit :frame-id as a tag key — :frame is the single canonical name; ports that re-emit must follow suit. The raw trace-event shape carries :frame only at [:tags :frame] — there is no public top-level :frame on a raw event (that top-level slot belongs to the projection layer — event bundles, :rf/epoch-records, dispatch consequences, cursor / summary records — per §Frame identity on the raw event and Tool-Pair §Identity spellings). Consumers read a raw event's frame through the one canonical accessor the trace contract owns — re-frame.trace/trace-event-frame (alias frame-of), implemented as (get-in ev [:tags :frame]) — not by hardcoding the path (and not via a dual (or (get-in ev [:tags :frame]) (:frame ev)) read). :frame is the one deliberate exception to the "every framework tag is namespaced" rule above: it is the single universal routing tag stamped on every frame-qualified event, carries zero collision risk in practice, and is the one key every tool special-cases — so it is bare rather than :rf/frame.
Open shape; new fields are additive¶
The map is open. New fields can be added by future versions without breaking consumers — listeners read what they understand and ignore the rest. The forward-compat commitments:
- Required top-level fields (
:id,:operation,:op-type,:time,:tags) are stable. Removing or renaming any is a breaking change. - Re-frame2 additions hoisted to top level (
:source,:recovery) are stable once shipped; they are present on every event whose tags carry them. - Op-type-specific fields inside
:tagsare stable within their op-type — including:frame, which every emit site supplies under:tags. New optional tag keys are additive; existing keys don't change shape. - New
:op-typevalues can be added without breaking existing tools — tools filter the values they recognise.
Canonical per-event trace sequence¶
A single event's run emits a canonical, ordered trace sequence. The ordering is contract — off-box monitors, Xray's Trace panel, Story, and conformance recorders rely on it to place each phase relative to the others. A conformant port MUST emit (the phases that fire for a given event; omit those whose condition is unmet) in this order:
:rf.event/dispatched ;; (envelope queued; one per dispatch — may precede the drain)
:rf.error/schema-validation-failure ;; ONLY when the event-vector schema
;; (010 §Validation order step 1) refuses the
;; payload — :where :event. The check runs before
;; cofx delivery, so a refused event runs no
;; supplier or generator (the refusal variant below).
:rf.cofx/run ;; per coeffect supplier that ran to success during
;; context assembly (carries :rf.cofx/id + value +
;; :rf.cofx/elapsed-ms). (:rf.cofx/generated, for the
;; recordable-generation step, also fires here.)
:rf.event/run-start ;; the handler's interceptor chain begins; carries
;; the post-delivery :rf.cofx as :rf.event/cofx
… 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). Fires
;; ONLY when the handler returned a :db slot;
;; suppressed otherwise.
:rf.flow/computed | :rf.flow/skip | :rf.flow/failed ;; the OUTERMOST :after —
;; the flow transform, per flow, in topological order.
;; Fires after the rest of the :after chain (so it
;; sees the fully-reshaped :db effect) and BEFORE
;; :rf.event/db-changed (install).
:rf.event/db-pending-post-flow ;; t2 — the post-flow-transform / pre-
;; commit pending :db. Carries the FULL flow-
;; augmented value under :tags :rf.event/db. Fires
;; ONLY when flows actually transformed the value
;; (the substrate's identical?-by-reference guard);
;; omitted otherwise (t1 == t2 carries no info).
:rf.event/db-changed ;; the FLOW-AUGMENTED :db installs into app-db (the
;; single deferred commit — the atomic boundary).
;; APP-DB-ONLY: fires only when the
;; app-db partition changed; never fires for a
;; runtime-only commit.
:rf.event/db-noop ;; OR (complement of db-changed) — a :db effect was
;; present but app-db did NOT change (handler returned
;; an unchanged db; identical?-noop skipped the write).
;; APP-DB-ONLY. For a :db-bearing commit EXACTLY ONE of
;; db-changed / db-noop fires; neither fires for an
;; :fx-only / runtime-only commit (no :db effect).
:rf.event/frame-state-changed ;; fires when EITHER partition changed (the frame-
;; level signal). Carries :tags :rf.event/partitions
;; — a set drawn from #{:app-db :runtime-db} naming
;; which partition(s) this commit touched. A runtime-
;; only commit emits THIS (with #{:runtime-db}) and
;; NOT :rf.event/db-changed; an app-only commit emits
;; both (db-changed + frame-state-changed #{:app-db}).
:rf.sub/run | :rf.sub/skip ;; sub-cache recompute on the new (flow-augmented) frame-state
:rf.fx/handled ;; per :fx entry (reads the flow-augmented app-db)
:rf.fx/do-fx ;; terminating :fx-walk marker — fires AFTER the per-fx
;; :rf.fx/handled entries (carries the :fx-vector +
;; :db-present? shape for the Event lens)
:rf.flow/computed | :rf.flow/skip | :rf.flow/failed ;; the SETTLE — ONLY when the
;; :fx walk changed the frame's state: the flow pass
;; again, over the post-walk frame-state, inside the
;; same event (013 §Sequencing).
:rf.event/db-changed / :rf.event/frame-state-changed ;; the settle's install —
;; ONLY when the settle pass changed app-db.
:rf.view/render | :rf.view/rendered ;; reactive re-render on the new db
:rf.event/run-end ;; run-tail: fires LAST, after the deferred :db
;; install and the :fx walk (router emits it in
;; emit-cascade-trailers!, after commit-and-flow!).
;; Carries :rf.event/elapsed-ms — the HANDLER-BODY-only
;; wall-clock (the interceptor chain, NOT the whole
;; run) for the Trace panel's DURATION column.
The :rf.event/db-pending / :rf.event/db-pending-post-flow pair (t1 / t2). Two trace events bracket the flow transform. t1 (:rf.event/db-pending) fires inside the framework's outermost :after (flows-after-interceptor) BEFORE running flows, when the handler returned a :db slot; t2 (:rf.event/db-pending-post-flow) fires inside the same interceptor AFTER running flows, when the flow transform actually changed the pending value ((not (identical? new-db pending-db))). Both carry the FULL :db value under :tags :rf.event/db — same payload-slot posture as :rf.event/fx on :rf.fx/do-fx: 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 classification policy — the SAME site the epoch arm of rf/project-egress uses for :db-before / :db-after — so declared sensitive paths egress as :rf/redacted and declared large paths get the :rf.size/large-elided marker; the walk is gated on the frame carrying declarations, so a frame with no marks keeps the copy-free reference-identity the slot promises. Consumers (Xray's Handler panel, re-frame2-pair's cascade-of) read t1 to render the handler's returned :db value and read (t1, t2) together to render the t1→t2 flow reshape — the framework does NOT precompute a diff (the values are full both ends, modulo redaction; client-side diff is cheap).
t1 fires when the handler returned :db, regardless of whether the flows artefact is loaded (apps that never registered a flow still get t1). t2 is by definition impossible without the flows artefact (no flow could have transformed the pending value). On a flow-throw abort (Spec 013 §Failure semantics), t1 still fires (it ran before the throw) but t2 does NOT (the run aborted, the pending :db was discarded with no install — mirrors the absence of :rf.event/db-changed). Both emits sit inside the shared interop/debug-enabled? gate so production CLJS bundles DCE them along with the rest of the trace surface.
The flow position is load-bearing. :rf.flow/computed is emitted after the handler's :after chain (the flow transform is the outermost :after, so it fires after the rest of the chain reshapes the :db effect) and before :rf.event/db-changed. :rf.event/db-changed reflects the flow-augmented db — the value installed already carries every flow's output. A consumer placing flows on the run timeline reads :rf.flow/computed between :rf.event/run-start and :rf.event/db-changed; the flow's write is visible in the :rf.event/db-changed snapshot, not applied after it.
:rf.event/run-end is a run-tail trace: the router emits it in emit-cascade-trailers! after commit-and-flow! has run, so it falls after the deferred :db install (:rf.event/db-changed) and after the :fx walk — it is the last trace of a clean run. (It is not emitted at the close of the interceptor chain; the chain completes inside run-chain, the install and :fx walk follow in commit-and-flow!, and only then does the trailer fire.) The relative order that consumers depend on is :rf.flow/computed → :rf.event/db-changed → :rf.fx/handled.
:rf.event/run-end's :rf.event/elapsed-ms is the HANDLER-BODY duration, not the run duration. Although the trace fires at run tail, the :rf.event/elapsed-ms tag it carries is measured around the interceptor chain only (run-chain, captured before commit-and-flow!) — so the Trace panel's DURATION column shows the time the handler body itself took, distinct from the whole run-start → run-end wall-clock (which also covers the :db install, the flow walk, the :fx walk, and the resulting sub recomputes + view re-renders). A consumer wanting the whole-run latency reads it off the always-on event-emit record's :elapsed-ms (per §Event-emit listener) or subtracts the run-start / run-end :time stamps; :rf.event/elapsed-ms answers the narrower "how long did the handler take?" question the per-op DURATION column poses. The tag rides interop/debug-enabled? so production DCEs it.
Throw variant — the event aborts at the commit boundary. A flow throw is a pre-install throw, so the event aborts before the :db install (the atomicity contract — per 013 §Failure semantics and 002 §Drain-loop pseudocode). The canonical sequence truncates: the :rf.flow/* phase ends at :rf.flow/failed, the router emits the run-level :rf.error/flow-eval-exception, and the run STOPS — NO :rf.event/db-changed, no :rf.sub/run, no :rf.fx/*:
:rf.event/run-start
… handler body + the rest of the :after chain run …
:rf.event/db-pending ;; t1 — STILL fires when the
;; handler returned :db; it ran BEFORE the
;; throw. The trace records what the
;; handler tried to write; the install
;; never happened.
:rf.flow/computed ;; per prior flow that ran (its WRITE is
;; discarded — the trace records the run only)
:rf.flow/failed ;; the throwing flow
:rf.error/flow-eval-exception ;; run-level error (always-on substrate)
:rf.event/run-end ;; run-tail — fires LAST (the trailer is
;; emitted unconditionally after the aborted
;; commit, with :outcome :flow-error)
;; — NO :rf.event/db-pending-post-flow, NO :rf.event/db-changed, NO
;; :rf.sub/run, NO :rf.fx/* (the event aborted before the deferred :db
;; install — t2 fires only on a successful post-flow path) —
:rf.event/db-changed does NOT fire because the pending :db effect was discarded (no install, app-db unchanged, no partial commit); :rf.fx/handled does NOT fire because :fx is the post-install stage and the event aborted before it. This is the same truncated signature every other pre-install throw produces — a handler throw or an interceptor-:after throw emits :rf.error/handler-exception and then the :rf.event/run-end run-tail trailer, with no :rf.event/db-changed and no :rf.fx/handled. (As on the clean path, :rf.event/run-end is the last trace — the error event precedes the trailer.)
Schema-rejection variant — the candidate is rejected at the commit boundary. Candidate app-db / machine-data schema validation runs over the COMPLETE candidate frame transition BEFORE the single install. On a failure the candidate is REJECTED — never installed — and the canonical sequence truncates at the commit boundary. This whole variant is a development-build sequence: the candidate validator is dev-only per 010 §Production builds, so a release build never produces it — the violating candidate installs and the dispatch follows the clean path with :outcome :ok.
:rf.event/run-start
… handler body + the rest of the :after chain run …
:rf.event/db-pending ;; t1 — the handler returned :db
:rf.flow/computed ;; per flow that ran (its write rides the
;; candidate, which is now discarded)
:rf.event/db-pending-post-flow ;; t2 — when flows transformed the value
:rf.error/schema-validation-failure ;; one per failing entry, :rollback? true
;; (= transaction REJECTED; every violation
;; surfaces — no first-failure short-circuit)
:rf.event/run-end ;; run-tail — fires LAST, with the always-on
;; event-emit record's :outcome :rolled-back
;; (dev builds only — the validator that
;; produces this outcome is elided in
;; production; see above)
;; — NO :rf.event/db-changed, NO :rf.event/db-noop, NO
;; :rf.event/frame-state-changed, NO :rf.sub/run, NO :rf.fx/* — the
;; candidate never installed; there is no :rf.trace/phase :rollback
;; re-emit —
The rejection differs from the flow-throw variant only in its ERROR op (:rf.error/schema-validation-failure — the flow/handler computed cleanly, the VALUE failed its declared shape — vs :rf.error/flow-eval-exception); both truncate with ZERO change traces. Synchronous observers can never see the invalid candidate: a trace listener reading the frame during the failure emit reads the PRE-handler value, a container watch receives zero callbacks, and no substrate subscriber (Reagent reaction, useSyncExternalStore snapshot) is notified — the container is simply never written.
Refusal variant — the event-vector schema refuses the payload. The check is step 1 of 010 §Validation order on event processing, before cofx delivery, so its report precedes :rf.event/run-start and no coeffect supplier or generator runs for the refused payload. A :boundary? true handler's :rf.error/schemas-artefact-missing throw sits at the same point. The chain still runs with the handler skipped, so every entered interceptor's :after runs, but a refused event installs nothing those interceptors staged and walks no :fx — in every build, :boundary? true handlers included (010 §Per-step recovery row 1). A chain throw, a flow throw or a malformed effect map during that unwind still reports, ahead of :rejected.
:rf.error/schema-validation-failure ;; :where :event — the dev diagnosis
;; (elided in production)
:rf.event/run-start ;; :rf.event/cofx is the envelope's own
;; :rf.cofx — nothing was delivered
… the chain runs with the handler skipped …
:rf.event/run-end ;; run-tail — fires LAST. The event-emit
;; record's :outcome is :rejected for a
;; :boundary? true handler, whose always-on
;; :rf.error/schema-validation-failure
;; record (:source :boundary) fans just
;; before this trailer; :ok for an
;; unguarded handler's dev-only refusal
;; — NO :rf.cofx/*, NO :rf.event/db-changed, NO :rf.event/db-noop, NO
;; :rf.event/frame-state-changed, NO :rf.sub/run, NO :rf.fx/* — nothing
;; the unwind staged installs —
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 from :dispatched to :run-end:
:operation |
Substrate | Gate — fires when |
|---|---|---|
:rf.event/dispatched |
always-on | every dispatch envelope (precedes the drain; one per dispatch) |
:rf.cofx/run |
dev-only | per coeffect supplier that ran to success during context assembly |
:rf.cofx/generated |
dev-only | a recordable generator ran at processing-start to fill a declared-absent fact; carries fact-name + supplier id + the produced value |
:rf.event/run-start |
always-on | every event the handler chain begins on |
: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 evaluation threw; carries :phase — :derive (the authored fn threw) or :output-write (it returned and the framework's install of that value threw) |
:rf.event/db-pending-post-flow (t2) |
dev-only | flows transformed the pending value ((not (identical? new-db pending-db))); suppressed when t1 == t2 |
:rf.event/db-changed |
dev-only | the flow-augmented :db installed into app-db — APP-DB-ONLY; never fires for a runtime-only commit |
:rf.event/db-noop |
dev-only | a :db effect was present but app-db did NOT change (handler returned an unchanged db; identical?-noop skipped the write) — APP-DB-ONLY; the complement of db-changed (exactly one fires for a :db-bearing commit) |
:rf.event/frame-state-changed |
dev-only | EITHER partition changed; carries :tags :rf.event/partitions (a subset of #{:app-db :runtime-db}) naming which partition(s) the commit touched |
:rf.sub/run / :rf.sub/skip |
dev-only | per sub on the cache that depends on a changed input |
:rf.fx/handled |
dev-only | per :fx entry, after the deferred install |
:rf.fx/do-fx |
dev-only | terminating :fx-walk marker, after all per-entry :rf.fx/handled emits |
:rf.view/render / :rf.view/rendered |
dev-only | per reactive view re-rendered on the new db |
:rf.error/* (run-level) |
always-on | every run error — :rf.error/handler-exception / :rf.error/coeffect-exception / :rf.error/interceptor-exception / :rf.error/flow-eval-exception / fx errors (each attributed to its true failing component) |
:rf.event/run-end |
always-on | run-tail; fires LAST after commit-and-flow!, on both clean and aborted paths |
Always-on emits ride the production-available event-emit / error-emit channels per §Production debugging: they survive :advanced DCE and feed the production listener surfaces (event-emit, error-emit, error-projection). Wire-egress to off-box monitors (Sentry / Rollbar / etc.) only sees these.
Dev-only emits are wrapped in the interop/debug-enabled? gate per §Production builds: zero overhead, zero code; they vanish entirely from :advanced bundles. Xray / Story / re-frame-10x consume them in dev builds where the gate is true. A production :advanced build emits exactly the always-on rows above and nothing else.
The always-on label on :rf.event/run-start / :rf.event/run-end names the flat event-emit RECORD, not the trace event's :tags. Two distinct substrates carry run-start/run-end information. The always-on event-emit substrate fans out one fixed-shape, flat record per processed event ({:event :event-id :frame :time :outcome :elapsed-ms}, per §What IS available in production) — that record is NOT a :tags-bearing trace event and is NOT extensible by adding a tag. The separate dev-only trace-stream emit (re-frame.trace/emit! :rf.event :rf.event/run-start {…tags…}) carries the :tags bag documented in §:tags is the open-ended bag and rides the interop/debug-enabled? gate inside emit!, so the whole emit — and every tag on it — DCEs in :advanced. Consequently a :tags key added to the run-start trace emit (e.g. :rf.interceptor/override-summary) is dev-only automatically: it never reaches the always-on flat record and never survives production elision. The always-on label gates the flat record's firing condition; it does not promote the trace event's tags to production.
Flow trace events¶
Five trace events constitute the flow lifecycle stream (per 013 §Flow tracing). All five carry :op-type :flow; consumers filter by :op-type to subscribe to the whole stream and branch on :operation to discriminate. Every event's :tags carries :flow-id, and every evaluation event :frame as well, so tools can attribute and route per-frame; the two registration events (:rf.flow/registered, :rf.flow/cleared) carry no :frame, because a registration names none.
:operation |
When it fires | :tags payload (in addition to :flow-id and :frame) |
|---|---|---|
:rf.flow/registered |
After reg-flow registers a flow definition in the registrar for the first time; a re-registration rides :rf.registry/handler-replaced. Activation checks run when a frame activates the flow, not here (per 013 §Activation). |
: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), :repaired? true (present only when that visit wrote the flow's remembered output back; a plain skip carries no :repaired? key). |
:rf.flow/cleared |
After (rf/clear :flow id) removes a flow's registration. Each frame that runs the flow vacates its output path on its next event (per 013 §Re-registration). |
:path (the cleared definition's output path) |
:rf.flow/failed |
A flow's evaluation threw during recompute — EITHER the authored :output fn itself (:phase :derive) OR the framework's own install of the value that fn RETURNED, at the flow's declared :output-path (:phase :output-write; most often the pending app-db holds a container at that path which cannot accept the path's final segment). The two are discriminated structurally, by which of the evaluator's two separate try forms caught the throw — never by reading the exception's message — so an install failure can never be reported as the application's :output fn throwing. 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. |
:phase (the failing flow-evaluation phase — :derive or :output-write), :path (the flow's declared output path — the same spelling :rf.flow/computed uses, so a consumer reads the target path off either op), :exception-message (the thrown exception's plain message string), :exception-data (the ex-info ex-data map, or nil) — a structured, EDN-safe exception summary, NOT a raw Throwable: a bare Throwable is not serializable and would bypass the central trace-projection chokepoint. The shape matches every other :rf.error/* category (:exception-message; :exception-data mirrors the machine path). When the flow's frame declares any :sensitive app-db classification, or the row names no :frame (fail-closed), project-trace-event redacts :exception-message and :exception-data to :rf/redacted and stamps top-level :sensitive? true; the same rule redacts the cascade-level :rf.error/flow-eval-exception's :exception, the only slot the live Throwable rides (for stack-trace introspection on that error row). Plus :inputs (the input values that were read just before the throw, each already routed through the wire-elision walker — runtime-qualified inputs normalized to their stripped declaration path) |
Payload-shape decisions:
:input-values/:resultare the actual values, not hashes. The trace surface is dev-only (per §Production builds) and downstream tools — Xray's flow panel, custom dashboards — display the values. Hashing would force consumers to consult an out-of-band side table; raw values keep the stream self-contained.:rf.flow/skipcarries:reason :inputs-value-equalrather than always being implicit. The keyword is the future extension point if additional skip reasons land (e.g. flow disabled mid-walk, frame in restore).:rf.flow/failedre-throws so run-level error-handling (Spec 009 §Error contract's:rf.error/flow-eval-exception) still fires; the per-flow:rf.flow/failedadds the per-flow attribution.
Pair-shaped tools and Xray's flow panel filter op-type :flow (per Tool-Pair §How AI tools attach) to subscribe to the whole stream.
Subscription / consumption¶
re-frame2's trace API uses event-at-a-time delivery: every registered listener is invoked once per emitted trace event, one event at a time, never concurrently with itself. There is no batching, debounce window, or background delivery loop — every event a public operation produces is delivered before that operation returns, and an event emitted inside a listener callback is delivered before the outermost delivery in progress returns. Listener-invocation order is not contract; tools must not depend on the order in which sibling listeners receive a given event. Listeners SHOULD do minimal work in the callback (queue, append to a buffer, mark a flag) and defer expensive work to a separate timer or animation frame they own.
Trace listeners are observers, not participants. When a listener is invoked depends on who emitted the event, and the runtime distinguishes two tiers:
- Public emits — an emit raised outside a frame drain, including one an application or tool raises itself — fan out synchronously, on the emitting call stack, through callback completion. The exception is an emit raised inside a listener callback: it is queued behind the event being delivered and returns at once (see §Listener invocation rules).
- Internal, drain-owned emits — those the runtime raises while it owns a frame's drain lock — are delivered at the post-drain boundary. They are queued during the drain and fanned out on the way out: serialized process-wide, in emission order, and completing before the enclosing dispatch / drain call returns — or, for a drain a listener callback started, queued behind the event that callback is handling. A listener therefore never observes a partially settled state, and two frames draining concurrently can never enter the same listener at once.
The reason is a hard invariant: arbitrary listener code is never invoked, nor awaited, while the framework owns any frame drain lock. A listener that dispatched, destroyed a frame, or blocked would otherwise be running inside the runtime's own critical section — which on a multi-threaded host admits overlapping delivery across independent frames, and lock cycles through listener-authored work.
The corollary is a contract boundary tools must respect: a listener's side effects cannot influence the drain that produced the event. A mutation performed in a callback takes effect when that callback runs, which for a drain-owned emit is after the drain has finished — exactly as if it had been written on the line following dispatch-sync. Code that depends on a listener body altering the outcome of the in-flight operation is out of contract on every platform. This holds uniformly across hosts: a single-threaded host also defers drain-owned delivery to the post-drain boundary — queueing during the drain, flushing once the lock is released — rather than delivering inline, so no listener observes a partially settled state, or can influence the in-flight drain, on any platform. Inline delivery is not a promise a portable tool may read intra-drain observation or influence into.
Observation itself is unaffected: every event is still delivered, exactly once, in emission order, before the operation that produced it returns — or, for an event emitted inside a listener callback, before the outermost delivery returns.
The listener API¶
The listener API is one stream-parameterized verb across the two raw dev observation streams. The differentiator is data — which stream — so it rides in a leading required stream keyword. The closed stream vocabulary is :trace / :epoch; an unknown stream throws :rf.error/unknown-listener-stream (no bare trace default, no compatibility aliases). Both members are raw and DCE-able, so the verb means one thing: raw dev stream. Production observation is a different verb — the frame-owned :observability sink, or the (rf/configure! {:observability …}) process default (see §What IS available in production). 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 :epoch below for
;; the other stream)
;; key — any comparable value identifying the listener
;; (replaces same-key registration)
;; callback-fn — invoked with one trace event per call.
;; Signature: (fn [trace-event] ...)
(rf/unregister-listener! :trace key)
;; Unsubscribes the listener registered under key on the stream. Returns nil.
;; There is deliberately NO facade `clear-listeners!` verb. Dropping every
;; listener on a stream is a test-isolation concern owned by the fixture layer:
;; `re-frame.test-support/make-reset-runtime-fixture` restores a clean listener
;; registry between tests via the lower-level sinks
;; (`re-frame.trace.tooling/clear-listeners!` and its :epoch sibling, plus the
;; implementation-tier `re-frame.event-emit` / `re-frame.error-emit` clears). 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 other stream takes the same verb. :epoch is the assembled-epoch listener (below):
(rf/register-listener! :epoch key callback-fn) ;; assembled :rf/epoch-record — a publication, not a per-event count
There is deliberately no always-on member. An always-on stream here would be a second production door — unprojected, raw :exception, fanned across every frame regardless of any frame's policy — beside the projected door this spec calls normal. Independent corpus observation regardless of a frame's policy is therefore not a public primitive; the re-frame.event-emit / re-frame.error-emit registries are implementation-tier seams for the framework's own capture sites and for tests.
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 :epoch-stream 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.
(rf/register-listener! :trace
:my-app/error-logger
(fn [trace-event]
(when (and (= :error (:op-type trace-event))
(not (:sensitive? trace-event))) ;; gate any off-box egress on :sensitive?
(println (:operation trace-event)
(-> trace-event :tags :reason)))))
The same pattern with the :epoch stream to log one assembled epoch per event:
(rf/register-listener! :epoch
:my-app/run-logger
(fn [epoch-record]
(println (:event-id epoch-record)
"→" (count (:effects epoch-record)) "fx"
"/" (count (:sub-runs epoch-record)) "sub-runs")))
The :epoch stream — 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 is fed by re-frame.epoch, which re-frame.core loads, so every dev build has it; an elided build records nothing, so the listener never fires. The app-facing route is the :epoch stream of the one listener verb — (rf/register-listener! :epoch key f) / (rf/unregister-listener! :epoch key), exactly like :trace; there is no dedicated rf/register-epoch-listener! / rf/unregister-epoch-listener! facade pair, and the stream delegates to the re-frame.epoch/register-epoch-listener! native form (per Tool-Pair §Time-travel):
(rf/register-listener! :epoch key callback-fn)
;; Subscribes callback-fn to receive assembled epoch records.
;;
;; Arguments:
;; stream — :epoch
;; key — any comparable value identifying the listener
;; (replaces same-key registration)
;; callback-fn — invoked with one :rf/epoch-record per publication: one per
;; dequeued event, and again when a post-settle back-fill
;; re-publishes the same epoch (reconcile on [frame epoch-id];
;; see the invocation rules below). Signature: (fn [epoch-record] ...)
;;
;; The record is the same shape the runtime appends to (re-frame.epoch/epoch-history frame-id):
;; assembled :event-id / :trigger-event / :db-before / :db-after, plus the structured
;; :sub-runs / :renders / :effects projections derived from the run's traces.
(rf/unregister-listener! :epoch key)
;; Unsubscribes the listener registered under key on the :epoch stream.
Invocation rules (mirrors register-listener!):
- Per dequeued event, not per drain — and a publication, not a counter. An ordinary event's full pipeline run (and, for a machine event, its entire macrostep) is one epoch (per 002 §Drain versus event). A drain that settles a parent event and the child it
:fx-dispatched publishes two records — one per event — not one for the drain. A machine's:raisesub-events and:alwaysmicrosteps do not publish: they ride inside the triggering event's epoch (per 005 §Drain semantics). But the callback is a record-publication notification, not a once-per-event clock: the SAME epoch re-publishes — carrying the same:epoch-id— when a post-settle render / sub-run / unmount back-fills into that already-settled epoch (a corrected record); the synthetic:rf.epoch/db-replaced,:halted-depthand:halted-destroyrecords publish with no dequeued event at all — the terminal:halted-destroymarks queued work a destroy dropped at an event boundary (see Spec-Schemas §:rf/epoch-record§Outcomes). Because the listener is process-global while:epoch-idis unique only within one frame, reconcile on the pair[(:frame record) (:epoch-id record)]— cache under that key and REPLACE on re-publication rather than counting callbacks as events;:outcomeis record STATE (:ok/:halted-depth/:halted-destroy), not identity. A dequeued event rejected before it runs (no handler) publishes nothing. - After commit. The callback receives a fully-formed record with
:db-after,:sub-runs,:renders,:effects, and any optional:trace-eventspopulated. An ordinary,:halted-depth, or:rf.epoch/db-replacedrecord has been appended to the frame'sepoch-historyring buffer when depth permits — the runtime attempts the append before it publishes, but a depth-0 ring retains nothing while the record still publishes; the terminal:halted-destroyrecord is delivered to listeners only and is never appended (see Halted runs below). - Exception isolation. An exception thrown by an epoch callback is caught and does not propagate. One broken epoch listener cannot break the app or block other listeners (raw-trace or epoch).
- Listener ordering is not contract.
- Production elision. The epoch listener machinery is gated on the same
re-frame.interop/debug-enabled?flag (alias ofgoog.DEBUG) as the raw-trace surface — see §Production builds. Production builds elide registration, dispatch, and the epoch ring-buffer all together.
Halted runs. Listeners receive epoch records for halted drains as well as clean settles. :outcome on the record discriminates — :ok, :halted-depth, or :halted-destroy. The partial record carries whatever the runtime captured up to the halt point: :trace-events, :sub-runs, :renders, :effects reflect the run-so-far, and :halt-reason carries a structured descriptor of why the drain halted. Retention differs by outcome: a :halted-depth record is ring-retained when depth permits (it lands in epoch-history like an ordinary record), while the terminal :halted-destroy — the queued work a destroy dropped at an event boundary — is delivered to listeners only and never retained, because it publishes at teardown's last step, when the destroyed frame's history is already gone. It reaches listeners before the destroy releases the frame id, so no same-id incarnation exists yet; it is still HISTORICAL for the destroyed incarnation (per Tool-Pair §the late-record consumer rule), never part of a later same-id incarnation's history. This is the devtools surface for failing runs — Xray's epoch panel, re-frame2-pair's cascade-of, post-mortem dashboards: all route off the same listener, and :outcome lets them render the failure with the right shape. Consumers that only care about successful drains filter on (= :ok (:outcome record)) at the top of their callback. restore-epoch! refuses non-:ok records — see Spec-Schemas §:rf/epoch-record §Outcomes.
When to use which. The :trace stream is the right shape for tools that need fine-grained per-event activity (custom recorders, error-monitor forwarders, timing aggregators). The :epoch stream is the right shape for tools that route diagnostics off "what just happened in this run" — pair-shaped tools, post-mortem dashboards, anything that wants the structured :sub-runs / :renders / :effects projection without re-folding the raw trace stream.
The two listener APIs are independent: tools may register either, both, or neither. They share the production-elision gate but have separate listener registries; no listener of one kind can interfere with the other.
Event-bundle projection (group-by-event / domino-bucket)¶
The grouping fn is group-by-event, and its per-run output record is an event bundle — one record per pipeline run (one dequeued event; the event-* noun family, per Conventions §The event-* noun family and Conventions §Event-pipeline vocabulary).
The raw trace stream is event-at-a-time; pair-shaped UIs (the Story trace panel, the Xray Epoch panel, re-frame2-pair's cascade-of) all want the per-run slice of the stream — one record per pipeline run (one dequeued event) with the event vector, handler emit, fx-map emit, effects, sub-runs, and renders already split into named slots. (First-contact mnemonic: the run's per-event half is the "six dominoes".) The framework ships that projection as a pure-data function in re-frame.trace.projection — the namespace a tool requires directly; there is no re-frame.core re-export:
(re-frame.trace.projection/group-by-event trace-events)
;; -> [{:dispatch-id <id-or-:ungrouped>
;; :parent-dispatch-id <id or nil> ;; causal-parent link from
;; ;; :rf.trace/parent-dispatch-id
;; ;; on the :rf.event/dispatched trace
;; ;; — the run that emitted this
;; ;; dispatch (an :fx :dispatch parent,
;; ;; a machine-internal dispatch, etc.);
;; ;; nil for a root / external dispatch
;; :frame <frame-id or nil> ;; the run's host frame, per
;; ;; 002-Frames §Routing the dispatch
;; ;; envelope; nil for an :ungrouped
;; ;; slot or a run with no frame
;; ;; tag in any of its events
;; :event <event-vector | nil> ;; from :rf.event/dispatched :tags
;; ;; (the slim, common-case form)
;; :dispatched <trace-event | nil> ;; the FULL :rf.event/dispatched
;; ;; trace event — preserves
;; ;; top-level hoisted slots
;; ;; (:rf.trace/call-site, :source,
;; ;; :origin)
;; :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
;; ;; + :rf.sub/dispose
;; :renders [<trace-event> ...] ;; :op-type :rf.view /
;; ;; :operation :rf.view/render
;; :other [<trace-event> ...]} ;; errors, warnings, machines,
;; ;; frames, flows, registry,
;; ;; anything outside the six dominoes
;; ...]
The slot order above is the projection's emit order. Each slot's wire-shape and semantics are documented alongside the per-frame ring's read surface at Tool-Pair.md §Reading the per-frame trace ring — the same event-bundle shape (rf/trace-buffer frame-id) returns by default, since the ring pre-computes this projection per run. (Pair tools assembling the same projection from an epoch-history record route off :rf/epoch-record's :sub-runs / :renders / :effects slots per Spec-Schemas §:rf/epoch-record; those structured slots derive from the same :trace-events stream this projection groups.)
(The event-bundle slot names above — :dispatch-id, :parent-dispatch-id, :frame, :event, :dispatched, :handler, … — are the projection's own output shape, distinct from the trace :tags keys it groups by.) Events without a :rf.trace/dispatch-id tag (registry-time emits, frame lifecycle, REPL evals outside a drain) collect under the projection's :dispatch-id :ungrouped slot. The returned vector is sorted by the lowest :id in each run so consumers render runs in emission order. The projection is pure data — JVM and CLJS run the same code; tools wiring up post-mortem renders against (rf/trace-buffer frame-id) get the same output shape as live consumers reading from a register-listener! listener.
(re-frame.trace.projection/domino-bucket trace-event) is the underlying classifier — returns one of #{:event :handler :fx :effect :sub :render :other}. Tools that want custom rollups can call it directly per event and skip group-by-event.
The projection is robust against errors, fx, sub-runs, and renders that fire inside an event's run even though they aren't :rf.event/dispatched — every such event carries :tags :rf.trace/dispatch-id (per-event correlation), so they group into that event's bundle automatically.
The projection is additive: new :op-type values that don't fit a domino slot flow through :other without breaking existing consumers.
Listener invocation rules¶
- Synchronous, event-at-a-time. Every registered listener is invoked once per emitted trace event, one event at a time, and every event is delivered before the public operation that produced it returns. An emit outside a frame drain returns once every listener has run; a drain-owned emit returns at once, and its event is delivered at the post-drain boundary, before the enclosing dispatch or drain call returns (§Subscription / consumption). There is no 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.)
- Nested emits are queued, never re-entrant. A listener may itself emit — a dispatch, a flow registration, a frame create/destroy all emit. Delivery is one FIFO queue owned by the outermost delivery: a nested
emit!appends its event behind the event being delivered and returns at once, and the outermost delivery hands every queued event to every listener, in emission order, before it returns. Concretely, when a listener handling event A emits B, every listener receives A, then B; no listener callback is ever re-entered (B reaches the emitting listener only after its callback for A has returned); and when the nestedemit!returns, no listener has received B yet. The same holds for the traces of a drain a listener callback starts (dispatch-syncinside a listener): they join the queue, so the listener'sdispatch-syncreturns with app-db settled and the frame's trace ring already holding the run, but with those traces not yet delivered to any listener. One consequence for tools: a register-run-unregister capture bracket placed inside another listener's callback closes before the events emitted within it are delivered, and so captures none of them. - A listener must not wait on another thread's trace-emitting work (JVM). One process-wide monitor is held for the whole outermost delivery, so another thread's emit waits until the listener returns, and a listener blocked on that thread waits forever.
- 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
:epochlistener 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. A trace listener's exception emits NO trace event for the failed listener (which would risk a re-entrant trace-emit storm); a development build prints one
[re-frame2] trace listener <id> threw — <message>line (*err*on the JVM,console.warnon a JS host), and a production build is silent. An:epochlistener callback's exception is caught the same way but reported, as a:rf.epoch.cb/listener-exceptiontrace event, so devtools can surface the broken consumer. - No retained delivery buffer. An event waits only until its delivery point — the post-drain boundary for a drain-owned emit, the outermost delivery's queue for a nested one — and nothing is kept for a listener that registers later. The per-frame trace rings described next are written at emit time, independent of delivery, and exist for late-attaching tools.
Per-frame trace rings (event-keyed, dev-only)¶
In dev builds, each frame owns its own trace ring alongside the synchronous-delivery path. The ring's unit of retention is the event (one dispatched event = one pipeline run = one slot, keyed by :rf.trace/dispatch-id), not the individual trace event. This lets pair-shaped AI tools, REPL-attached debuggers, and post-mortem dashboards read recent activity from the frame they care about without having to be registered as a register-listener! listener at the time the events fire — and without their reads being polluted by trace volume from other frames.
This is the per-frame model joining what frames already own — app-db, epoch-history, the sub-cache reactive context, fx/cofx routing — extended one rung further: the trace surface, too, is partitioned by frame, with event-keyed eviction.
Event-keyed, per-frame¶
Each frame owns an independent event-keyed trace ring; the unit of retention is the EVENT (one dequeued event = one pipeline run = one slot). Two structural properties drive this shape (a process-global, raw-trace-count ring has neither):
- Frame mismatch. Tools that mount in their own frame (Xray, re-frame2-pair-mcp, story-mcp, any future inspector) emit trace events whose volume swamps every other frame's view of the same buffer. Sub-recompute storms in an inspector's reactive substrate could evict every application event from the global ring within a few microtask cycles.
- Eviction unit mismatch. Trace volume per run is wildly uneven — a single click run through a machine entry can emit hundreds of
:rf.sub/skipshort-circuit events alongside the handful of:run-start/:run-end/:db-changed/:fx/handled"real" events. A flat ring sized by trace-event count means one chatty run can evict every earlier run's traces (including its own real events) before a consumer reads them.
Two compositional structural properties answer them:
- Per-frame ring. Every frame owns an independent ring; emit-site routing places each trace event in the frame whose reactive chain or event pipeline run is running (see §Emit-site routing below). Each frame's ring is sized independently via frame metadata. Frames are isolated (per 002 §Per-frame and trace surface); the trace surface matches.
- Event-keyed eviction. The ring slot is the event. One
:rf.trace/dispatch-idconsumes one slot regardless of how many trace events that run emitted. When event #N+1 arrives, the oldest event (and every trace event ever emitted under it) is dropped as a unit. A run with 5 traces and a run with 50,000 traces each occupy one slot.
Retention contract — the single knob :rf.trace/events-retained¶
Retained unit — the one known misread. The retained unit is one slot per EVENT (one dequeued event = one pipeline run = one epoch), regardless of how many trace events that run emitted — a run emitting 50,000 trace events still consumes exactly one slot.
events(dequeued events / traversals) vstrace-events(individual instrumentation emissions) is the one known misread. The knob is theevent-*noun family (per Conventions §Theevent-*noun family).
| API | Default | Notes |
|---|---|---|
:rf.trace/events-retained (frame metadata) |
50 | Per-frame override. Sets the number of retained slots — one slot per event (per dequeued event / pipeline run) — in this frame's ring. 0 disables the ring (synchronous delivery still works). |
(rf/configure! {:trace-buffer {:events-retained N}}) |
applies to :rf/default |
Process-default tuner — applied to frames that did not set per-frame metadata. |
This is the entire retention surface. There is no per-run trace cap, no per-trace-type cap (no :skip-specific budget, no :sub-specific budget), no per-frame override beyond the event count, no other knobs. Operator-facing tuning is one number: how many events (pipeline runs) does this frame keep?
Rationale:
- Matches operator mental model. Operators think "I want to see my last 50 events"; storage matches the unit they think in.
- Predictable retention. A chatty sub never silently evicts the run the operator cares about. Only newer events push older events out.
- Naturally bounds skip noise. Sub-skip emits stay associated with their parent run; the burst lives or dies as a unit with the rest of that run's traces.
- Aligns with epoch-history.
epoch-historyis already retained per-event (one assembled:rf/epoch-recordper dispatch). The trace ring is "the diagnostic detail of the same 50 epochs" — consistent retention semantic across the per-frame model. - No per-trace-type tuning needed. Eviction is event-driven; trace volume per run is incidental. A run with 50K traces is a slot like any other.
There is no worst-case memory bound on a single run's trace volume. If a run emits 100K traces, that event's slot holds 100K traces until evicted. The operator's tradeoff is in the event-count knob, not in trace-volume bounding. (Apps whose trace-volume budget genuinely matters tune the per-frame event-count downward.)
Retention boundary — structural terminal facts are delivered, never retained¶
One class of trace fact bypasses every ring: a destroyed frame incarnation's terminal facts, the ones it reports after its teardown has released its per-frame trace state (002 §Destroy step 7). Two kinds arrive then: what the terminal publication (step 9) emits — the :halted-destroy record's trailers, a terminal listener's failure, the step's own failure warning — and a :rf.frame/drain-interrupted that a drain of the incarnation's old router reports once the destroy has finished. The runtime delivers each through retentionless structural delivery (re-frame.trace/call-with-structural-delivery). Such a terminal fact still streams live to every registered listener exactly once, but no per-frame ring retains it. Retention is the only thing this path gates (deliver!'s retain? input); listener fan-out is unconditional, so a tool listening at teardown still sees the fact.
The boundary exists because retention is keyed by the bare frame id, which outlives the incarnation. A fact pushed after the release would recreate a ring under an id whose frame is gone: residue that nothing frees for a per-request id, and that a later same-id incarnation inherits, because construction keeps a ring it finds. A report from the old router can also arrive after the destroy has released the id, when a same-id successor may already own it. The rule is therefore absolute — no current or future frame's ring ever retains a destroyed incarnation's terminal facts. Post-mortem ring reads never surface a dead incarnation's tail; the live listener stream is the sole channel for it.
Emit-site routing (per-frame)¶
Trace events that ride inside an in-flight run are routed to the frame whose router is processing that event. Concretely:
- The runtime carries the in-flight frame's id through
re-frame.trace/*handler-scope*(alongside:rf.trace/dispatch-id, per §Handler-scope).emit!reads the slot to look up the destination ring. - For trace events emitted from inside a frame's drain loop, fx-pass, or epoch-settle path (
:rf.event/dispatched,:rf.event/run-start,:rf.event/db-changed,:rf.fx/handled,:rf.machine/*,:rf.flow/*, every:rf.error/*thrown inside the run), the destination frame is the one whose router is running — the same frame the trace event's:frametag already carries. - For trace events emitted from inside a sub's reactive recompute (
:rf.sub/run,:rf.sub/skip), the destination frame is the one whose reactive chain is running — Spec 006's per-framesub-cachealready runs each sub against its own frame, so the per-frame ring inherits that boundary. Cross-frame sub composition is an anti-pattern (per Spec 002); subs do not reach across frames, so trace events from a frame's reactive substrate stick to that frame's ring. - For trace events emitted from inside a view render (
:rf.view/render,:rf.view/rendered,:rf.view/unmounted), the destination frame is the frame the view is bound to — carried on:frameand on the in-flight handler scope set up by the registered-view wrapper.
Cross-frame runs — merge by :dispatch-id¶
Frames are isolation boundaries, not communicating agents — re-frame2 does not support one frame dispatching directly into another; the run of a dispatched event lives in exactly one frame. But several distinct runs may share the same :dispatch-id family when consumers correlate across rings (e.g. a pair-mcp client watching every frame, an off-box trace dashboard merging runs from a multi-frame story session).
The contract: each frame retains the traces of runs that EXECUTED IN IT, keyed by their own :rf.trace/dispatch-id. Cross-frame consumers (pair-mcp, monitoring tools, the Xray off-box rendering tier) merge by :dispatch-id across rings to reconstruct a multi-frame timeline. The framework does not maintain a process-global "cross-frame index"; the merge is the consumer's job, and it is cheap because each ring already keys by :dispatch-id.
Frameless trace events — live stream only (no ring storage)¶
Some trace events ride outside any in-flight run — registration-time :rf.registry/handler-registered / :rf.registry/handler-replaced / :rf.registry/handler-cleared emits, :rf.frame/created / :rf.frame/destroyed lifecycle, REPL evals that don't dispatch, schema-validation warnings emitted at namespace load. These events have no :rf.trace/dispatch-id and no destination frame.
Frameless events SKIP the rings entirely. They stream live to registered listeners (register-listener!) only; they are NEVER retained in any ring.
- No frameless ring is allocated. There is no
:rf/globalring, no shared "uncorrelated bucket", no fallback slot for frameless emits. The ring exclusively holds events. - The live stream is the egress channel. Tools that care about registration drift, hot-reload diagnostics, or REPL-eval emits subscribe to the live stream via
register-listener!and filter by:op-type/:operationthemselves. Per §The listener API, the live stream is synchronous and event-at-a-time. - The registry is the source of truth for "what's registered right now". Tools reading "the current set of registered handlers / frames / routes" consult
(rf/registrations {:kind 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 — every re-registration emits¶
A hot-reload re-registers every handler in the reloaded namespaces, and every re-registration emits :rf.registry/handler-replaced (per 001 §Hot-reload trace surface). A clear emits :rf.registry/handler-cleared only for an id that was registered, so a double-clear emits once. The registrar deduplicates nothing by shape: re-evaluating a namespace builds fresh fns, so a shape comparison cannot tell a reload from an edit, and keeping every registration change visible is worth more than a quiet reload. A tool that wants to hide idempotent-reload noise filters on the event's :different-fn? tag, which is false when a re-registration rotates no executable.
These emits are frameless, so reload churn reaches the live stream and never a ring. Together: rings hold events exclusively; the live stream carries every registry change; the registry is the source of truth for "what's registered right now". Tools wanting "the current state of the world" read registrations; tools wanting "what changed in the last run" read the per-frame ring; tools wanting fine-grained event-by-event observation register a listener and receive the live stream.
trace-buffer API — per-frame, event bundles by default¶
The query surface is per-frame, with a frame-id required argument. The default return shape is event bundles (one map per pipeline run / dequeued event with the run's :dispatch-id, its trace events, and the structured projection slots; the event-* noun family, per Conventions §The event-* noun family); the :flat opt-in returns raw trace events for callers that want the pre-grouping shape.
| API | Signature | Notes |
|---|---|---|
(rf/trace-buffer frame-id) |
(frame-id) → vector |
Returns the frame's event-bundle vector, oldest-first. Each entry is {:dispatch-id <id> :parent-dispatch-id <id or nil> :frame <frame-id or nil> :event <event-vector or nil> :dispatched <trace-event or nil> :handler :fx :effects :subs :renders :other :trace-events [...]} — the group-by-event-shape projection per §Event-bundle projection, pre-computed per run by the ring. Empty when no events have been recorded. |
(rf/trace-buffer frame-id opts) |
(frame-id, opts) → vector |
Optional filter map (see §Filter vocabulary below). Filters compose AND-wise across bundle-level fields; absent key = no constraint. The :flat true opt returns raw trace events instead of event bundles (escape hatch — see below). |
(rf/clear-trace-buffer! frame-id) |
(frame-id) → nil |
Empties the named frame's ring. Tooling uses this between sessions. |
(rf/clear-trace-buffer!) |
() → nil |
Empties every frame's ring. Retention policy — the process default and each frame's explicit :rf.trace/events-retained override — is preserved; the fixture-grade reset that also resets policy is re-frame.trace.tooling's concern, not a public verb. This is the clear a user-facing "clear buffer" affordance or a privacy scrub wants: its subject is retained events, not the user's tuning. |
(rf/configure! {:trace-buffer {:events-retained N}}) |
(opts) → nil |
Process-default ring depth (applies to :rf/default and any frame that did not set per-frame metadata). |
The :flat opt is the escape hatch for callers that want the pre-grouping raw stream:
(rf/trace-buffer :step-deck)
;; → [{:dispatch-id 17 :parent-dispatch-id nil :frame :step-deck
;; :event [:user/click ...] :dispatched {...}
;; :handler {...} :fx {...} :effects [...] :subs [...] :renders [...] :other [...]
;; :trace-events [...]}
;; {:dispatch-id 18 :parent-dispatch-id 17 :frame :step-deck
;; :event [...] :dispatched {...} ...}
;; ...]
(rf/trace-buffer :step-deck {:flat true})
;; → [{:operation :rf.event/dispatched :tags {:rf.trace/dispatch-id 17 ...} ...}
;; {:operation :rf.event/run-start :tags {:rf.trace/dispatch-id 17 ...} ...}
;; {:operation :rf.sub/skip :tags {:rf.trace/dispatch-id 17 ...} ...}
;; ...]
The default (event bundles) matches the storage unit; tools whose code shapes around the raw stream pass {:flat true} and re-fold via group-by-event themselves. (The storage is genuinely per-event, so even :flat reads the ring's per-run slots and flattens them; there is no pre-grouping flat ring.)
Filter vocabulary¶
(rf/trace-buffer frame-id opts) recognises the following filter keys. All compose AND-wise; an absent key means "no constraint on that axis." Unrecognised keys are ignored (forward-compat: tools may probe new axes; missing support degrades to "no filter").
| Key | Type | Semantics |
|---|---|---|
:flat |
true |
Return raw trace events instead of event bundles. The other filter keys apply to events when :flat true, otherwise to bundles. |
:operation |
keyword | (:flat-only) Match exact :operation value (e.g. :rf.event/dispatched, :rf.fx/handled). |
:op-type |
keyword | (:flat-only) Match exact :op-type discriminator (e.g. :rf.event, :rf.fx, :error). |
:since |
number | (:flat-only) Keep events whose :id is strictly greater than this. Cursor-based polling — read the last event's :id, pass on next call. |
:severity |
:error / :warning / :info |
(:flat-only) Synonym for :op-type restricted to the three severity tiers. |
:event-id |
keyword | Match :tags :rf.trace/event-id (the first element of the dispatched event vector, e.g. :user/login). For event-bundle reads, matches bundles whose :event first element is this id; for :flat, matches per-event. |
:handler-id |
keyword | (:flat-only) Match :tags :handler-id. Present on handler-error emits. |
:source |
one of Spec-Schemas §:rf/dispatch-envelope's :source enum (:ui / :after-timer / :http / :repl / :machine-action / :machine-spawn / :fx-dispatch / :fx-dispatch-later / :always / :ssr-hydration / :test / :frame-init / :unknown / :other) |
(:flat-only) Match the top-level :source slot. |
:origin |
:app / :pair / :story / :test / ... |
Match :tags :rf.event/origin. For event-bundle reads, matches bundles whose root :rf.event/dispatched carries this origin. |
:dispatch-id |
number | Match the bundle's :dispatch-id (event-bundle reads) or :tags :rf.trace/dispatch-id (:flat). |
:since-ms |
number | Keep bundles / events whose :time (host-clock ms) is strictly greater than this. |
:between |
[t0 t1] |
Two-element vector — keep bundles / events whose :time falls in [t0, t1] inclusive. |
:pred |
(fn [ev-or-bundle] → truthy) |
Arbitrary predicate. Receives the event bundle (or raw event when :flat true). Returning truthy keeps the entry. Escape hatch for filters not yet promoted to named keys. |
Filters compose AND-wise — supplying both :op-type :error and :flat true keeps only error events. For event-bundle reads, bundle-level keys (:event-id, :origin, :dispatch-id, :since-ms, :between, :pred) apply; event-level keys (:operation, :op-type, :since, :severity, :handler-id, :source, :sensitive?) require :flat true, and a bundle read carrying one with a non-nil value throws :rf.error/trace-buffer-flat-only-filter rather than keeping every bundle.
Semantics¶
- Ring discipline — per-event. When the ring is full at
:rf.trace/events-retainedslots, the oldest event slot (and every trace event ever emitted under its:dispatch-id) is evicted as a unit as the new event arrives. No allocation churn beyond the slot count. - Per-frame isolation. Each frame's ring is independent — a burst of
:rf.sub/skipruns in:rf/xraydoes not touch:step-deck's ring. Tools that mount in their own frame (Xray, re-frame2-pair-mcp, story-mcp) see their own ring; the app frame they observe sees its own ring; cross-frame consumers merge by:dispatch-idacross rings. - Same events as delivery. Every event delivered to listeners also lands in its frame's ring (when in-run). Ring-buffer events are the same maps the listeners receive.
- Frameless events bypass the ring. As above, frameless trace events never land in any ring; they stream live to listeners only.
- Independent of listeners. A tool that attaches after events have fired can read the most-recent N events from the ring to bootstrap its view; a tool that wants a continuous live feed registers a
register-listener!listener as well. - Production elision. The ring, like the rest of the trace surface, is compile-time eliminated in production builds (per §Production builds).
(rf/trace-buffer frame-id)returns an empty vector in production, and the ring itself is not allocated. - Events-retained-zero semantics. When configured with
{:events-retained 0}, the ring is disabled but the surface remains live:(rf/trace-buffer frame-id)returns[],(rf/trace-buffer frame-id opts)returns[], and(rf/clear-trace-buffer! frame-id)is a no-op (returnsnil). Synchronous-delivery to registered listeners continues to fire — only the queryable history is suppressed. - Lowering events-retained on a populated ring. Applied while the ring holds more than
Nevents, drops the oldest events first to fit (same eviction order as the ring discipline). Raising it keeps existing events and grows the slot count. - Reads against a destroyed / missing frame.
(rf/trace-buffer <unknown-frame-id>)returns[](parity with(rf/app-db-value <unknown>)returningniland the destroyed-frame read posture in Tool-Pair §Surface behaviour against destroyed frames).
Rejected alternatives¶
For context — the design space the event-keyed per-frame ring won out against:
- "Don't emit sub-skip trace events at all." Sub-recompute short-circuit signal is useful diagnostic output (performance work, invalidation chasing). The ring keeps the emit by routing it to the right frame's ring and bundling it with the parent run.
- "Filter
:skipat the consumer level." Too late — by the time a consumer reads a global ring, the real events the operator cares about are already evicted by the:skipflood. Filter-at-read is structurally late; the filtering has to happen at INGEST or at the storage-partition level. - "Bigger flat ring (10× the default)." Defers the architectural mismatch without fixing it. A bigger ring still gets polluted by tool-frame traces and still has no eviction semantic that respects event boundaries.
- "Frameless events go to a
:rf/defaultcluster (the:rf/default+nilslot)." The cluster would leak under hot reload: every file save re-fires the entire ns-load worth ofreg-*traces, the cluster grows without bound, and the operator never benefits from the noise. Keeping the ring exclusively for events closes the leak: reload churn reaches only the live stream, where a tool filters it on:different-fn?.
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 — 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 tooling surface — register-listener! / unregister-listener! / trace-buffer / clear-trace-buffer! / configure-trace-buffer!, plus the fixture-layer clear-listeners! that the facade deliberately does not publish — 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 name the tooling sibling directly — re-frame.trace publishes no listener/buffer surface of its own. 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 property is var-level rather than namespace-level, so an app that never CALLS the surface lets DCE drop the body wholesale (~2 KB raw / ~600 B gzipped saved).
Emitting trace events¶
The framework emits trace events through one entry point: re-frame.trace/emit!. User code may also call it (re-exported as rf/emit-trace-event!) to add custom events to the stream.
(re-frame.trace/emit! op-type operation tags)
;; Emits one trace event with the given :op-type / :operation / :tags.
;; Returns nil. The runtime stamps :id and :time, hoists :source and
;; :recovery (when present in tags) to the top level, routes the event
;; into the in-flight frame's per-frame ring (event-keyed; frameless
;; emits bypass the ring), and synchronously invokes every registered
;; listener (an emit raised inside a listener callback is queued instead
;; and returns at once — see §Listener invocation rules).
The shape is synchronous and side-effecting: the emit returns once every listener has been invoked — except an emit raised inside a listener callback, which queues its event behind the one being delivered and returns at once (per §Listener invocation rules). 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, per §Handler-scope); the gate sits inside the outer interop/debug-enabled? when so production elision is preserved.
(rf/reg-event :rf.xray/note-trace-event
{:rf.trace/no-emit? true} ;; <- opt-out
(fn [{:keys [db]} [_ event]]
{:db (assoc db :trace-buffer (conj (:trace-buffer db []) event))}))
The flag is the framework-level escape hatch for trace-consuming integrations whose own bookkeeping dispatches — emitted from inside a registered trace-cb — would otherwise re-enter the consumer through the trace-cb fan-out and form a cb-dispatch loop. Xray, Story, re-frame2-pair-mcp, and story-mcp all have the same risk shape; without the opt-out each consumer would need its own per-dispatch guard predicate. Promoting the gate to the framework lets any consumer mark a handler internal-only and trust the runtime to suppress the run.
Semantics:
- What's suppressed.
:rf.event/dispatched(queue-time, when the target handler's meta carries the flag), the pipeline-run markers:rf.event/run-start/:rf.event/run-end,:rf.event/db-changed,:rf.fx/handled,:rf.machine/transition,:rf.sub/run,:rf.view/render, and every:rf.error/*emit produced inside the handler's scope. The always-on event-emit substrate and the frame-owned:handled-eventsobservability sink route ALSO honour the flag and drop the per-event record for:rf.trace/no-emit?-flagged handlers — same boundary semantics as the:sensitive?short-circuit, on the rationale that framework-internal bookkeeping handlers are not user-domain observable signal. - What's NOT suppressed. The handler body still runs — the opt-out applies to OBSERVABILITY (trace + event-emit), not handler execution. The dispatch is queued, drained, and committed normally; the handler's db effect is committed; its fx are walked.
- Run composition. Innermost in-scope handler wins. A non-opt-out handler dispatched from inside a
:rf.trace/no-emit? truehandler emits normally — the inner binding rebinds to false and the inner run is visible. (Same composition rule as:trigger-handler, per §Handler-scope.) - 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 and:handled-eventsshort-circuits survive production builds (both are always-on), so production listeners and sinks equally drop opt-out handler records.
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 listener registry is process-global, an inspector's self-instrumentation would otherwise reach every listener — the inspector's own included — interleaved with the application events it inspects. Marking the tool frame trace-disabled means tool frames produce no trace at all, while application frames are unaffected.
Semantics:
- What's suppressed. Every trace + error emit tagged with the marked frame —
:rf.event/dispatched,:rf.event/run-start/:rf.event/run-end,:rf.event/db-changed,:rf.sub/run,:rf.view/render,:rf.frame/createdfor the frame itself, and every:rf.error/*whose:frameis the marked frame. The framework's emit sites already thread:frameonto these tags, so the gate keys on(:frame tags). - Mechanism (one canonical predicate).
re-frame.trace/frame-trace-disabled?is the single source of truth; frame construction reads the config flag and callsre-frame.trace/set-frame-no-emit!. No call site hardcodes a frame id (e.g.:rf/xray). Honoured on first registration and surgical re-registration so a hot-reload can flip it either way. - Production elision. The gate sits inside the same
interop/debug-enabled?whenas the rest of the emit substrate, so it DCEs out of:advancedproduction builds (which emit no trace at all).
Where trace emission lives¶
The framework emits trace events from these call sites:
events.cljc—:rf.error/effect-map-shape;:rf.error/effect-handler-bad-return; the registration-time throws:rf.error/reg-event-bad-interceptors,:rf.error/reg-event-bad-middle-slot, and:rf.error/reg-event-bad-arityfor malformedreg-eventinterceptor-chain shapes; and the retired-name hard errors:rf.error/reg-event-db-removed,:rf.error/reg-event-fx-removed, and:rf.error/reg-event-ctx-removed(the diagnostic stubs that recognise the removed public names and throw, namingreg-event/reg-interceptor).subs.cljc—:rf.sub/create,:rf.sub/run(the purecompute-subform — base shape only, no value-change/cascade attribution; see the:rf.sub/runop-type entry above);:rf.error/no-such-suband:rf.error/sub-exceptionfor failure paths;:rf.error/bad-subscribe-opts(thrown, for a non-map second argument tosubscribe/subscribe-once);:rf.warning/sub-input-dispose-exception(a declared-input release that threw).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; see the:rf.sub/runop-type entry above);:rf.sub/skipper memo-hit (input value-equal to last-seen → user body suppressed; see 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; see 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.:rf.warning/sub-input-dispose-exceptionwhen disposing an evicted slot's reaction threw,:wherenaming the eviction path.fx.cljc—:rf.fx/do-fxper drain step (op-type:rf.fx; the emit's:tagsadditionally carries:rf.event/fx(the vector the handler returned) and:rf.event/db-present?(boolean — was the handler's return-map's:dbslot supplied?) so consumers can align run rows with handler returns without re-reading the interceptor context; the:dbVALUE is intentionally NOT stamped — slice changes already ride:rf.event/db-changed. Both slots sit under:tagsalongside:frame, consistent with the payload-shaped tag convention),:rf.fx/handledper dispatched fx,:rf.fx/override-applied,:warning :rf.fx/skipped-on-platform,:rf.error/fx-handler-exception,:rf.error/no-such-fx, plus:rf.machine.spawn/spawnedand:rf.machine/destroyed.cofx.cljc—:rf.cofx/run(op-type:rf.cofx; emitted on the success branch of an ambient supplier that ran during context assembly, inside the supplier's scope binding, carrying:rf.cofx/id+:rf.cofx/value(the PRODUCED value, redacted by marks) +:rf.cofx/arg(the requirement-arg of a parameterized[id arg]requirement, omitted otherwise) +:rf.cofx/elapsed-ms);:rf.cofx/generated(op-type:rf.cofx; emitted when a declared-absent generator-backed recordable fact's generator runs at processing-start, inside the cofx scope binding, carrying:rf.cofx/id+:rf.cofx/value(the produced value, redacted by the same classification chokepoint) +:rf.cofx/arg); the cofx error family — the registration-time rejections:rf.error/cofx-name-collision,:rf.error/cofx-registration-invalid, and:rf.error/cofx-request-invalid, the delivery-time errors:rf.error/unregistered-cofx/:rf.error/missing-required-cofx/:rf.error/coeffect-exception(a supplier or generator throw during context assembly) /:rf.error/cofx-value-invalid(a supplied / replayed / generated recordable value failed its:schema, a production hard error), and the removed-API hard error:rf.error/inject-cofx-removed(the canonical per-error definitions are the §Error event catalogue rows; the retired draft dispatch opt:rf.world/inputsearns no dedicated error id — it rides the generic:rf.warning/unknown-dispatch-optat the dispatch boundary, not fromcofx.cljc, per Conventions §The tombstone rule),:warning :rf.cofx/skipped-on-platform(emitted when a registered cofx's:platformsexcludes the active platform; mirrors:rf.fx/skipped-on-platformper 011 §Effect handling on the server).router.cljc—:rf.event :rf.event/run-startand:rf.event :rf.event/run-end(the pipeline-run markers; both also carry the redundant:rf.trace/phase :run-start/:run-endtag),:rf.event :rf.event/dispatched,:rf.event :rf.event/db-changed,:rf.event :rf.event/db-noop(the commit-level app-db no-op signal — a:dbeffect that left app-db unchanged; the complement ofdb-changed),:warning :rf.warning/db-nil-coerced(emitted from the commit path when a handler returned{:db nil}; the nil is coerced to{}and the diagnostic flags the accidental-wipe-vs-deliberate-clear distinction),:rf.error/handler-exception,:rf.error/drain-depth-exceeded,:rf.error/no-such-handler,:rf.error/no-frame-context(emitted when a frame-scoped op carries no frame stamp and runs under no scope),:rf.error/dispatch-sync-in-handler,:rf.error/frame-destroyed,:rf.error/flow-eval-exception,:rf.frame/drain-interrupted(lifecycle event emitted when the ordinary drain observes its exact incarnation's destruction claim; its dropped count combines claim-time and check-time removals; per 002 §Edge cases worth pinning).frame.cljc—:rf.frame/created,:rf.frame/re-registered,:rf.frame/destroyed,:rf.machine.lifecycle/destroyed,:rf.error/on-destroy-handler-exception(the dedicated:on-destroy-throw category; rides the always-on error-emit axis via the:error-emit/dispatch-on-errorlate-bind hook so the discriminable teardown signal survives production),:warning :rf.warning/teardown-hook-exception(the single emit site isrecord-teardown-failure!— the onetrace/emit-error!call that funnels every best-effort teardown failure duringdestroy-frame!; it is reached fromsafe-call-contribution!(an artefact's lifecycle-contribution member, 002 §Lifecycle contributions), fromsafe-teardown-step!(the runtime's own guarded steps), and from the after-dissoc continuations, whose catch records:phase :after-dissoc— teardown continues best-effort, the warning is the breadcrumb that a cleanup step leaked).core.cljc—:warning :rf.warning/unknown-configure-key(emitted fromconfigure!when its argument carries a top-level key the runtime does not read and that key is bare orrf-namespaced; the call still returnsniland the key still applies nothing, so the warning is the loud-not-silent signal that a typo'd key changed nothing; user-namespaced extension keys pass in silence; per §Error contract and API §Configure keys).trace/tooling.cljc—:warning :rf.warning/trace-buffer-unrecognised-opts(emitted fromconfigure-trace-buffer!when(configure! {:trace-buffer ...})is handed an opts map without a usable:events-retained— a{:depth N}shape or a negative / non-numeric value; the call is a no-op and retention stays at its current default, so the warning is the loud-not-silent signal that the knob did nothing; per §Retention contract — the single knob:rf.trace/events-retained);:rf.error/trace-buffer-flat-only-filter(thrown bytrace-bufferfor a bundle read carrying a:flat-only filter key).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),:warning :rf.warning/registration-hook-exception(a registration or replacement hook threw; emitted through the:trace/emit-error!late-bind hook).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.timer/scheduled,:rf.machine.timer/fired,:rf.machine.timer/cancelled,:rf.machine.timer/stale-after,:rf.machine.timer/skipped-on-server(under SSR; per 005 §SSR mode),:rf.machine/guard-evaluated(emitted from the unifiedevaluate-guardhelper at every user-declared guard call site inmachines/transition.cljc;:tags {:actor-id <live-instance-id> :guard-id <kw-or-fn> :input {:data <data> :event <event-vec, nil in an eventless :always step>} :state <the active state the guard ran against> :outcome :pass | :fail | :threw :exception <Throwable on the :threw path, or :rf/redacted on a redacted row> :frame <frame-id>}, per Spec-Schemas §MachineGuardEvaluatedTags; 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 {:actor-id <live-instance-id> :action-id <kw-or-fn> :phase <driver phase> :decl-path <declaring-node-path> :region <region-name, in a parallel region only> :input {:data <data> :event <event-vec, nil in an eventless :always step>} :outcome <return-value> | :ok | :rf.error/action-threw :exception <Throwable on the throw path, or :rf/redacted on a redacted row> :frame <frame-id> :transition-slot <the selected transition's spec-path discriminator, on a transition :action only>}, per Spec-Schemas §MachineActionRanTags;:decl-pathis the path of the node declaring the action —[]for a tree root, the machine root or a parallel region body — region-relative inside a region, so a tool addresses a root's own:entry/:exitwithout borrowing a state from the surrounding transition (per 005 §Trace events); success-with-nil-return collapses to:ok; the throwing path emits one trace with:outcome :rf.error/action-threw+:exceptionbefore propagating theresult/fail),:rf.machine.event/unhandled-no-op(the benign no-op when no transition matched —machines/transition.cljcfor flat / compound,machines/parallel.cljcfor the parallel-region aggregate; op-type:rf.machine, NOT an error),:rf.error/machine-spawn-unregistered-type(the always-on fail-closed reject of a spawn naming an unregistered:machine-id— emitted bymachines/lifecycle_fx/spawn.cljcon both the single-:spawnand:spawn-alljoin-init paths), plus the machine-error categories.routing.cljc—:rf.route/planned(the route-plan projection, emitted at the one navigation pipeline's commit branch inrouting/url_change.cljc, through therouting/resolve.cljcprojection-to-tags mapping),:rf.route/prefetched+:error :rf.error/prefetch-bad-address(the warm-mode intent-preload summary and its two pre-planning rejections; the emit site for both isrouting/prefetch.cljc— the ONE routing op that is deliberately not part of an activation drain, so it is accompanied by no:rf.route/planned/ nav-token / activation pair),:error :rf.error/replan-bad-request(the:rf.route/replan-resourcespre-planning rejection —routing/replan.cljc; a replan emits NO routing op of its own: its evidence is the Resources planner row below carrying:plan-cause :replan, and no:rf.route/planned/ nav-token / activation pair accompanies it either),:rf.route/fragment-changed(fragment-only navigation),:rf.route/registered/:rf.route/cleared/:rf.route/activated/:rf.route/deactivated(lifecycle pair),:rf.route/navigation-blocked(leave block),:rf.route/entry-denied(terminal entry denial),:rf.route.nav-token/allocated,:rf.route.nav-token/stale-suppressed,:rf.fx/skipped-on-platform(route-fx platform skips),:warning :rf.warning/route-shadowed-by-equal-score,:error :rf.error/can-leave-non-boolean,:error :rf.error/can-enter-non-boolean.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 attempt / 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 — an attempt's rows fromfetch-startedon, through its load-more, abort, completion,repliedandstale-suppressedrows, name it by:work/id+:resource/id, andwork-startedis the one that states the scoped:resource/keyit serves),:rf.resource/work-abort-requested,:rf.resource/work-completed,:rf.resource/succeeded,:rf.resource/failed(first-load) /:rf.resource/refresh-failed(background-refresh), the infinite-feed load-more family:rf.resource/load-more(events.cljc— a load-more issued the next-page fetch: the feed's:resource/id, resolved:page-param,:page-index/:page-countof the APPEND, generation, work id) /:rf.resource/page-appended(a page fetch succeeded and was appended::page-index, new:page-count, derived:next-page-param,:terminal?) — the pagination cursor (:page-param/:next-page-param) is an app-:next-page-param-derived free tag that can carry a record id, so on OFF-BOX trace egress it is owner-classified: the family-level projector (re-frame.resources.trace-egress) tokenizes it to a CONTENT-FREE{:rf/redacted <shape>}— a closed-vocabulary shape tag and a count, never a digest of the cursor (016 §The off-box redaction token carries no enumerable content) — when the row's resource OWNER is non-:serialize(sensitive / large / derived-sensitive / unregistered fail-closed — the SAME disposition that governs that resource's:resource/keyon its entry rows, read here from the row's:resource/id), riding verbatim for a plain feed; the trusted-local:rf.egress/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 owner leaks, and the page still fetches + appends; emitted once on EVERY branch — issue / skip / dedupe / no-feed — when(some? :owner)),:rf.resource/invalidated,:rf.resource/refetch-decision(the free:scopetag these two carry — as does:rf.resource/removedbelow, and every other row in the family whose:scopeis a concrete resolved scope — or an unresolved{:from-db …}reference, on theclear-scopefail-closed warning — sitting outside a scoped key;re-frame.resources.trace-egressis the authority on which rows those are — is NOT owner-classified the way the pagination cursor above is, and deliberately so: an invalidation sweep spans owners and aclear-scopeteardown outlives them, so the row carries no:resource/keyto read an owner claim from. The same family-level projector applies its SHAPE-driven fail-closed default instead::rf.scope/globalis a scalar and rides verbatim; a[tier {identity}]scope keeps its TIER keyword, so attribution survives, and tokenizes the identity MAP; a{:from-db …}reference tokenizes WHOLE, its resolver-id attribution surviving on the sibling:from-dbtag. Every one of those tokens is the CONTENT-FREE{:rf/redacted <shape>}the cursor gets, so per-scope joins do NOT survive off-box — distinctness across sensitive scopes is surrendered deliberately rather than bought with an enumerable digest, and the trusted-local:rf.egress/include-sensitive?opt-in is the only lift to the raw scope),:rf.resource/revalidate-scan(focus/reconnect scan summary; the per-entry refetches emit their own:rf.resource/work-started/fetch-started),:rf.resource/owner-released(events.cljc— emitted by BOTH the whole-owner:rf.resource/release-ownerand the same-owner subset:rf.resource/release-owner-identitiesthe replan planner dispatches;:releasednames the scoped keys actually released, so a subset release is read from the row, not from a discriminator),:rf.resource/stale-fired/:rf.resource/gc-fired/:rf.resource/gc-skipped/:rf.resource/poll-fired(events.cljctimer-fire path;poll-firedcarries the poll-tick:decision—:polled/:coalesced/:paused-hidden/:no-owner/:no-entry) +:rf.resource/stale-scheduled/:rf.resource/gc-scheduled/:rf.resource/poll-scheduled(timers.cljcarm path),:rf.resource/removed(events.cljc+registry.cljc),:rf.resource/stale-suppressed(the SINGLE suppression op),:rf.resource/route-plan(route.cljc— the ONE summary row per resource plan; this row has three shapes, and the discriminator is:plan-cause, whose members are:prefetchand:replan. Activation shape (:plan-causeABSENT — a route-entry plan on a navigation commit):{:route-id <LEAF target> :nav-token <token> :branch [parent-most … leaf] :ensured <count of ADDED identities — real ensures> :kept <count of ADOPTED identities — owner handoff, no fetch> :removed <count of prior-plan identities this plan drops> :blocking [<scoped resource key> …] :identities [<scoped resource key> …] :ensured-identities [<scoped resource key> …] :kept-identities [<scoped resource key> …] :removed-identities [<scoped resource key> …]}plus:redundant-childrenwhen the branch produced any (a vector of{:resource :scoped-key :ancestor {:route-id :local-id} :child {:route-id :local-id}}advisories — the redundant-child advisory in 016 §Effective parent-chain resource plans) and:plan-error trueon a failed plan.:ensured+:keptequals the size of:identitiesby construction, and each of the three counts is the size of its like-named vector: the vectors are the identity PARTITION the counts summarize, so ONE row answers which identity was ensured / kept / removed on this navigation without diffing two consecutive rows. They are named for what the runtime DID rather than for the diff — a retained-but-unusable identity takes the ordinary ensure path, so a vector named:addedwould disagree with the:ensuredcount beside it.:identitiescarries the planner's GROUPED PLAN ORDER (post-dedupe, each collapsed identity exactly once, in the order the plan executes),:ensured-identities/:kept-identitiesare that same order filtered.:removed-identitiespromises no meaningful order, and deliberately so — removal is not an ordered operation (a plan releases the whole prior owner in ONE effect), and no prior-plan order is even available to report: the routing handoff records a plan's identities as the unordered map{<key-id> <scoped-resource-key>}at[:rf.runtime/routing :resource-plan <nav-token>](Spec-Schemas §:rf/runtime-db) and hands that map back as the next activation's previous-identity input. The row is instead a pure function of the removal membership: it is derived from that map and emitted in the CANONICAL order of the CEDN-1key-idthe cache is already keyed on, so the same removal membership yields the same vector for every caller shape and on every host, and:removedis its size by construction. Deriving from the carrier's own iteration would NOT achieve that — a small ClojureScript map is array-map-backed and iterates in insertion order, so a sequential caller's ordering would walk straight back out — which is why the canonical sort, not carrier iteration, is the guarantee. The vector rides exactly as:blockingdoes, which is likewise a membership answer with no promised order. Every one of these vectors carries scoped resource keys,:blockingincluded, so none of them is named by the family projector's slot roster and all of them egress through its SHAPE-driven fail-closed default rather than riding raw.:blockingis the AT-COMMIT subset that still has to resolve, so an already-fresh blocking requirement is absent from it (the route projects:idlerather than a transient:loading). A FAILED plan reports the atomicity rule directly on the row::ensured 0,:kept 0,:blocking [],:identities [],:ensured-identities [],:kept-identities [], and every prior identity under:removed/:removed-identities— no partial ensure was dispatched, and the prior owner was still released. Warm shape (:plan-cause :prefetch— a warm-mode intent preload):{:route-id <destination> :plan-cause :prefetch :branch [parent-most … leaf] :ensured <count of unique requirements, all OWNERLESS ensures>}plus:plan-error trueon a failed warm plan. The warm shape carries none of:nav-token/:kept/:removed/:blocking/:identities/ the three identity vectors /:redundant-children— a preload owns no prior plan, allocates no nav-token, and cannot change readiness, so there is no diff to report and no blocking set to write.:plan-cause :prefetchand:nav-tokenare therefore mutually exclusive on this row, as are:plan-cause :prefetchand:blocking/:identities/ the identity partition. Replan shape (:plan-cause :replan— a same-token replan of the active route, 012 §Replanning the active route's resources): the activation shape in full —:nav-tokenPRESENT (the token that is staying), the three counts and the identity partition,:blocking,:redundant-children,:plan-error trueon a failed replan — plus:plan-cause :replanand:replan-cause, the caller's:causeverbatim.:keptcounts the identities adopted under the SAME owner with no request;:removed/:removed-identitiesname the identities the same-owner subset release dropped. A failed replan reads like a failed activation (:ensured 0,:kept 0, every prior identity under:removed) and additionally carries the two replan keys), 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-owner/release-owner-identitiesare 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, and:rf.error/malformed-schemawhen the validator THROWS on a structurally malformed schema form.validate-app-schema!emits both for:where :app-dbitself, isolating a malformed entry per-entry and rejecting the candidate fail-closed (per 010 §App-db schemas). Every other surface reaches them through the one validation seam,check!, published as the:schemas/check!late-bind hook (per 010 §The validation seam): the seam emits them forvalidate-event!/validate-fx!/validate-sub!and for each caller outside the artefact that asks it to —spec.cljc's:boundary? truecheck,subs/override_schema.cljc's:sub-overridepin andmachines/data_validation.cljc's:machine-outputpayload — and hands the same tags back unemitted to the surfaces that report under their own recovery (the three bullets afterspec.cljc's). There is no injection-timevalidate-cofx!— the live cofx schema surface isre-frame.cofx's:rf.error/cofx-value-invalid. The artefact also emits: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).router.cljc—:rf.error/malformed-schema(defensive backstop: the candidate-validation guard emits this category — with:rollback? true— and REJECTS the candidate transition fail-closed (treating the throw as a pass would be a fail-OPEN bypass) if a wholesale validator-machinery throw reaches its catch, so a throwing validator can neither install unvalidated state nor go invisible; a malformed REGISTERED schema is handled upstream per-entry inschemas.cljcand does not reach here).spec.cljc—:rf.error/schema-validation-failure :where :event :source :boundary(the DEV TRACE half of the:boundary? truecheck, which the seam emits on this namespace's call —:rf.error/malformed-schemainstead when the validator throws; per 010 §Production builds). The refusal does not emit the always-on half itself — the router stamps:rf/boundary-rejected?on the context, sorouter.cljc's pipeline tail (emit-boundary-rejection-record!) owns the structural record that survives production — one emit site for the dev and production enforcement routes both.machines/data_validation.cljc—:rf.error/schema-validation-failure, or:rf.error/malformed-schemawhen the validator throws, at:where :machine-data, from the seam's tags; the:rollback? truearm fans its:errors-stream record first, then emits the trace.routing/url_change.cljc—:rf.error/schema-validation-failure, or:rf.error/malformed-schemawhen the validator throws, at:where :event, when a named address'sroute-urlthrows on a schema failure;routing/registry.cljcbuilds that throw's ex-data from the seam's tags. Any otherroute-urlrefusal of a named address reports that throw's own id at:where :event(:rf.error/no-such-route,:rf.error/missing-route-param,:rf.error/route-url-validation,:rf.error/route-url-non-edn-value).cofx.cljc—:rf.error/malformed-schemaat:where :cofx, when a recordable coeffect's:schemamakes the validator throw, ahead of the always-on:rf.error/cofx-value-invalidthe same check throws.events.cljc—:rf.error/at-boundary-missing-schema(thrown fromreg-event-*when a handler declares:boundary? truebut its metadata-map carries no:schemaKEY; per 010 §Production builds).events.cljc—:rf.error/reg-event-bare-interceptor(thrown fromreg-eventwhen a BARE interceptor — a map carrying:before/:after— is handed where metadata:interceptorswas required, e.g.(reg-event id mw/some-interceptor handler)instead of(reg-event id {:interceptors [mw/some-interceptor]} handler); an interceptor is a map, so the bare form would read as the metadata-map and silently drop the chain — the loud rejection is the realisation of Conventions §No silent swallow); and the retired-name hard errors:rf.error/reg-event-db-removed/:rf.error/reg-event-fx-removed/:rf.error/reg-event-ctx-removed.interceptor_registry.cljc(registered interceptors — reference-only) — the registration- and resolution-time throws:rf.error/invalid-interceptor(reg-interceptor*— a malformed descriptor, or a migration-value:idmismatch),:rf.error/unregistered-interceptor(resolve-ref— an event/frame chain references an unregistered interceptor id; validated atreg-event/make-frameregistration and re-guarded at dispatch-time chain assembly),:rf.error/invalid-interceptor-ref(resolve-ref/resolve-chain— a chain entry that is neither a ref nor an interceptor value),:rf.error/inline-interceptor-removed(resolve-chain, andevents.cljcat registration — an INLINE interceptor value in a chain position; chains are reference-only), and:rf.error/interceptor-factory-arity(resolve-ref/resolve-factory— a parameterized ref to a non-factory, a bare ref to a factory, or a factory that cannot build for the arg). All thrown ex-info, not traces — dev-trace-only registration / chain-assembly validation.ssr.cljc—:rf.error/sanitised-on-projection.substrate/spine.cljs+ Fresco'simpl/mount.cljs—:warning :rf.ssr/hydration-mismatch(the adoption reporter every hydrating root installs: carries:errorand:where; per 011 §Hydration-mismatch detection).epoch.cljc—:rf.epoch/snapshottedper dequeued event (one per epoch),:rf.epoch/restoredon restore success,:rf.epoch/db-replacedon pair-tool injection (replace-frame-state!, the ONE partial-map mutator) success, plus the six restore-failure categories and the four injection failure categories (:rf.error/replace-frame-state-bad-keys,:rf.epoch/replace-during-drain,:rf.epoch/replace-schema-mismatch,:rf.epoch/replace-history-disabled), plus:rf.epoch.cb/listener-exception(op-type:error) emitted once per broken-listener invocation when an epoch listener throws — the isolation contract 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. The same wrapper additionally fires:rf.view/renderedcarrying run attribution (:rf.view/id,:frame,:rf.view/render-key, plus — when an in-flight run buffer is available —:rf.view/cause-event-idand:rf.view/cause-subs).:rf.view/renderedALSO carries:rf.view/mount?(a boolean —trueon the component instance's first render,falseon every subsequent re-render) and, when the view derefs any subs,:rf.view/deref-subs(the vector of subscription query-vectors THIS view read during the render — its OWN per-view read-set, distinct from the run-wide:rf.view/cause-subswhich over-reports). The wrapper ALSO emits the: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) composes them; these ops/fields ride the same per-render / per-instance-teardown path.:rf.view/renderedis capped at 100 per run with a one-shot:rf.view/rendered-cap-reachedmarker (carries:frame+:rf.view/dropped-after) to bound the per-run buffer's heap budget for full-page re-render storms (:rf.view/unmountedis NOT capped — one emit per instance teardown). Consumers (Xray Reactive / Views panels) walk the per-run buffer's:rf.view/renderedentries to graph cause→effect attribution and read:rf.view/mount?/:rf.view/deref-subs/:rf.view/unmountedto label each view's mount-vs-rerender-vs-unmount ACTION and its per-view reactive REASON.adapter/context.cljs—:rf.error/frame-context-corrupted(function-component_currentValueread observed a non-coercible shape).frame.cljc(provider-arg validation) —:rf.error/bad-frame-provider-arg(a publicframe-providerwhose:frameis non-nil but neither a keyword nor a live frame value; rides the always-on error-emit axis via the:error-emit/dispatch-on-errorlate-bind hook, then throws. Validated at every public provider entry point — Reagentre-frame.views.provider/frame-providerand the shared UIx-spine 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-frame-target). The split frame-boundary components additionally validate their mount opts at the entry points (diagnostic-channel, thrown ex-info, viare-frame.views.frame-boundary): the ENSURE component (rf/frame-root {:id …}) rejects a missing/non-keyword:idwith:rf.error/frame-root-missing-id, a stray:framewith:rf.error/frame-root-given-frame, and a mounted:id/opts change with:rf.error/frame-root-reconfigured; the SCOPE-only component (rf/frame-provider {:frame …}) rejects an absent frame with:rf.error/frame-provider-frame-absentand a stray:idwith:rf.error/frame-provider-given-id.subs.cljc(unsubscribeargument validation) —:rf.error/bad-unsubscribe-arg(a query vector handed tounsubscribe, which takes the reactionsubscribereturned; rides the always-on error-emit axis via the:error-emit/emit-error-bothlate-bind hook, then throws; recovery:supply-reaction).substrate/adapter.cljc—:error :rf.error/write-after-destroyemitted by thereplace-container!wrapper when called with a nil container (the frame was destroyed mid-drain or before a scheduled write fired; the underlying adapter'sreplace-container!is NOT invoked). Rides the always-on error-emit axis via the:error-emit/dispatch-on-errorlate-bind hook — the substrate cannot static-requirere-frame.error-emit. Per 006 §replace-container!.http/*.cljc(the optional HTTP artefact, per the file split — each emit below names its file) —:warning :rf.http/cljs-only-key-ignored-on-jvm(http/transport_jvm.cljc),:info :rf.http/issued(http/handlers.cljc),:info :rf.http/retry-attempt(http/transport.cljc),:info :rf.http/aborted-on-actor-destroy(http/registry.cljc; per 014 §Abort on actor destroy), and fromhttp/middleware.cljc:info :rf.http.interceptor/registered,:info :rf.http.interceptor/cleared,:error :rf.error/http-interceptor-failed(an interceptor's:beforeor:afterthrew, per 014 §Middleware) and the thrown:rf.error/unregistered-interceptor(capture-chain— the frame's:http-interceptorsnames an id with no definition, so the request fails at issue), plus the Spec 014 failure categories.
User code can also emit traces — re-frame.trace/emit! is public and re-exported as rf/emit-trace-event!.
Production builds: zero overhead, zero code¶
Dev-side instrumentation is a development-only concern. In production builds the trace surface, the dev-time arms of the schema validation surface (Spec 010) — the validate-*! family that a programmer's own :schema declarations feed — and the registrar's hot-reload trace emit (:rf.registry/handler-{registered,replaced,cleared}) are all compile-time eliminated through a single shared gate. The closure compiler's dead-code elimination removes the gated branches, and nothing behind that gate reaches a production binary.
What sits behind the gate is settled by what the check is for, not by who declared the schema it reads (per C-000.35). An ordinary registration diagnostic is a development aid, so it elides. A check the framework relies on to keep a promise of its own — a handler's :boundary? true schema, a recordable coeffect's durable-value contract, a declared route's shape, the reserved :rf.server/* effects' own arguments — is not behind the gate and behaves identically in both builds, whoever wrote the schema it consults: the recordable coeffect's :schema comes off the programmer's own reg-cofx, and survives because of where the framework applies it. The always-on error- and event-emit substrates sit outside it too; §What IS available in production enumerates what a release build still carries. Surviving is not the same as reporting, and the two do not always travel together: a check can run in a release build while the rich dev trace above it elides.
The mechanism: re-frame.interop/debug-enabled? (alias of goog.DEBUG)¶
The CLJS implementation uses one shared flag — an alias of the standard goog.DEBUG closure-define — for every dev-only branch:
Every framework-internal dev branch — trace/emit!, trace/emit-error!, schemas/validate-app-schema!, schemas/validate-event!, schemas/validate-fx!, schemas/validate-sub!, and the registrar/{register!,unregister!,clear-kind!} trace emits — wraps its body in (when interop/debug-enabled? ...). With :advanced compilation and :closure-defines {goog.DEBUG false}, Closure constant-folds the gate and DCEs every dependent allocation: trace maps, listener iteration, malli calls, error reason strings, the Performance API bridge.
;; user's shadow-cljs.edn — production build
{:builds {:app {:target :browser
:output-dir "..."
:compiler-options {:closure-defines {goog.DEBUG false}}}}}
(Most production CLJS builds already set goog.DEBUG=false; re-frame2 piggybacks on the canonical CLJS production flag rather than introducing its own.)
The gate must be the outermost form of the body. (when interop/debug-enabled? ...) and (if interop/debug-enabled? <body> <else>) constant-fold reliably; (when (and X interop/debug-enabled?) ...) does NOT — Closure can't statically rule out X, and the dead branch survives into the bundle. The verifier (see §Production-elision verification) catches that mistake.
A reachable but dead branch in a production bundle:
- Allocates no trace event maps.
- Holds no listener registry beyond the (small)
defoncecells (which carry{}and0). - Never invokes listener predicates.
- Excludes the per-frame trace rings (the event-keyed payload).
- Excludes the Performance API bridge.
- Excludes the schema validation entry points and their malli/explanation calls.
How users opt in (dev builds)¶
CLJS dev builds default to goog.DEBUG=true — every gate stays live with no extra configuration. A user who wants trace machinery in a :advanced artefact (rare) can flip the flag explicitly:
User-side listener registration¶
User-side (rf/register-listener! ...) calls should also elide in production. Wrap them with the same predicate the framework uses:
(when ^boolean re-frame.interop/debug-enabled?
(rf/register-listener! :trace :my/listener callback-fn))
In production (goog.DEBUG=false), re-frame.interop/debug-enabled? is the constant false, the when is dead, and the entire registration is elided.
The same pattern applies to (rf/register-listener! :epoch …), trace-buffer, clear-trace-buffer!, and (rf/configure! {:trace-buffer {:events-retained N}}) — every dev-only call site in user code should sit under the when ^boolean re-frame.interop/debug-enabled? guard.
JVM builds¶
Cross-reference: see Security.md §Production gates — A JVM that hosts SSR defaults the gate
false(011 §The debug gate on an SSR JVM); webhook and other long-running JVMs facing untrusted input MUST set itfalseexplicitly 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)) ;; unset: false on an SSR host, true elsewhere
…reads the gate ONCE at namespace load. Unset, it defaults false on a JVM that hosts SSR (a classpath carrying re_frame/ssr_host.edn, which the ssr-ring artefact ships) and true (dev parity) everywhere else. An explicit setting wins both ways, so -Dre-frame.debug=true is the dev opt-in on an SSR JVM (011 §The debug gate on an SSR JVM) — 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 read true, 1, yes, on as on and false, 0, no, off, empty string as off, case-insensitively, with whitespace trimmed. Anything else — including absent / unset — takes the JVM's default, so a typo (disabled, nope) neither opts an SSR JVM into dev posture nor silently disables a dev JVM.
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.- Epoch (per Tool-Pair §Time-travel) installs nothing at load, so it records no
:frame-state-before/:frame-state-after/:trace-eventspayloads, fires no:epochlisteners, and refusesrestore-epoch!/ the pair-tool injection surface (replace-frame-state!).
What remains live (always-on by construction). Disabling the gate does NOT silence the production-survivable surfaces:
- The event-emit substrate (which the
:observability :handled-eventssink rides, per §What IS available in production #1) keeps firing. - The error-emit substrate (which the
:observability :errorssink rides, per §What IS available in production #2) 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: an SSR / headless JVM process running re-frame2 should not retain user input in per-frame trace rings or epoch history. An SSR JVM is in that posture by default, and its dev and test runs opt in with -Dre-frame.debug=true; any other JVM artefact shipped for production should set -Dre-frame.debug=false in its deployment, while its dev and test keep the default true.
Production-elision verification¶
The contract above is enforced by an automated test in CI:
implementation/core/test/re_frame/elision_probe.cljsis a probe namespace that exercises every gated surface —register-listener!,emit-trace-event!, the per-frame trace rings (trace-buffer/clear-trace-buffer!/(configure! {:trace-buffer {:events-retained N}})),validate-{app-db,event,sub-return,cofx}!,register!/unregister!/clear-kind!, the epoch surface ((rf/register-listener! :epoch …)/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.
A new dev-only surface follows the same pattern:
- Wrap its dev-only body in
(when interop/debug-enabled? ...), outermost. - Touch the surface from
re-frame.elision-probeso the DCE graph reaches it. - Add a sentinel to
DEV_ONLY_SENTINELSincheck-elision.cjs(a string literal or keyword name that only the gated branch contains).
Production debugging: what remains¶
The elision contract above is uncompromising — in a :advanced build with goog.DEBUG=false, the entire trace surface disappears. That decision is correct for binary-size and hot-path cost, but it has consequences for post-mortem debugging that this section makes explicit so users aren't surprised when a production incident leaves them with thin tooling.
What is NOT available in a default production CLJS build¶
A :closure-defines {goog.DEBUG false} :advanced build carries no trace machinery. Concretely, the following surfaces have been DCEd and the runtime cannot reach them at all:
register-listener!/unregister-listener!— listener registration is a no-op because the gate aroundtrace/emit!is constant-folded out. Even if user code registered a listener at boot (which it shouldn't, per §User-side listener registration), nothing would ever invoke it.- The per-frame trace rings (
trace-buffer,clear-trace-buffer!,(configure! {:trace-buffer {:events-retained N}})) — pulling "the last N events from a prod session" is not supported. The ring'sswap!site is inside the same elision gate. - The
:epochlistener stream and the per-event:rf/epoch-recordassembly — the record assembly, ring-buffer append, and listener publication run inside the trace surface and elide with it, so a production build produces no records. (The purerf/project-egressegress transform is itself ungated and stays callable on an already-held or synthetic record; a production build simply assembles no record for it to project.) - Every
:rf.error/*,:rf.warning/*,:rf.info/*,:rf.fx/*,:rf.ssr/*, and:rf.epoch/*trace event documented in §Error event catalogue. They are not emitted, not buffered, and not deliverable to any listener. (The always-on error-emit substrate is a documented exception: it survivesgoog.DEBUG=false, fanning out one tight record per promoted runtime:rf.error/*(the categories meeting the promotion criterion, not every production-reachable one) to the frame:observabilitysink route. 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. One arm of the first is narrower than it looks. A handler that declares:boundary? truehas its own:schemachecked ungated, so that check still runs in this build (per 010 §Production builds), and so does the always-on record the router's tail fans for it: what elides is only the richemit-error!dev trace carrying the offending value. So a boundary rejection in this build both happens and reports — as a structural record on the:errorsstream and:outcome :rejectedon the:handled-eventsone. See the §Error event catalogue paragraph on the category. - Xray and the re-frame2-pair server (per Tool-Pair.md). These are dev-only tools that attach to the trace surface; they are not designed for, and not shippable to, production. The Xray preload artefact must not be on a production build's classpath; the re-frame2-pair server lives in its own dev-only artefact for the same reason.
What IS available in production¶
Cross-reference: see Security.md §Production gates for the framework-wide threat-model entry — the two always-on substrates below are the production-survivable surface; everything in §What is NOT available in a default production CLJS build is dev-only.
Five surfaces survive elision and are the canonical production-debugging fallbacks:
-
The handled-event observation surface — the frame-owned
:observability :handled-eventssink, declared on the frame or once per process with(rf/configure! {:observability …})and wired withregister-observability-sink!— is always-on, NOT gated byre-frame.interop/debug-enabled?. After the cascade settles the router fans out once per processed event to two siblings: this sink route, and the implementation-tier event-emit registry (parallel to the error-emit substrate in #2; see API.md §Event-emit). The sink does not ride the registry. The registry's 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:okto an off-box shipper::ok(the event's state committed, its flows ran and its:fxwalk completed — that is all:okcertifies, not that every effect succeeded: effects are best-effort and an issued effect cannot be rolled back, so an effect that throws is caught, reported on its own as:rf.error/fx-handler-exceptionon the errors stream, and its later siblings still run),:error(the interceptor chain — handler or interceptor — threw, or a coeffect supplier or recordable generator threw at context assembly, before the chain ran; either way nothing is installed and:fxis skipped),:rolled-back(candidate:db/ machine-data schema validation REJECTED the transition BEFORE install per 010 §Per-step recovery row 4 — the container was never written and keeps its pre-handler value; flows and:fxwere skipped.:rolled-backis the STABLE public vocabulary fortransaction rejected; it does not imply a physical write-pair. This member has no producer in a production build. The record itself is always-on, but its only producer is the candidate validator, which is dev-only per 010 §Production builds — so in a release build a candidate that violates a registered schema simply installs and the dispatch reports:ok. Do not wire a production alert to a quiet:rolled-backcount and read it as "no schema violations"; it is quiet by construction),:flow-error(a flow's:outputthrew per 013 §Failure semantics rule 3 — the cascade halted before:fx), or:rejected(the:boundary? truecheck REFUSED the event's payload against the handler's own:schemaper 010 §Production builds — the handler never ran; entered interceptors still unwound in full, and nothing they staged installed. Neither other non-:okvalue fits::errormeans the interceptor chain or a coeffect supplier threw, which neither did, and:rolled-backmeans a candidate transition was refused before install, but a refused event builds no candidate. Reporting:okwould be a lie — the stream's own contract is that an aborted dispatch is never reported as:ok.:rejectedis the exact COMPLEMENT of:rolled-backon the production question, and reading the two together is what stops the warning above generalising to the whole vocabulary.:rolled-backhas no producer in a release build;:rejecteddoes, because boundary validation is ungated per 010 §Production builds — so this is the outcome to alert on for hostile or malformed input at an untrusted ingress. Boundary validation is not the only schema check a release build keeps — what may be elided is settled by what the check is for, not by who declared the schema it reads, and the framework's other load-bearing checks are ungated alongside it (per C-000.35, restated with the full set at the head of this section). It is, however, the only one this:outcomevocabulary names in its own right: the others throw on their own paths, and a throw reports:error. It is paired with one always-on:rf.error/schema-validation-failurerecord (:source :boundary) on the:errorsstream, which carries the identifiers; the offending value never egresses). The chain-exception path reports:erroreven when a downstream rejection would also apply: it is the proximate, most-actionable signal, and a chain throw short-circuits the:dbcommit so there is no candidate to validate.:rejectedis the LOWEST-priority discriminator for the same reason read the other way — a chain throw, a flow throw or a malformed effect map during the unwind all still win, and it is reported only when the boundary skip is the whole story. The registry record's:eventis projected ONCE before fan-out with off-box defaults, in two steps: the event registration's own:sensitive/:largepaths, resolved through the frame's own view, thenre-frame.elision/elide-wire-valueagainst the frame's durable classification (large →:rf.size/large-elided; sensitive →:rf/redacted). Per-listener exceptions are caught inside the substrate so a buggy listener cannot break the cascade or block sibling listeners. The sink receives its own:rf.observe/handled-eventrecord,{:kind :frame :event-id :time :outcome :elapsed-ms :effects :correlation}, already PROJECTED under the owning frame's classification and the entry's egress profile. Its:timeand:outcomeare the registry record's values for the same event, so the:outcomevocabulary above is the sink record's too.:eventrides it only when the entry opts sensitive content back in, and is then projected the same two ways. Registration sites SHOULD use^boolean re-frame.interop/debug-enabled?as a belt-and-braces gate alongside the user's explicit config flag:(rf/configure! {:observability {:handled-events [{:sink :datadog/forward}]}}) (when (and (= "production" (:env config)) (not ^boolean re-frame.interop/debug-enabled?) (:api-key config)) (rf/register-observability-sink! :datadog/forward (fn [event-record] (datadog/track event-record))))Catches the "accidentally deployed a dev bundle with prod config" bug class.
-
The error observation surface — the frame-owned
:observability :errorssink, declared on the frame or once per process with(rf/configure! {:observability …})and wired withregister-observability-sink!(see API.md §Error-emit for the underlying record) — sibling of #1, runs through the always-on error-emit substrate. NOT gated byre-frame.interop/debug-enabled?. The runtime fans out one record per catalogued promoted runtime:rf.error/*event — handler / interceptor / cofx exceptions, flow exceptions, fx-handler exceptions, reserved-fx typed throws, reactive- andcompute-sub-resolution exceptions, the machine action / guard exception:rf.error/machine-action-exception(a throwing action / guard /:on-done/ destroy-:exitaction — a NON-EVENT union record fanned through the:error-emit/dispatch-error-recordhook, sincemachinesships above core's require graph), and the invalid-operation categories:rf.error/frame-destroyed,:rf.error/no-such-handler,:rf.error/no-such-sub,:rf.error/no-such-fx,:rf.error/unregistered-cofx,:rf.error/override-fallthrough, the suppressed-write category:rf.error/write-after-destroy, and the teardown-discrimination category:rf.error/on-destroy-handler-exception. (Dev-only-validation / registration-time categories — dev schema checks, machine-unresolved-guard — stay dev-trace-only and do NOT survive elision; that is correct, not a gap — plus, in a DEVELOPMENT build only, the dev-gated candidate-rejection records named under §The promotion criterion, which are present exactly when their check is and absent from a release build with it.) The frame-teardown report:rf.error/frame-teardown-failedalso 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-failuresvector instead of the per-event:event/:event-idslots — 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 —{:rf.error/id <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-handlerride the dev-only trace surface and elide with it). Attribution is a RULE, not a per-category enumeration: every always-on error record carries:failing-id <kw>naming the failing COMPONENT's own code identifier whenever that component is DISTINCT from (or more specific than) the dispatched event — plus, where the category defines them,:reason <string>and category-specific structural identifiers. The interceptor / cofx / machine categories are instances of the one rule::rf.error/interceptor-exception(:failing-id= the failing interceptor id),:rf.error/coeffect-exception(:failing-id= the failing cofx supplier id), and:rf.error/machine-action-exception(:failing-id= the throwing action / guard keyword, PLUS:state= the active state path — machine-local code identifiers in the same privacy class as:event-id). The:event-idslot carries the EVENT id for these categories, so without:failing-idan off-box shipper would see the category but not WHICH interceptor / cofx / action / guard failed (the classified component id otherwise rides only the dev-trace tags, which DCE undergoog.DEBUG=false).:failing-idis present whenever a distinct component id exists — for:rf.error/handler-exceptionand the:rf.error/sub-*categories the failing id already EQUALS:event-id(the handler IS the event; the sub-id rides:event-id), so no separate:failing-idis added there; an anonymous inline machine action / guard (no keyword identity) falls back to the actor instance id, with:statestill attributing WHICH state's transition threw. 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). This is the single error-observability surface; recovery is the framework's typed per-category default and is NOT app-steerable (there is no per-frame:on-errorrecovery policy). Per-listener exceptions are caught inside the substrate so a buggy sink cannot block siblings or the cascade. 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 handled-event pattern in #1:The substrate record carries the raw
:exception; the frame-owned sink route PROJECTS it. There-frame.error-emitsubstrate record carries the raw:exceptionobject, because a post-mortem shipper (Sentry / Honeybadger / Rollbar) needs the host throwable and its stack. That 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. That substrate is IMPLEMENTATION tier and carries no public registration verb — independent corpus observation regardless of a frame's policy is not a public primitive. The production observation surface is the frame-owned:observability :errorssink (Spec 015 §Frame-owned observability sink policy): EVERY production-reachable:rf.error/*record routes there, where the runtime PROJECTS the record under the owning frame's classification and the sink's egress profile BEFORE the sink sees it (sensitive paths redacted,:exceptiondropped under:rf.egress/public-error). This holds for BOTH the event-centric records (dispatch-on-error!→observability/route-error!) and the NON-EVENT union records — the frame-teardown report and the promoted SSR categories (dispatch-error-record!→observability/route-error-record!, which lifts the flat category slots onto a projected:tagstree-key so declared sensitive paths inside them are redacted under frame policy; a nested throwable within that tree — the per-step:exceptionon a:hook-failuresentry — is an opaque host value the walker passes through unchanged, so it is NOT sanitised, and:rf.egress/public-errordrops only the record's top-level:exception).:exceptionis not what separates the two doors. The sink also delivers:exceptionunder the default:rf.egress/off-box-observabilityprofile, and:rf.egress/local-rawis the explicit opt-in for the raw shape. Two classes of record land on the process default: (i) a FRAMELESS record (:frame nil— the pre-frame SSR hydration-parse path), which carries no frame-owned sink policy by definition, and (ii) a record whose frame incarnation is already dead — the per-frame sink route stays suppressed so a dead frame's bare id cannot resolve to a same-id successor's sink, and(rf/configure! {:observability …})takes delivery instead, under an explicitly nil governing frame.Off-box shippers wire the sink. The off-box production path for Sentry / Honeybadger / Rollbar is the frame's
:observability :errorssink (Spec 015 §Frame-owned observability sink policy): declare it on the frame config, register the concrete sink fn withregister-observability-sink!, and the runtime hands the sink an already-PROJECTED record (sensitive paths redacted,:exceptiondropped under:rf.egress/public-error) — no sink-local redaction, no raw owner-local data crossing the trust boundary.;; 1. Declare the frame's error-observability policy. (rf/make-frame {:id :rf/default :observability {:errors [{:sink :my-app.sinks/sentry :rf.egress/profile :rf.egress/off-box-observability}]}}) ;; 1b. Classify durable app-db paths beside the feature (there is no ;; frame `:sensitive {:app-db …}` annotation). (rf/reg-classification :app/auth {:sensitive [[:auth :token]]}) (rf/reg-event :app/login-succeeded (fn [_ [_ token]] {:db (assoc-in {} [:auth :token] 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 [record] (sentry/capture-event record))))A cross-frame seat is the same grammar declared once per process rather than a second door.
(rf/configure! {:observability …})covers every frame that does not declare the stream itself, and it is where a frameless or dead-incarnation record lands. A shipper that genuinely wants the substrate's raw shape asks for it with a profile:;; Cross-frame, declared once; :rf.egress/local-raw asks for the raw shape. (rf/configure! {:observability {:errors [{:sink :sentry/forward :rf.egress/profile :rf.egress/local-raw}]}}) (when (and (= "production" (:env config)) (not ^boolean re-frame.interop/debug-enabled?) (:dsn config)) (rf/register-observability-sink! :sentry/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 a process-wide events+errors seat; a frame that wants its own policy declares the stream on itself and shuts the default out for that stream.
-
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 anyPerformanceObserver— including the host APM's — receives. This is the production observability surface re-frame2 ships and supports. - The SSR error-projector boundary (per 011 §Server error projection) — on the server (JVM/SSR),
re-frame.interop/debug-enabled?defaults totrue(per §JVM builds), so the trace surface is live unless the operator opts out. The runtime emits structured:rf.error/*traces, the registered error projector consumes them, and the locked:rf/public-errorshape 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. - Native browser machinery —
window.onerror/window.onunhandledrejectionremain live, but they observe only throws OUTSIDE the cascade: view/render code, async callbacks and timers, third-party libraries, and caller-observed pure throws at the native error boundary. They see NO failure the cascade captured. The interceptor chain records a handler / interceptor throw into:rf/interceptor-error, and context assembly captures a coeffect-supplier throw, rather than re-throwing (the drain must not abort — the router'semit-pipeline-exception!states this in terms), so the exception propagates out of neitherdispatchnordispatch-syncand never reacheswindow.onerror. The same holds for every framework refusal —:rf.error/no-such-handlerfor a typo'd event id,:rf.error/effect-map-shapefor a foreign top-level effect key, an unregistered fx: dispatch returns normally and the record fans out through the error observation surface (#2), which is the ONLY channel it reaches. With no sink declared, nothing throws — that half is unconditional and holds in every build, on every host. What does not hold is "nothing prints." In a dev build, on any host — browser, Node-targeted CLJS, CLJS SSR and the JVM — a promoted error record that NOTHING ROUTED is ADDITIONALLY printed as one line:[re-frame2] <category> — <message>followed by the event or sub id, the frame and the source coordinate where the record carries them ([re-frame2] :rf.error/no-such-handler (event :todo/toggel, frame :app)), every part read off the record and no event payload. A browser host prints it withconsole.error("[re-frame2]", line, record, exception), the record as a structured value and the original exception as its own separate argument when the category carries one; a non-DOM JS host printsconsole.error("[re-frame2]", line); the JVM writes the line to*err*. So a typo'd event id is not literally nothing on a developer's screen or at a REPL, wheredispatch-syncreturnsnilwhether the handler ran or failed. It is a fallback, not a sixth channel, and two conditions gate it:re-frame.interop/debug-enabled?(so:advanced+goog.DEBUG=falseconstant-folds the whole body away, and a JVM run with-Dre-frame.debug=falseprints nothing), and NOTHING HAVING ROUTED THE RECORD. The second is the one an author cannot re-derive, and it is two arms rather than one. A record is OWNED when (a) a listener on the implementation-tierre-frame.error-emitregistry CLAIMED it — a listener claims a record by returning exactlytruefrom that delivery, so one that returns anything else, or throws, owns nothing, and registration alone owns nothing; that arm is the framework's own synchronous-window capture sites and tests rather than an app-facing door, and each claims only the record it turns into its own outcome (a setup step the pre-commit failure it re-raises as:rf.error/initial-events-step-failed, an:on-destroywindow the frame's own handler exception it re-emits), so a typo'd:initial-eventsstep still prints its:rf.error/no-such-handlerline — or (b) the record's governing policy (the frame's:observability :errorsdeclaration, else theconfigure!process default) resolved at least one entry to a REGISTERED sink fn, which was invoked. The fallback fires iff NEITHER holds. The two arms earn ownership differently on purpose: a listener owns only the records it claims, one at a time, while a frame's sink policy owns that frame's own records by delivering them, so a sibling frame on the same page that neither declared a policy nor inherits a process default keeps its console line. Declaring a policy is not routing — an entry naming a sink the app never registered routes NOWHERE and does not own the record (fail-visible: the console is the only channel it has left), while a registered sink that THROWS does own it, because delivery is the ownership; a throwing or non-claiming listener does not. That self-suppression IS the off-switch, there is no separate knob, and unregistering the last sink a policy names resumes the fallback. Xray does not populate the listener registry (it rides the dev-only trace axis), so a console line may coexist with an Xray row. The no-silent-swallow warnings get the same fallback on the trace axis, which is their only channel: in a dev build, on any host, a:rf.warning/*that Conventions §No silent swallow's per-surface table names, delivered while no:tracelistener is registered, prints[re-frame2] <id> — <:reason, else :hint>—console.warnon a JS host,*err*on the JVM. "No listener" is read off the same listener snapshot the delivery fans out to, so a printed warning is exactly one no listener received. Any:tracelistener — Xray, Story, or oneregister-listener!call — receives the warning instead and the line stands down; every other warning stays trace-only. A sink remains the contract: wire the frame:observabilitysink, or theconfigure!process default, before counting on any framework failure being visible anywhere, in production or under a build that elides the fallback.
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-listener! :epoch/unregister-listener! :epoch(§Assembled-epoch listener, Tool-Pair §Time-travel). - Handled-event sink —
register-observability-sink!against a frame's:observability :handled-eventspolicy or theconfigure!process default (API.md §Event-emit). - Error sink —
register-observability-sink!against a frame's:observability :errorspolicy or theconfigure!process default (API.md §Error-emit). - Performance API channel —
performance.measurebrackets (options-bag form, cleared after emit) (§Performance instrumentation).
Each surface sits in exactly one of three production postures:
- dev-only DCE — gated on
re-frame.interop/debug-enabled?(alias ofgoog.DEBUG, defaulttruein dev /falsein:advancedprod CLJS, on the JVM defaultfalsewhen it hosts SSR andtrueotherwise, overridable by-Dre-frame.debug). Compile-time eliminated in production; allocates zero in the bundle. Per §Production builds. - always-on — runs through a small substrate that survives
goog.DEBUG=false(and survives-Dre-frame.debug=falseJVM-side). Tight record shape, post-elision (large →:rf.size/large-elided; sensitive →:rf/redacted), per-listener exceptions isolated. Designed for production observability without preserving the dev-only trace surface. - opt-in goog-define — gated on an independent compile-time flag distinct from
goog.DEBUG. Default off; consumer flips the flag explicitly via:closure-defines. Production bundles that don't opt in carry zero instrumentation; those that do retain the surface even withgoog.DEBUG=false.
Posture × surface matrix¶
| Surface | Dev (goog.DEBUG=true) |
Nightly (goog.DEBUG=true + events-retained tuned) |
Production (goog.DEBUG=false) |
Posture |
|---|---|---|---|---|
1. Raw trace listener (register-listener!) |
live — full structured trace stream, every :op-type (:rf.event, :rf.sub, :rf.fx, :error, :warning, :rf.machine/*, :rf.flow/*, …), dev-side enrichments (:rf.trace/trigger-handler source-coord, :rf.trace/dispatch-id / :rf.trace/parent-dispatch-id correlation, :rf.event/origin tag) |
live — same as dev; tune per-frame event retention via (rf/configure! {:trace-buffer {:events-retained N}}) (or per-frame :rf.trace/events-retained metadata) for long-tail traces |
elided — emit! gate constant-folded; registration is a no-op, listener never invoked; zero allocation in bundle |
dev-only DCE |
2. Assembled-epoch listener (register-listener! :epoch) |
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}}) controls retention |
live — bump :depth for longer post-mortem windows; redaction applies only at off-box egress inside rf/project-egress — never at ring-append / listener fan-out (the ring and this listener retain the raw record) |
elided — projection runs inside the trace surface and elides with it; epoch ring records nothing, listeners never fire, restore-epoch! / the pair-tool injection surfaces refuse |
dev-only DCE |
3. Handled-event sink (register-observability-sink!) |
live — one :rf.observe/handled-event record per processed event: {:kind :frame :event-id :time :outcome :elapsed-ms :effects :correlation}, projected under the owning frame's classification and the entry's egress profile; :event only when the entry opts sensitive content back in, and then projected |
live — same record shape; no tuning knobs | live — survives goog.DEBUG=false; identical record shape and projection; per-sink 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 sink (register-observability-sink!) |
live — one :rf.observe/error record per catalogued promoted runtime :rf.error/* event (handler / interceptor / cofx / flow / reserved-fx exceptions, reactive- & compute-sub exceptions, the parametric sub-input materialization categories :rf.error/sub-input-fn-exception / :rf.error/sub-input-fn-bad-return, :rf.error/frame-destroyed, :rf.error/no-such-handler, :rf.error/no-such-sub): {:kind :frame :rf.error/id :event-id :event :exception :elapsed-ms :time}, plus :tags, :correlation and the :failing-id / :flow-id / :where / :source-coord attribution slots when the error carries them, projected under the owning frame's classification and the entry's egress profile (:rf.egress/public-error drops :exception). The single error-observability surface; recovery is the framework's typed per-category default, not app-steerable (there is no per-frame :on-error recovery policy) |
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 a PerformanceObserver or the DevTools Performance panel (performance.getEntriesByType('measure') also needs retain-entries? on) |
default off — same as dev; opt in for nightly perf regression catches | default off — bracket sites DCEd. Apps that want production timing observability opt in via the same :closure-defines flag; brackets at the four hot paths (:event, :sub, :fx, :render) emit User-Timing measure entries readable by any PerformanceObserver including the host APM |
opt-in goog-define |
Posture-row reading. A surface in the dev-only DCE row is gone from production bundles — no listener, no allocation, no overhead. A surface in the always-on row keeps firing under goog.DEBUG=false; the record is tight, post-elision, exception-isolated. A surface in the opt-in goog-define row is gone by default but recoverable in production without preserving the full trace surface — the consumer flips one independent compile-time flag.
Use case × surface routing¶
The same five surfaces map onto the canonical observability use cases. The table below pins, for each use case, the surface that fits and the surface that does NOT (with the reason — typically posture mismatch or wrong record shape).
The five surfaces are the substrate; the normal production-monitoring entry point sits on top of them. For an app shipping handled-event metrics and error records off-box (Datadog / Sentry / Honeycomb / a custom pipeline), the surface to reach for 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. A cross-frame seat is the same grammar declared once with(rf/configure! {:observability …})rather than a second door;register-listener!has no:events/:errorsstream. The#3/#4"Recommended surface" cells in the table below name the always-on substrate a production use case rides; the sink is the declarative entry point on top of that substrate. The full consumer-facing ordering — frame sink first, process default for the cross-frame and frameless cases, devregister-listener!never a production wire, SSR projector server-tier-only — is the guide §16 entry-point hierarchy.
| Use case | Recommended surface | Why this one | What to avoid |
|---|---|---|---|
| Real-time UI dashboard (re-frame-10x style) — live run view, per-domino timing, error highlighting in dev | #1 raw trace listener + #2 epoch listener (composed) | Need every :op-type event with dev-side enrichments (:dispatch-id correlation, source-coord, :origin). Tools like re-frame-10x consume both: raw stream for the timeline, epoch records for the structured per-run slice. Dev-only is the right posture (the dashboard isn't shipped to production). |
Don't use #3 / #4 — the tight record shape strips correlation fields the dashboard needs. Don't use #5 — Performance API is timing-only, no semantics. |
| Off-box APM forwarder (Datadog, Honeycomb, New Relic) — ship event throughput + latency to a hosted backend, including in production | #3 handled-event sink + #4 error sink — the frame-owned :observability :handled-events / :errors entries, declared on the frame or once per process with (rf/configure! {:observability …}) and wired with register-observability-sink! |
Always-on posture is mandatory (the forwarder MUST run in production). Each record arrives projected under the owning frame's classification and the entry's egress profile, with event args omitted off-box by default. Per-sink exceptions isolated. | Don't register on the implementation-tier re-frame.event-emit / re-frame.error-emit registries — they are not an application door. 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 sink | The sink route 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 at all for cascade failures — the chain captures a handler / interceptor / cofx throw rather than re-throwing, so nothing propagates out of dispatch / dispatch-sync and the native machinery sees NOTHING (not even a bare exception). Don't use #1 in production — it's elided. |
| Performance budget (CI perf-regression gate, real-user monitoring) — measure event / sub / fx / render timing against a budget | #5 Performance API channel | Designed for this use case. Production-survivable via the independent re-frame.performance/enabled? flag; surfaces in DevTools Performance panel and the host APM's PerformanceObserver; zero overhead when not opted in. |
Don't use #1 — it's elided in production. Don't use #3 — the :elapsed-ms field is event-level only; #5 brackets sub / fx / render too. |
| Custom recorder (in-app debug overlay, story-runner-style replay capture) | #2 epoch listener | Each record is a raw assembled per-run slice (:db-before, :db-after, :trace-events — causal replay material, not projected). A local-only recorder may append it as-is; any record that then egresses to a log, tool, or process boundary must first be routed through rf/project-egress (where the frame/profile projection applies — never at ring-append / listener fan-out). (rf/configure! {:epoch-history {:depth N}}) controls retention. Dev-only is the right posture for a debug overlay. |
Don't use #1 — raw trace stream requires per-run grouping logic the consumer would have to reimplement. |
Framework's own SSR error projection — turn runtime errors into the locked :rf/public-error HTTP-wire shape on the JVM/SSR tier |
#4 error-emit listener (per 011 §Server error projection) | Production-required surface (an SSR error is by definition a production-survivable concern). Always-on substrate survives both goog.DEBUG=false (CLJS) and -Dre-frame.debug=false (JVM, when the operator opts out per Security.md §Production gates). |
Don't route SSR error projection through #1 — it's gated by interop/debug-enabled?, which an SSR JVM facing untrusted input is explicitly directed to disable. Routing through #4 keeps the projector firing under both postures. |
Combining surfaces¶
The five surfaces are independent — registering a listener on one does NOT register on the others. Common compositions:
- Full dev observability: #1 + #2 + #5. Raw stream feeds the dashboard, epoch listener feeds the recorder / pair tool, Performance API feeds DevTools timing.
- Full production observability: #3 + #4 + #5. Event throughput + latency to APM (#3), error monitoring (#4), timing budget (#5).
- Hybrid: app with both a dev-time dashboard AND a hosted production monitor uses #1 + #2 in dev (registered under
(when ^boolean re-frame.interop/debug-enabled? …)) and #3 + #4 always. The dev-only registrations elide in production; the always-on registrations survive.
Tuning knobs by posture¶
Each posture row has a small set of runtime knobs (orthogonal to the elision gate itself):
| Posture | Knob | Effect | Surface(s) affected |
|---|---|---|---|
| dev-only DCE | (rf/configure! {:trace-buffer {:events-retained N}}) |
Per-frame per-event ring for register-listener! late-attach (N=0 opts out of the ring entirely; default 50; per-frame override via the :rf.trace/events-retained frame-config key) |
#1 |
| dev-only DCE | (rf/configure! {:epoch-history {:depth N :trace-events-keep N}}) |
Epoch ring depth and per-record trace-event budget | #2 |
| dev-only DCE | (rf/configure! {:elision {:rf.egress/threshold-bytes N}}) |
Per-payload size threshold for :rf.size/large-elided marker in trace records |
#1, #2 (records ride post-elision) |
| always-on | none — record shape is fixed by contract | Listeners receive identical record shapes in dev and prod | #3, #4 |
| opt-in goog-define | :closure-defines {re-frame.performance/enabled? true} |
Enables the four performance.measure bracket sites (:event, :sub, :fx, :render) |
#5 |
| opt-in goog-define | :closure-defines {re-frame.performance/retain-entries? true} |
Skips the per-emit performance.clearMeasures so entries persist in the retained buffer for one-shot getEntriesByType('measure') readers (DevTools / console); default off — entries are delivered to observers then cleared |
#5 |
The goog.DEBUG flag (CLJS) and the -Dre-frame.debug system property / RE_FRAME_DEBUG env var (JVM) are not user knobs — they're the build-time/process-start gates that select the posture. Apps DO NOT toggle them per-request or per-session; once the bundle is compiled (or the JVM is started), the posture is fixed.
Off-box egress contract¶
Three of the five surfaces are designed to feed off-box (hosted) backends; the contract differs:
| Surface | Off-box ready? | Record shape | Privacy guarantee |
|---|---|---|---|
| #1 raw trace listener | NO — dev-only; not present in production bundles. Apps SHOULD NOT ship trace records to a hosted backend in dev as a substitute for #3 / #4 (the record is much larger and carries dev-side fields irrelevant to APM) | Full structured trace event with :tags open bag, source-coord, :dispatch-id correlation |
The dev-side register-listener! runs after :sensitive? substrate-level scrubbing per §Privacy / sensitive data in traces. |
| #2 epoch listener | NO — dev-only; epoch records carry full :db-before / :db-after snapshots and are not sized for hosted ingestion |
Assembled :rf/epoch-record per Tool-Pair §Time-travel |
The listener delivers the raw record (causal replay material). Redaction happens only at off-box egress inside rf/project-egress — never at ring-append / listener fan-out — so it does not redact what this listener receives. |
| #3 handled-event sink | YES — always-on, projected before delivery, sink-exception-isolated. The door for hosted-backend forwarding | :rf.observe/handled-event {:kind :frame :event-id :time :outcome :elapsed-ms :effects :correlation}; :event only when the entry opts sensitive content back in |
Projected by rf/project-egress under the owning frame's classification and the entry's egress profile (default :rf.egress/off-box-observability, which omits event args). A kept :event takes the event registration's :sensitive / :large paths first, then the durable-classification walk (large → :rf.size/large-elided; sensitive → :rf/redacted). |
| #4 error sink | YES — same posture as #3 | :rf.observe/error: summary slots (:rf.error/id, :frame, :event-id, :time, …) plus :event, :tags and :exception |
Projected like #3: :event takes the registration pass, then the walk, and :tags the walk; :exception is the JS / JVM throwable, not a serialised string, and :rf.egress/public-error drops it. |
| #5 Performance API channel | INDIRECT — User-Timing measure entries are consumed by host APMs through PerformanceObserver; the framework does not emit to a hosted backend directly. Entries are delivered to observers then cleared from the retained buffer (observer-first; see §Consumer access) |
Browser-native PerformanceMeasure entries with name / startTime / duration (no detail is set — the options-bag measure call carries only start / end timestamps) |
No payload — measures carry only timing, not user data. |
The re-frame.event-emit / re-frame.error-emit registries underneath #3 / #4 are implementation tier: the framework's own capture sites and tests read them, and an application does not. Their records carry a dispatched event's :event projected in the same two steps — the event registration's :sensitive / :large paths, resolved in the record's frame, then re-frame.elision/elide-wire-value against the frame's durable classification.
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 as an
:observability :errorssink (per §Error observability). The sink route rides the always-on error-emit substrate, NOT the trace surface — it fires under:advanced+goog.DEBUG=false. Under:rf.egress/local-rawthe sink 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 promoted runtime:rf.error/*(the promotion-criterion set, not every production-reachable one — a caller-observed pure throw surfaces at the native error boundary instead); dev-side enrichments (:rf.trace/dispatch-id,:rf.trace/trigger-handler, per-frame rings) are not carried. - Native-SDK complement — NOT a fallback for cascade failures: install the monitor's native browser SDK at the top of the bundle (
Sentry.init({...})). It captureswindow.onerror,window.onunhandledrejection, and any explicitSentry.captureExceptioncall — none of which see a failure the cascade captured: a handler / interceptor throw is recorded into:rf/interceptor-error, and context assembly captures a coeffect-supplier throw, rather than re-throwing, so the failure propagates out of neitherdispatchnordispatch-syncand surfaces ONLY on the:errorsstream (surface #2 of §What IS available in production; its #5 says why native browser machinery never sees it). The native SDK therefore covers what the sink does not — throws outside the cascade (view code, async callbacks, third-party libraries) — and an app that installs ONLY the native SDK has no handler-exception coverage at all. Install it alongside the:observability :errorssink, never instead of it. - 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, so their floor is the listener fan-out alone.
Performance instrumentation¶
The trace stream above is dev-only — too noisy for prod, gated on re-frame.interop/debug-enabled? (an alias of goog.DEBUG). Many apps still want a separate, default-off, prod-friendly timing channel: one that surfaces in Chrome DevTools' Performance panel alongside React renders, network, and paint, and that consumers (the host's APM, a custom PerformanceObserver, an in-app perf overlay) can read via the standard browser User Timing surface.
re-frame2 ships that channel through the browser's performance.measure (options-bag form, no marks — see §What gets bracketed), gated on a second compile-time constant — re-frame.performance/enabled? — that is independent of goog.DEBUG. The default is off; consumers opt in by flipping the goog-define via :closure-defines. Closure DCE then either keeps the bracket sites or elides them entirely; production binaries that don't ask for timing carry zero User-Timing instrumentation.
This is distinct from the trace surface above:
| Axis | Trace stream | Performance instrumentation |
|---|---|---|
| Compile-time gate | re-frame.interop/debug-enabled? (alias of goog.DEBUG) |
re-frame.performance/enabled? |
| Default | on in dev (goog.DEBUG=true), off in prod |
off in both (enabled?=false) |
| Consumer | register-listener! listeners, the per-frame trace rings, :epoch listeners |
PerformanceObserver, Chrome DevTools Performance; performance.getEntriesByType('measure') only with retain-entries? on (see §Consumer access) |
| Shape | structured trace events (open maps with :operation / :op-type / :tags) |
User Timing measure entries (name, startTime, duration) |
| Where it runs | both platforms (dev) | CLJS only — JVM is a no-op |
The two flags compose: a build that wants both flips both. A typical prod build has goog.DEBUG=false and either re-frame.performance/enabled? true (perf timing kept; trace elided) or false (everything elided).
The compile-time flags¶
;; src/re_frame/performance.cljc
(goog-define ^boolean enabled? false) ; the channel gate
(goog-define ^boolean retain-entries? false) ; keep entries in the retained buffer
A consumer flips the channel gate in their shadow-cljs.edn / compiler-options:
{:builds {:app {:target :browser
:output-dir "..."
:compiler-options {:closure-defines {re-frame.performance/enabled? true}}}}}
Like goog.DEBUG, :advanced constant-folds the value, the gated branch DCEs, and the body collapses to its un-bracketed shape — for the perf surface that means each call site becomes a direct invocation of the body it brackets.
retain-entries? is a second, independent goog-define (default false). Leave it off for production (RUM) — entries are delivered to observers then cleared so the buffer does not grow. Flip it on (:closure-defines {re-frame.performance/retain-entries? true}) only for one-shot DevTools / console workflows that read the retained buffer via getEntriesByType('measure'). See §Consumer access.
What gets bracketed¶
The reference runtime brackets four hot-path call sites. Each runs inside a (performance/mark-and-measure :<bucket> <id> <body>) macro form so the bracket is a compile-time decision (the macro expands to (if enabled? <gated-bracket> (do <body>)), which Closure constant-folds):
| Bucket | Where | Entry name |
|---|---|---|
:event |
Event handler invocation (router's process-event* step that runs the interceptor chain) |
rf:event:<event-id> |
:sub |
Subscription recompute (the body fn inside compute-and-cache!'s reaction) |
rf:sub:<sub-id> |
:fx |
Per-fx walk-step (every entry processed by handle-one-fx, including reserved fx-ids :dispatch / :dispatch-later and user-registered fx) |
rf:fx:<fx-id> |
:render |
Per view-boundary render — the wrapper the view substrate emits around a registered view's body (reg-view*'s frame-aware wrapper on the adapter path; the equivalent minted component fn on a compiled-view substrate) |
rf:render:<view-id> |
The :render bucket is keyed on the representation, not on the registration API. One entry means one view boundary's body ran, once, because the host asked for it — so any substrate that mints view boundaries owns this bucket on the same terms, and a substrate that mints them by more than one route (e.g. a second wrapper taking its frame as a prop rather than from context) brackets every route or reports half a page. Three consequences follow rather than being separately specified: a render the host skipped (a memoization bail-out, where the substrate's comparator sits above the bracketed fn) emits nothing, because no body ran; a host that invokes the body twice for one logical render (React StrictMode's dev double-invoke) correctly emits twice, because two bodies ran; and a substrate that re-runs a body internally for one host render — a consistency fence, a retry loop — emits one entry spanning the retries, because the host asked once and the duration a consumer wants is the wall-clock the host paid. Plain helper functions inlined into a body are not view boundaries and are not separately bracketed; their cost lands inside the enclosing boundary's entry.
The bracket shape (when the flag is on at compile time):
start = performance.now()
try <body>
finally
performance.measure(<name>, { start, end: performance.now() })
if (!retain-entries?) performance.clearMeasures(<name>)
Two design choices bound the entry buffer (per the observer-first contract, §Consumer access):
- Options-bag
measure, no marks. The bracket uses the options-bag formperformance.measure(name, {start, end})with numericperformance.now()timestamps rather than named marks. Noperformance.markentries are allocated — two marks per bracket would be pure buffer growth with no documented consumer, tripling the per-bracket entry churn. - Clear after emit. Immediately after emitting the measure the bracket clears it by name (
performance.clearMeasures(name)), unless the consumer flips there-frame.performance/retain-entries?goog-define(default off). A livePerformanceObserverstill receives the entry: under the W3C Performance Timeline's queue a PerformanceEntry algorithm,measure()appends the entry to each registered observer's own buffer and queues a task that runs the observer callbacks later, whileclearMeasuresempties only the timeline buffer. Timing is therefore delivered to any attached observer / host APM; the retained buffer thatgetEntriesByType('measure')reads simply does not grow.
The try/finally ensures the measure entry is emitted (and delivered to observers) even when the body throws — observability does not become silent on the unhappy path. The thrown exception still propagates after the finally runs.
retain-entries? (a second goog-define, default false) is the escape hatch for one-shot DevTools / console workflows that read the retained buffer via performance.getEntriesByType('measure'): flip it on and the per-emit clear is skipped so entries persist. Long-running (RUM) sessions leave it off and read via a PerformanceObserver — see §Consumer access.
Naming convention¶
Every entry name uses the shape rf:<bucket>:<id>, so consumers filter by the rf: prefix without parsing per-bucket shapes. Keyword ids preserve their namespace:
rf:event:user/login
rf:sub:cart/total
rf:fx:dispatch
rf:fx:rf.http/managed
rf:render:my.app/page-header
Tools that want a per-bucket view split on the second :. The shape is stable: new buckets adopt the rf:<bucket>:<id> convention and are additive.
<id> is the id the thing was registered under, rendered verbatim — build-name preserves a keyword's namespace and stringifies a symbol or string as written. A view substrate whose views are named with strings rather than keywords (a macro that mints "<ns>/<view>") therefore produces the same rf:render:my.app/page-header shape with nothing to reconcile; the rule is that the <view-id> in the measure name and the id the substrate publishes to the developer (React displayName, the registrar key) are one identifier, so a name read off the User-Timing stream is directly jumpable in the tooling.
Consumer access¶
The channel is observer-first: entries are delivered, not retained. Each bracket emits its measure and then clears it from the retained buffer (unless retain-entries? is on — see §What gets bracketed), so the primary read is a PerformanceObserver. Each measure() queues its entry for every observer registered at that moment, and the clear does not reach that queue, so an observer attached before the work receives every entry; one attached after an entry was cleared cannot recover it, even with buffered: true:
// Live: a PerformanceObserver — the production (RUM) path. Each measure()
// queues its entry in the observer's own buffer, and the callback later
// receives everything queued since its last run. The framework's clear
// empties only the timeline buffer, so the observer sees every rf:
// measure even with buffer retention off (the default).
new PerformanceObserver((list) => {
for (const e of list.getEntriesByType('measure')) {
if (e.name.startsWith('rf:')) {
// entry: { name, startTime, duration }
sendToAPM(e);
}
}
}).observe({ type: 'measure', buffered: true });
// One-shot DevTools / console snapshot: only populated when the app was
// built with :closure-defines {re-frame.performance/retain-entries? true}.
// With retention off (the default) this returns [] — the entries were
// delivered to observers then cleared.
performance.getEntriesByType('measure')
.filter(e => e.name.startsWith('rf:'));
Chrome DevTools' Performance panel renders the measures as named tracks alongside React renders, network, and paint — no custom UI required (the panel captures entries as they are emitted, so it too is unaffected by the post-emit clear).
Why clear after emit. The W3C User-Timing registry defines maxBufferSize as Infinite for mark and measure entries — the buffer is not bounded by the host. An always-on production channel that never cleared would grow the buffer without limit: a multi-hour RUM session would retain millions of entries and leak memory. re-frame2 therefore clears each measure after emit; the retained buffer is bounded by re-frame2, not the host. Long-running pages read via the PerformanceObserver above (which is unaffected by the clear) and offload to durable storage; they do not rely on the retained buffer. Consumers that genuinely want a retained buffer for one-shot inspection opt in via retain-entries?.
Production-elision verification¶
The bundle-isolation contract is enforced in CI by npm run test:perf-bundle (the dual of npm run test:elision):
:examples/counterbuilds the standard counter example under:advancedwith the perf flag off (the goog-define default).:examples/counter-perfbuilds the same source under:advancedwith:closure-defines {re-frame.performance/enabled? true}.scripts/check-perf-bundle.cjsgreps both bundles. The contract:- Off bundle MUST NOT contain
performance.measure,clearMeasures, or any"rf:entry-name fragment (and — since the bracket allocates no marks — MUST NOT containperformance.markeither, in both bundles). - On bundle MUST contain
performance.measure,clearMeasures, and the"rf:fragment.
- Off bundle MUST NOT contain
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.
re-frame.performance-cljs-test grades the observer-first contract at the macro level in the per-PR :node-test lane: one test rebinds enabled? on and retain-entries? off, attaches a PerformanceObserver before repeated brackets, and asserts that the observer receives every measure (takeRecords()), that the retained buffer is empty afterwards (clear after emit), and that no mark entry of any name is allocated (the options-bag form). The emission call-site coverage lives in the nightly re-frame.performance-emit-nightly-test, which flips retain-entries? true so it can read the entry names synchronously and therefore cannot observe clear-after-emit.
JVM scope¶
The Performance API is browser-only. The JVM half of re-frame.performance:
- Defines
enabled?as^:const falseso the macro expansion's(if enabled? ...)is statically dead and the JVM body runs as if instrumentation were absent. - Expands
mark-and-measureto(do body...)— pure pass-through, no instrumentation overhead.
JVM artefacts (headless tests, SSR, Pedestal/Ring services using re-frame2 for state) that want timing should reach for the host's profilers (clj-async-profiler, JFR, async-profiler).
Forward compatibility for tools¶
External tools consume re-frame2 through stable surfaces. Production builds elide the entire trace surface; everything in this section is dev-only.
Stable surfaces consumed by every tool¶
| Surface | Stability |
|---|---|
register-listener! / unregister-listener! |
Preserved |
| Synchronous, event-at-a-time delivery | Preserved |
Trace event shape (:id, :operation, :op-type, :time, :tags) |
Preserved exactly |
:op-type discriminator vocabulary (:rf.event, :rf.sub, :rf.fx, :rf.view, :rf.frame, :rf.machine, :warning, :error, ...) |
Preserved; new values additive |
:tags for op-type-specific data (:frame (bare carve-out), :rf.trace/event-id, :rf.event/v, :db-before, :db-after, :rf.trace/dispatch-id, :rf.trace/parent-dispatch-id, :rf.event/origin, ...) |
Preserved |
Hoisted top-level fields (:source, :recovery) |
Preserved |
re-frame.interop/debug-enabled? (alias of goog.DEBUG) |
Preserved |
Compile-time elision via goog.DEBUG=false + :advanced |
Preserved |
Public registrar query API (registrations/handler-meta/frame-ids/frame-meta/app-db-value/frame-state-value/sub-topology/sub-cache-snapshot) |
See 002 §The public registrar query API |
Hot-reload notifications (:rf.registry/handler-registered, :rf.registry/handler-cleared, :rf.registry/handler-replaced, :rf.frame/created, :rf.frame/destroyed) |
Trace events |
Capabilities tools depend on¶
- Multi-frame UI — frame selector; per-frame trace slicing via
(get-in ev [:tags :frame]); per-frame app-db via(app-db-value id). - Epoch-per-event semantics — each dequeued event is its own epoch (per 002 §Drain versus event); a drain may settle several events run-to-completion, but each (incl. an
:fx-dispatched child or the frame-init event) yields its own record. Per-run correlation rides on:dispatch-id/:parent-dispatch-id(per §Dispatch correlation). The fully-assembled:rf/epoch-record(per Spec-Schemas) provides the structured projection. - Machine trace types —
:op-typevalues (:rf.machine/transition, etc.) for state-machine activity. - Per-frame override visibility —
:fx-overrides/:interceptor-overridesare inspectable via(frame-meta id).
Programmatic interaction surfaces¶
- Generate test cases from trace history.
- Suggest refactors based on registry inspection.
- Drive interactions via
dispatch-sync. - Snapshot state, modify, restore.
- Read state in any frame;
frame-idsenumerates them.
JVM vs. CLJS scope¶
All trace functionality is dev-build only — production builds elide the entire trace surface on both platforms.
| Capability (dev builds) | JVM | CLJS |
|---|---|---|
| Trace event emission | ✓ | ✓ |
register-listener! / unregister-listener! |
✓ | ✓ |
register-listener! :epoch / unregister-listener! :epoch |
✓ | ✓ |
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 four-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 four 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), or the view coord a reg-view-injected op carries — {:ns :file :line :column} or nil for fn-form callers. Hoisted as :rf.trace/call-site on every emit (success and error). |
:dispatch-id (HandlerScope slot) |
Per-event correlation id — allocated once per dequeued event at router.cljc's process-event! (the epoch unit; one per dispatch, incl. each :fx-dispatched child) and merged into :tags :rf.trace/dispatch-id of every event emitted inside that one event's run. :raise/:always microsteps ride the triggering event's id. (The internal scope-record slot keeps the short name; the emitted trace tag is the namespaced :rf.trace/dispatch-id.) |
: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 / :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) 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 read off the descriptor the failing frame resolved — the registration coordinate of the implementation that ran (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 and the reg-view-injected subscribe op). 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). 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. |
: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 | Hoisted on every emit, not only errors — 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. |
: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 fifth 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: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 four Vars¶
The reading is carried by one HandlerScope record bound to one Var: one binding-frame allocation per scope, one Var to mock in tests, one record-field edit when a new concern lands.
Error contract¶
Errors that occur during runtime execution are emitted as structured trace events, with a defined :op-type and a Malli-schemed :tags payload. This satisfies AI-first property P7 (machine-readable errors) and gives every consumer of the trace stream a consistent error surface.
This section is the authoritative model for re-frame2's error taxonomy. Per-feature specs (010, 011, 012, etc.) reference categories defined here; the §Error event catalogue below is the single source of truth for category names, payload shapes, and recovery defaults. Two axes carry the structured information:
:op-type— universal severity discriminator (:erroror:warning). Consumers branch on severity without parsing the prefix.:operation— namespaced category keyword (:rf.error/<category>,:rf.fx/<category>,:rf.ssr/<category>,:rf.warning/<category>,:rf.epoch/<category>). The prefix carries domain provenance; the suffix names the specific category.
Observability channels and the promotion criterion¶
This subsection is the authoritative home for which channel a failure rides and what must ride the always-on axis; EP-0008 records why.
For failure categories, re-frame2 has three observability channels with different production guarantees. This contract names all three normatively and states the rule for moving a category onto the production-survivable one.
- The causal channel — effects-as-data, replayable, part of the semantic value. It is the program: a
:dispatch/:fxentry is data the run executes, not a log line. Never elided. - The diagnostic channel —
trace/emit!(every:op-typetrace event, the:rf.error/*/:rf.warning/*/:rf.fx/*/:rf.ssr/*/:rf.epoch/*catalogue, the per-frame trace rings, source-coord enrichment). Ambient, framework-wide; production-elided — Closure DCE undergoog.DEBUG=false(CLJS:advanced), runtime-gated onre-frame.debug(JVM). For development eyes and tools. - The always-on error axis — the error-emit substrate (which the
:observability :errorssink rides, surface #4, per §What IS available in production). Production-survivable: it is NOT gated byre-frame.interop/debug-enabled?, so it survives:advanced+goog.DEBUG=false(CLJS) and-Dre-frame.debug=false(JVM), fanning out one tight record per promoted:rf.error/*(the categories meeting the promotion criterion, not every production-reachable one) to app-registered shippers (Sentry / Honeybadger / Rollbar).
Production guarantees, once:
- CLJS
:advanced+goog.DEBUG=falseDCEs the diagnostic channel entirely (per §Production builds). - The JVM gate (
re-frame.debug/RE_FRAME_DEBUG, per §JVM builds) defaults on except on a JVM that hosts SSR, where it defaults off — elsewhere "production-elided" means elidable, not elided by default. A production tooling or webhook JVM that does not set-Dre-frame.debug=falseruns the full dev diagnostic surface, so such 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).
-
Locally invisible — the failure leaves the process in a state nobody standing at the call site can read back. Two shapes qualify:
- (a) Contract breach or resource leakage — leaked handles, skipped teardown, suppressed writes, corrupted invariants. The damage outlives the call, and the next operation cannot see it.
- (b) A refusal whose only local effect is the ABSENCE of the action — a fail-closed gate that declines as a no-op: nothing thrown, nothing returned, the cascade continuing exactly as if the call had never been made. The call site therefore receives no value with which to distinguish refused from never asked.
Excluded under both, and this is the line the legs draw: a malformed input the caller can observe and fix at the call site. "Observe" is the operative word — a guard that THROWS or returns a failure value has already told the caller, so the diagnostic channel suffices.
:rf.server/redirectcarries the contrast inside one effect: its CR/LF/NUL gate throws, so the caller is told and the record rides:rf.error/fx-handler-exception; its URL gate no-ops, so without promotion the identical class of refusal would reach nobody. Two halves of one security surface, and only the silent half needed leg 2(b). -
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.
Failing leg 1 keeps a category off the OBLIGATION, not off the stream. The criterion says what MUST ride the always-on axis in every build. A check that is itself compiled out of production — dev-only schema validation is the case in point — can still report on the :errors stream in the build where it runs, and for one class it does: a dev-gated refusal that discards a whole candidate transition (:rollback? true — Spec 010 §Per-step recovery rows 4 and 7, and the fail-closed :rf.error/malformed-schema rejection) is a leg-2(b) no-op whose silence compounds (leg 3), so from inside its own debug-enabled? gate it fans one structural-only record per failing registration onto the :errors stream beside its dev trace. The stream is still always-on — what is conditional is the PRODUCER, exactly as for every dev-elided check — and a release build carries neither the check nor the record. The Channel column does not move for this: it states what SURVIVES ELISION, and a dev-gated route does not. The record obeys the structural-only rule above; its per-registration granularity is the trace's, and the single-bounded-report idiom below governs always-on emissions that would otherwise fan per item IN PRODUCTION, which this one never reaches. Leaving it trace-only would hide a whole class: the class is every reg-app-schema in every app, and a permanent rollback loop would report nothing to a listener, nothing to the frame's :observability sink and nothing to the console, so an untooled dev build would simply render empty.
Leg 2(b) covers the fail-closed refusals on the axis. Limb (a) alone would exclude every one of them —
:rf.error/machine-spawn-unregistered-type, the:boundary? truearm of:rf.error/schema-validation-failure, and the three redirect URL-gate rejections (:rf.error/redirect-invalid-url,:rf.error/redirect-scheme-rejected,:rf.error/redirect-host-disallowed) are refusals, not leaks. Each rides the axis on the same reasoning: the refusal is correct, and its correctness is exactly why it is invisible. Categories on the always-on axis carry structured data only (error id, ids/keys, frame) — never raw values; the axis is subject to the same egress redaction posture as every off-box surface (per §Privacy / sensitive data in traces).
Category kind follows the channel. The always-on axis is contractually :rf.error/*-only (one tight record per promoted :rf.error/*, per §What IS available in production); it is not widened to warnings. A failure fact that meets the criterion but is surfaced only as a :rf.warning/* diagnostic is on the wrong channel — promotion names the production-survivable fact as a new :rf.error/* category with a typed per-category default :recovery from the §Recovery contract vocabulary (the recovery may stay :ignored — the channel, not the recovery, is what promotion changes).
Promotion is not a blind per-item rename. Where a single always-on emission would otherwise fan out one record per item — the frame-destroy case, where a teardown recipe runs many hooks — the criterion is satisfied by a single bounded report naming the higher-level fact, with the per-item detail carried as a payload vector. This is the corpus idiom: the Spec 016 trace family settled the same fan-out with single summary rows (:rf.resource/route-plan, :rf.resource/revalidate-scan) plus per-item detail on ordinary diagnostic traces. The report-vs-per-item choice is scoped to the always-on axis only — the diagnostic channel keeps its per-item rows at their causal positions (dev, DCE'd in production).
Channel-promotion catalogue rows¶
Two catalogue entries work the promotion criterion through — one per leg-2 limb; the rest of the catalogue is graded against the criterion.
-
Frame-teardown failures. Frame destroy runs a best-effort recipe: each loaded artefact's lifecycle-contribution members (the machines
:exitcascade among them) plus a few of the runtime's own guarded steps; a teardown step throwing is production-reachable (long-lived SSR / tooling), is a resource-leakage class (skipped teardown — leaked request data, orphaned timers, cross-request contamination the next operation cannot see locally), and compounds with process lifetime. All three legs hold, so the fact rides the always-on axis as:rf.error/frame-teardown-failedcarrying a:hook-failuresvector (:recovery :ignored— teardown stays best-effort). The per-failure detail stays on the diagnostic channel as:rf.warning/teardown-hook-exceptionat its causal positions, funneled through the sharedrecord-teardown-failure!emit boundary the teardown catch sites (safe-call-contribution!,safe-teardown-step!, and the after-dissoc continuations) all route through (dev, DCE'd in production). See the §Error event catalogue rows for both.Emit-safety contract (finally-shaped flush). The always-on
:rf.error/frame-teardown-failedreport MUST be emit-safe on a partial teardown: the hook-failure entries are accumulated during the teardown walk and flushed through a finally-shaped emission boundary, so that if teardown itself aborts after (say) hook 3 of 7, the entries collected so far still ship the report. The single-report shape therefore does not sacrifice incremental delivery against a mid-teardown collapse — the one genuine advantage per-hook emission would have had. The report is emitted at most once per destroy (whether teardown completes or aborts). -
Security-boundary refusals — the worked example of leg 2(b).
:rf.server/redirect's URL gate is the case in full: it is production-real and rejects correctly under-Dre-frame.debug=false, but the rejection is a no-op — the fx returns nil and the response simply carries no redirect — so without the always-on record an attacker-supplied?next=javascript:alert(1)would produce no shipper event, no metric and no frame-owned record, and the refusal would be indistinguishable in production from the redirect never having been attempted. Leg 1 holds (untrusted ingress is a production-only path), leg 2(b) holds (the refusal's only local effect is the absent redirect), leg 3 holds emphatically (a single rejected target is noise; recurrence is what turns it into visible probing, and recurrence is precisely what silence destroys). The three categories:rf.error/redirect-invalid-url,:rf.error/redirect-scheme-rejectedand:rf.error/redirect-host-disallowedtherefore ride the always-on axis. Their siblings on the same limb —:rf.error/machine-spawn-unregistered-typeand the:boundary? truearm of:rf.error/schema-validation-failure— ride the axis on the same reading.Record discipline, and it is stricter here than elsewhere on the axis. A security refusal's payload is caller-untrusted by construction — that is what the gate exists for — so the closing sentence of the criterion ("structured data only … never raw values") is not a redaction obligation on this class but a projection obligation. The record is BUILT FROM a closed allow-list of framework-owned values rather than scrubbed down to one, because a scrub is a deny-list and a deny-list over an arbitrary attacker-supplied input is one component away from the next leak. And a closed set of keys is not yet a closed set of values: a slot holding a parsed component of caller input is still holding caller input, whatever its name implies. Both halves bite on this family: a carrier-scrubbed URL leaks userinfo, path and value-less query keys, and a parsed
:scheme/:hostleaks sentinels and lets a prober drive unbounded metric cardinality. The test to apply: could a caller choose the bytes in this value, or the number of distinct values it can take? If either, it is not structural. The redirect-gate record's shape —#{:frame :recovery :reason :scheme-class}, every value a framework keyword or the frame's own id — is the worked example.
The error event shape¶
All error trace events are open maps with these required keys:
{:id any ;; unique trace id
:operation :rf.error/<category> ;; specific category, see below
:op-type :error ;; the universal discriminator for errors
:time timestamp ;; emit time, host clock
:source keyword? ;; (when present) the trigger source — :ui, :after-timer, :http, :machine-action, ... (full enum: Spec-Schemas §:rf/dispatch-envelope)
:recovery keyword? ;; :no-recovery, :replaced-with-default, :skipped, ...
:rf.trace/trigger-handler ;; (when present) the in-scope handler at emit time
{:kind #{:event :sub :fx :cofx :view}
:id keyword
:source-coord {:ns sym? :file string? :line int? :column int?}}
:rf.trace/call-site ;; (when present) invocation coord stamped by the
{:ns sym? :file string? ;; macro form. Dev-only — elided under
:line int? :column int?} ;; :advanced + goog.DEBUG=false.
:tags {:failing-id any ;; the registered id that failed (event id, fx id, sub id, view id, etc.)
:reason string ;; one-sentence human description
:frame keyword? ;; (when known) the frame the failure happened in
...}} ;; category-specific keys
:source and :recovery are top-level fields hoisted out of :tags by the runtime; both are present on every error event. :frame rides under :tags (every emit site that knows the frame supplies it there). The :tags payload's category-specific keys are documented per category below, and each category has a registered Malli schema so consumers can validate / branch on the payload safely.
The thrown-error shape — the :rf.error/id ex-data contract¶
Most runtime failures emit a trace event (the shape above) and let the run recover. A minority are thrown — a registration is rejected, an optional artefact is absent, a delegation surface is reached before (rf/init! …). These surface as ex-info rather than as trace events (the catalogue's "Surfaced as a thrown ex-info, not a trace" rows). Thrown errors carry their own canonical ex-data shape so a single consumer path — Xray's error widget, the pair-tool overlay, an error listener, a try/catch in user code — reads one discriminator slot uniformly regardless of which surface threw.
The discriminator slot is :rf.error/id — a :rf.error/<category> keyword from the §Error event catalogue. This is the single normative discriminator for thrown errors; the four-slot skeleton below is the canonical shape every framework throw conforms to. Every (throw (ex-info …)) site in the runtime is built through the central builder re-frame.error/throw-error! / re-frame.error/thrown-ex-info (the re-frame.error.cljc chokepoint), which DERIVES the message from :reason + the :rf.error/id token and sets the canonical ex-data shape — so the human message and the machine discriminator are derived from one source and cannot drift, and a keyword-only message is structurally impossible to emit:
(error/throw-error!
<category-kw> ;; :rf.error/id — CANONICAL DISCRIMINATOR, :rf.error/<category>
'rf/<surface> ;; :where — the user-facing fn symbol that threw
"<one human-actionable sentence>" ;; :reason — public concept + expected fix + key context
{:recovery <disposition> ;; :no-recovery / :fix-registration / :skipped / … (default :no-recovery)
:extra {…}}) ;; surface-specific payload: :flow / :route-id / :machine-id / :received / :cycle / …
;; The builder produces this canonical ex-info:
(ex-info
"<reason> [:rf.error/<id>]" ;; message LEADS with the human sentence, TRAILS the [:rf.error/<id>] token
{:rf.error/id <category-kw> ;; CANONICAL DISCRIMINATOR — the SOLE machine pivot
:where 'rf/<surface> ;; the user-facing fn symbol that threw
:recovery <disposition> ;; :no-recovery / :fix-registration / :skipped / …
:reason "<one sentence>" ;; the required human sentence (also leads the message)
…}) ;; + surface-specific payload merged on top
Required slots on every thrown runtime error: :rf.error/id, :where, :recovery, :reason. Surface-specific payload (:flow, :bad-entries, :cycle, :installed, :attempted, :received, …) merges on top.
The human-message policy — two channels, one property. (ex-message e) is a human-actionable sentence; (:rf.error/id (ex-data e)) is the sole machine discriminator. The two channels are separate, and both remain machine-locatable:
(ex-message e)is a human-actionable one-line sentence naming the public concept, the expected fix, and key context — e.g."rf/init! cannot continue because no adapter is installed; require an adapter ns and install it before boot. [:rf.error/no-adapter-installed]". It is not 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, without making the keyword the whole message. The message leads with the human sentence and carries 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.
: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 a place a reader can land on, so a grep-for-symbol arrives somewhere real. Exactly three spellings satisfy it: a public var in the alias dialect ('rf/reg-flow, 'rf.ssr/hydrate!); a reserved public event id as a symbol ('rf.resource/invalidate-tags, 'rf.mutation/execute — per Conventions §The single-root reserved set); or a real namespace spelled IN FULL ('re-frame.ssr.emit). The third form is the full namespace and never the require-alias spelling 'rf.ssr.emit — quoting does not expand an alias, and the reader who most needs this slot is an off-box agent with no checkout, which can grep a full namespace straight onto its ns form but cannot resolve a dialect alias. A where-sym that names none of the three — a var its namespace does not export, or a qualifier for a namespace that does not exist in the tree — is a defect. :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 and on the always-on error record — the same :rf.error/<category> keyword in every slot, so a consumer reads one vocabulary across all three surfaces. The pairing with re-frame.core-artefact/defwrapper (per Conventions §single-import contract) and the missing-artefact wrappers (:rf.error/<feature>-artefact-missing) is the canonical reference.
One discriminator slot¶
Every thrown framework error carries its category under the single ex-data slot :rf.error/id (a :rf.error/<category> keyword); no other discriminator slot exists — not :error, not :type, not :kind, and never the keyword doubling as the human :reason. The always-on error record — the corpus listener's record and the :rf.observe/error sink record alike — carries its category under the same key, so (:rf.error/id x) reads the category off a thrown error's ex-data and off a record without a branch. (The v1→v2 slot mapping lives in MIGRATION.)
:rf.trace/trigger-handler — naming the in-scope handler¶
The optional top-level :rf.trace/trigger-handler slot names the handler whose execution produced the trace event and carries its registration-site source-coord. Tools (Xray, pair, IDE jump-to-source) render click-to-jump links from this field — given a trace event, the user lands on the line of code that defined the responsible handler.
The slot rides on every trace event emitted while a handler is in scope, not just errors. Success-path traces — :rf.fx/handled, :rf.machine/transition, :rf.event/db-changed, :rf.fx/do-fx, ... — carry the in-scope handler's registration coord too. This lets consumer tools render jump-to-source links from any trace event in a cascade, not just errors. The error-path and success-path emit shape is identical — same field name, same nested map, same top-level placement.
Coverage is keyed off "is a handler currently in scope at emit time?":
| Emit context | :rf.trace/trigger-handler present? |
Carries |
|---|---|---|
| Inside an event handler's interceptor chain | Yes | The event handler's coord |
| Inside a cofx fn body | Yes | The cofx's coord |
| Inside an fx handler body | Yes | The fx handler's coord |
| Inside a sub recompute (body fn) | Yes | The sub's coord |
| Inside a view render | Yes | The view's coord |
| Inside a machine transition (machines register as event handlers) | Yes | The machine's coord |
At outermost dispatch with no handler resolved (:rf.error/no-such-handler) |
No | — |
At depth-exceeded drain rollback (:rf.error/drain-depth-exceeded) |
No | — |
At registration-time emits outside any handler (:rf.registry/handler-registered, :rf.frame/created) |
No | — |
For :rf.fx/handled specifically: the slot carries the fx handler's own registration coord (not the enclosing event handler that produced the :fx vector). The runtime rebinds the handler-scope's :trigger-handler slot (per §Handler-scope) to the fx handler's meta around the fx body's invocation and the success-path emit that follows, so consumer tools jump to the reg-fx site — where the fx's logic actually lives — not the event handler upstream. Reserved fx-ids (:dispatch, :dispatch-later) 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 two user-facing surfaces stamp the call-site at compile time; the same name's plain-fn value form (Convention A on CLJS, per Conventions §Convention A — there are no dispatch* / dispatch-sync* facade twins) or the owning ns fn directly (re-frame.router/dispatch! / -dispatch-sync!) does not stamp:
| Surface | Macro (stamps) | Fn-form (no stamp) |
|---|---|---|
| Dispatch (queued) | dispatch (call position) |
dispatch (value position) / re-frame.router/dispatch! |
| Dispatch (sync) | dispatch-sync (call position) |
dispatch-sync (value position) / re-frame.router/dispatch-sync! |
For dispatch / dispatch-sync, the call-site rides through the dispatch envelope and is bound around process-event! so errors emitted inside the handler chain (handler exception, the declared-coeffect errors :rf.error/unregistered-cofx / :rf.error/coeffect-exception, no-such-fx, schema validation failures) attach the call-site of the dispatch that triggered the cascade — the user lands on the line they wrote, not somewhere deep in framework code. subscribe is a plain fn on both hosts and stamps no call-site; a reg-view-injected subscribe (a capture-frame :subscribe op) binds the view coord around the synchronous miss path, so :rf.error/no-such-sub and :rf.error/frame-destroyed carry it. (Coeffect delivery is not 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 — there is no inject-cofx; see the :rf.error/inject-cofx-removed row in §Error contract.)
Coverage:
| Reached through | :rf.trace/call-site present? |
|---|---|
Macro form (dispatch, dispatch-sync, call position) |
Yes |
Fn form (subscribe anywhere; dispatch / dispatch-sync in VALUE position; or the owning ns fn directly — re-frame.router/dispatch! / -dispatch-sync!, re-frame.subs/subscribe) |
No |
Higher-order use ((map dispatch xs) — dispatch read as a value, not called) |
No |
View-render injected dispatch / subscribe locals (per reg-view) |
Yes (view-level) — the macro injects the reg-view definition-site coord into the capture-frame (below) |
capture-frame ops built with :dispatch-opts / :subscribe-call-site (the reg-view injection path) |
Yes (view-level) — the op merges the supplied coord into its owning-ns dispatch / subscribe call |
Bare captured capture-frame ops ((:dispatch (rf/capture-frame)) / (:subscribe (rf/capture-frame)), no coord) |
No — the returned op delegates through re-frame.router/dispatch! with no coord |
View-level call-site for the reg-view-injected handle ops. Inside a reg-view body the lexically-bound dispatch / subscribe are the :dispatch / :subscribe ops of a single render-time capture-frame, which shadow the coord-capturing macros for the whole body (they are LEXICAL locals, not the re-frame.core facade Var — qualified rf/dispatch in the same body still resolves the macro/value-alias pair unshadowed). A view's on-click #(dispatch [...]) therefore reaches re-frame.router/dispatch! directly without a macro stamp — its trace would otherwise carry no call-site and classify as :source :unknown. So the reg-view macro injects the view's definition-site coord into the handle via its :dispatch-opts and :subscribe-call-site (it builds (re-frame.capture-frame/make-capture-frame (rf/current-frame-id) {:dispatch-opts {:source :ui :rf.trace/call-site <view-coord>} :subscribe-call-site <view-coord>})). The :dispatch op merges {:source :ui :rf.trace/call-site …} below the captured :frame and any per-call opts; the :subscribe op wraps its subs/subscribe call in with-call-site so the synchronous miss path (:rf.error/no-such-sub, :rf.error/frame-destroyed) carries the coord. The view body is spliced verbatim — no code-walking, no rewriting of user view code. Coord precision is view-level: "go to code" lands on the reg-view definition, not the exact #(dispatch …) line. The handle's render-time frame capture is preserved unchanged — the dispatch routes to the render frame, not a click-time :rf/default fall-through.
Production elision: dev-only. Each macro expands to (if interop/debug-enabled? <stamping-branch> <no-stamping-branch>); under :advanced + goog.DEBUG=false the closure compiler constant-folds the gate to false and the entire stamping branch DCE's — the literal {:rf.trace/call-site {...}} map vanishes from the bundle. The reg-view handle injection rides the same gate: each injected arg is (if interop/debug-enabled? <dev-coord-or-opts> <prod>) where the :dispatch-opts prod branch is exactly {:source :ui} (the :rf.trace/call-site keyword + coord DCE; :source :ui survives because dispatch! reads it unconditionally) and the :subscribe-call-site prod branch is nil. Apps using goog.DEBUG=true builds (or any JVM build) get the field; the default :advanced + goog.DEBUG=false production build does not — the elision-probe (per §Production builds) asserts the "rf.trace/call-site" string fragment is absent from the production bundle. The trace surface itself is still gated; this is an additional compile-time gate that strips the call-site machinery even when the trace surface is kept live.
The mechanism is "compile-time map + handler-scope bind + emit read." The macro produces a literal map at compile time; the runtime publishes it on the :call-site slot of re-frame.trace/*handler-scope* around the underlying owning-ns fn call (re-frame.router/dispatch! / -dispatch-sync!, re-frame.subs/subscribe) (or threads the value through the dispatch envelope so process-event! binds it for the handler chain, per §Handler-scope); build-event reads the slot and hoists it onto every emitted event (success and error) when bound. The queue-time :rf.event/dispatched emit additionally wraps its trace/emit! in a with-call-site binding sourced from the envelope's :call-site slot, so the enqueue trace carries the dispatch-site coord even though process-event!'s cascade-wide binding hasn't fired yet. No new namespace or registry; consumer access is (:rf.trace/call-site event).
The hoist covers every emit (success and error): the Event lens 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 rule matches :rf.trace/trigger-handler's: one consistent rule rather than two paths to remember.
:rf/default? — framework-auto-wrapped interceptor flag¶
reg-event wraps the user's handler into a single framework interceptor — :rf/event-handler (the one wrapper id for every event handler) — before appending it to the user-supplied :interceptors chain. The wrapper appears in (rf/handler-meta {:kind :event :id 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 {:kind :event :id :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 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 {:kind :machine-guard :id [machine-id guard-id]})/(rf/handler-meta {:kind :machine-action :id [machine-id action-id]}). There is no:machine-guard/:machine-actionside-table —(rf/registrations {:kind :machine-guard})returns{}. See Spec 005 §:machine-guard/:machine-actionhandler-meta surfaces and Spec 001 §Registry model. The Xray Epoch panel and the 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 a JVM that does not host SSR; 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 {:kind :event :id 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 {:kind :event :id id})).
Error namespace convention — six prefix shapes¶
Error categories use six distinct namespace prefixes:
| Prefix | Meaning | Example |
|---|---|---|
:rf.error/<category> |
A genuine runtime error: a contract was violated. | :rf.error/handler-exception, :rf.error/no-such-sub |
:rf.fx/<category> |
An fx-substrate event that rides the error envelope but is not necessarily a failure. | :rf.fx/skipped-on-platform |
:rf.cofx/<category> |
A cofx-substrate event that rides the error envelope but is not necessarily a failure. | :rf.cofx/skipped-on-platform |
:rf.ssr/<category> |
An SSR-substrate event with its own diagnostic shape (server-vs-client divergence, compatibility-check mismatches). | :rf.ssr/hydration-mismatch |
:rf.warning/<category> |
A misuse the runtime can recover from but wants surfaced. | :rf.warning/missing-doc |
:rf.epoch/<category> |
Time-axis tooling (epoch buffer, time-travel) diagnostics. | :rf.epoch/replace-during-drain |
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 these six 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 channel classification. Its value is one of two (the causal channel is data, not a catalogue row):
- always-on — the category rides the production-survivable always-on error-emit axis (surface #4): it meets the promotion criterion and survives
goog.DEBUG=false/-Dre-frame.debug=false, fanning a tight record to registered error shippers. Always-on categories are exactly the promoted runtime:rf.error/*set enumerated in the paragraph below — the subset meeting the promotion criterion, NOT every production-reachable runtime category (a production-reachable category surfaced only as a caller-observed purethrow-error!stays diagnostic). - 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-inforejection the caller observes at its own call site is diagnostic-channel for catalogue purposes — it is not delivered to the error-emit listener — whether a registration-shape rejection (the "Surfaced as a thrown ex-info" rows) or a production-reachable runtime purethrow-error!the caller resolves at the call site (:rf.error/invalid-cofx,:rf.error/bad-path,:rf.error/unknown-egress-profile,:rf.error/bad-egress-opts).
Every emitted category therefore carries a Channel; a conformance test pins it — every emitted category appears in this catalogue with a channel, and every always-on category is exercised through the error-emit listener in at least one test (so promotion is real, not documentary).
The catalogue is the single source of truth for the error categories, their channel, and their default recovery. Per Spec-Schemas §:rf/error-event and Spec-Schemas §Per-category :tags schemas, the per-category Malli :tags schemas are canonicalised in Spec-Schemas — one schema per row below. The category vocabulary is stable: existing categories cannot be renamed or removed; new categories are added by extending the operation namespace (per Spec-ulation).
Production-elision applies to the dev trace surface uniformly, and the Channel column is what tells you whether a row is on it: every diagnostic row loses its emission, and the dev-time validation behind it, per §Production builds. The always-on rows keep theirs. Nor does elision reach the checks the framework relies on to keep its own promises — Spec 000 §Contract C-000.35 settles what may be elided by what the check is for rather than by who declared the schema it reads, so an ordinary registration diagnostic elides while a load-bearing check runs in every build. This section grounds that clause's equivalence promise: what goes is diagnostic, so removing it cannot change frame state or event order. And surviving is not the same as reporting — a row can name a check that runs in production while the payload-bearing trace above it elides, which is why the Channel column and the row's own prose both have to be read.
OUT-OF-CATALOGUE. One emitted :rf.* category is NOT an error-event-catalogue row; the co-edit invariant excludes it. :rf.route/navigation-blocked (and its terminal sibling :rf.route/entry-denied) ride the :rf.event op-type — they are dispatched user-event lifecycle traces (the no-op default events a :can-leave block / :can-enter denial dispatch, per 012 §Navigation blocking), NOT error / warning / advisory categories. Like the rest of the :rf.event/* and :rf.<feature>/<lifecycle> event-vocabulary it appears only as a :rf.event/dispatched event vector and belongs to the dispatched-event family, not this error catalogue. The conformance scan's out-of-catalogue-allow-list is EMPTY.
The always-on error-emit listener (surface #4) is the exception: it survives goog.DEBUG=false and delivers one tight record per catalogued promoted (always-on) runtime :rf.error/* (the handler / interceptor / cofx / flow / fx / reserved-fx / reactive- & compute-sub-exception categories, the parametric sub-input materialization categories :rf.error/sub-input-fn-exception / :rf.error/sub-input-fn-bad-return, the invalid-operation categories :rf.error/no-frame-context, :rf.error/bad-frame-provider-arg, :rf.error/frame-destroyed, :rf.error/no-such-handler, :rf.error/no-such-sub, :rf.error/no-such-fx, :rf.error/unregistered-cofx, :rf.error/override-fallthrough, the suppressed-write category :rf.error/write-after-destroy (the replace-container! choke point's nil-container drop — the write-path partner of :rf.error/frame-destroyed), the teardown-discrimination category :rf.error/on-destroy-handler-exception (the dedicated :on-destroy-throw signal), plus the frame-teardown report :rf.error/frame-teardown-failed — one bounded record per destroy carrying a :hook-failures vector, per §Observability channels and the promotion criterion; plus the machine action / guard exception :rf.error/machine-action-exception — a throwing action, a throwing guard (which converges on the same surface), an :on-done callback, or a destroy-time :exit action — a NON-EVENT union record (structural-only, carrying :failing-id = the action/guard keyword + :state = the active state path) fanned through the SAME :error-emit/dispatch-error-record hook, since machines ships above core's require graph; plus the machine fail-closed spawn reject :rf.error/machine-spawn-unregistered-type — a runtime spawn of an UNREGISTERED :machine-id 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 generated-address arm of :rf.error/machine-spawn-all-duplicate-id — a spawn, or a whole :spawn-all batch, refused because a generated <type>#<n> address it would take is held by a live actor, which depends on the runtime population, fans the same kind of structural-only record (:machine-id / :failing-id / :parent-id / :invoke-id / :frame) through the same hook, while the category's registration and within-batch arms stay diagnostic; plus the drain-depth halt :rf.error/drain-depth-exceeded — the run-to-completion drain hitting its :drain-depth limit (a runaway / infinite dispatch cascade) is inherently DATA-dependent and PRODUCTION-only, so on the dev trace alone it would go silent under goog.DEBUG=false; it fans a NON-EVENT union record (structural-only: :depth / :queue-size / :last-event-id / :tail-event-ids (the cycle-evidence ring of the last K settled ids — the repeating suffix names the runaway cycle) / :dropped-event-ids / :rollback?) via the :error-emit/dispatch-error-record hook, with the rich human :reason prose staying on the DCE'd dev trace; plus the failed live-frame refresh :rf.error/reprojection-failed — a frame whose reprojection threw stays on its prior generation for every id, running a program the registrations no longer describe, so it fans a NON-EVENT union record (structural-only: :frame / :registration / :namespaces / :reason, or the raw :exception for a cause other than a duplicate id) through dispatch-error-record!, once per failed refresh; plus the ambiguous live registration :rf.error/ambiguous-registration — a frame made without :images dispatching an event, or walking an :fx entry, whose id more than one namespace registers runs neither, so the use fans one per-event record (:registration / :namespaces / :reason) through emit-error-both!; plus the promoted SSR error categories — :rf.error/ssr-render-failed, :rf.error/malformed-hydration-payload (both the hydrate-handler path AND the pre-frame FRAMELESS parse sub-path, the latter carrying :frame nil per the :rf.error/no-frame-context frameless precedent), :rf.error/ssr-head-resolution-failed, :rf.error/sanitised-on-projection, :rf.error/ssr-ring-error-view-failed, :rf.error/hydration-frame-id-mismatch (the :rf/hydrate HANDLER's direct-dispatch-sync frame-id-mismatch guard; the boot helper hydrate! validates + THROWS pre-dispatch as the diagnostic-channel sibling, but a direct dispatch bypasses the boot check and hits only the handler, which fails closed and emits the always-on record), and :rf.error/ssr-ring-response-status-invalid (the Ring materialiser's fail-closed :status rewrite; a non-integer status turns the app's 200 into a 500, and with only a dev-bus warning a production host would answer 500 with nothing on either axis. FRAMELESS, :frame nil, because the materialiser is a pure map-to-map fn) — each production-reachable on a long-lived JVM SSR host or a goog.DEBUG=false client build where the dev trace is elided. These 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), the post-commit member (:rf.error/sanitised-on-projection) and the post-resolution materialiser member (:rf.error/ssr-ring-response-status-invalid) are NON-PROJECTING — the SSR error-emit-projection-listener skips them, so promotion changes what off-box SHIPPERS see, never what the WIRE does. Two SSR resource categories — :rf.error/resource-ssr-blocking-timeout and :rf.error/resource-route-blocking — are DEMOTED (kept diagnostic): their failure is recorded in observable resource/route state, so they fail the promotion criterion's leg 2 under BOTH limbs — nothing leaks, and the refusal is not invisible because the state says so — and their named home is the resources trace family + the observability-sink routing, not this axis (see their catalogue rows). Plus the three redirect-gate rejections — :rf.error/redirect-invalid-url, :rf.error/redirect-scheme-rejected and :rf.error/redirect-host-disallowed. :rf.server/redirect's URL gate is production-real and rejects correctly under -Dre-frame.debug=false, but the rejection is a silent no-op on the wire (the fx returns nil and the response simply carries no redirect), so reporting ONLY through the debug-gated trace/emit-error! would leave an attacker-supplied ?next=javascript:alert(1) on a production JVM with no shipper event, no metric and no frame-owned :observability :errors record: a security team could not see open-redirect probing against their own app. The CRLF / NUL gate on the SAME fx THROWS, so it rides :rf.error/fx-handler-exception and is always-on; the no-op half needs this record to match it. Each of the three fans a NON-EVENT union record through the :error-emit/dispatch-error-record hook (the malformed-hydration-payload sibling — a rejected redirect is not a dispatched-event failure). :location is caller-untrusted BY CONSTRUCTION — an app's ?next=… target is attacker-supplied, which is why the gate runs on every redirect and only :allow :any waives its host check — and a rejected target routinely looks like ?next=https://evil.example.com/cb?token=…, so the tag map is built ONCE for both axes with the EP-0015 egress scrub applied to :location INSIDE the builder: the query / fragment carrier VALUES are redacted while the structured path, the scheme and the host are kept. Building once is what stops the production record and the dev trace disagreeing about the same rejection — but the two axes do not carry the same map, and the difference is the whole point. That scrubbed :location is DEV-TRACE-ONLY; the always-on record carries no URL, and no component of one. The blanket EP-0015 carrier scrub does string surgery after the first ? or #, which is right over the app's OWN URL space (the route-miss :url of :rf.error/no-such-handler :kind :route) and is not fail-closed over an arbitrary attacker-supplied FOREIGN one: userinfo (https://alice:pw@host/), the whole path (a reset token is the canonical opaque path-borne secret) and value-less query keys all sit LEFT of the carriers and would ride out verbatim. Widening the deny-list would leave little but the scheme and host anyway and would still be one URL component from the next leak, so the always-on record is instead BUILT FROM a closed allow-list: #{:frame :recovery :reason :scheme-class}. Built from, not filtered down to — an unrecognised tag then has no path into the record even in principle — and by NAME as well as by slot, since :scheme-class is a classification of the diagnostics' :scheme rather than a copy of it. A closed set of keys is not yet a closed set of values, and this record is the corpus's worked example of the difference. Carrying the PARSED :scheme and :host as strings, on the reasoning that a parsed component is structural, would fail: parsing locates a substring in the grammar and says nothing about who wrote it. A scheme is any ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) (RFC 3986 §3.1), so s3cr3t-probe-token:payload would reach the non-http(s) arm carrying the sentinel; a rejected host is by definition one the app did NOT authorise, so https://s3cr3t-reset-token.evil.example/ would ship that name whole. Both would also let a prober drive cardinality — a fresh host per request writes unbounded distinct values into a metrics dimension, the records meant to reveal a flood becoming one. So :scheme arrives as :scheme-class, a lookup into the framework's own closed scheme vocabulary (:javascript / :data / :vbscript / :http / :https, else :other) that folds case first so an alternating-case prober cannot fragment one spike across buckets, and :host is dropped outright: on every arm that carried it, it is a name the app did not authorise, the redirect is already refused so nothing is left to block, and it was the record's last unbounded caller-authored string — :reason keeps the discrimination an operator acts on. :allowlist is absent for its own reason — the application's own security configuration, unbounded policy data naming the boundary being probed, which :reason :not-in-allowlist discriminates without disclosing. Every value the record can carry is therefore a framework-owned keyword or the frame's own id. The source of truth is re-frame.ssr.egress/redirect-record-slots (with the vocabulary at re-frame.ssr.egress/scheme-classes, pinned to the gate's own scheme sets by a parity test), and both are pinned closed by re-frame.ssr-redirect-production-test. All three are NON-PROJECTING, and that half is load-bearing rather than incidental. They are members of re-frame.ssr.error-listener/non-projection-eligible-errors, so the SSR error-emit-projection-listener never buffers them for status projection: the rejection ships a record and NEVER stamps a status. Were they projection-eligible, the default projector's :else arm would map the buffered record to the locked generic 500 and ?next=javascript:alert(1) would become a trivial denial of service — the exact inverse of the promotion's intent (mutation-proved: removing the three entries turns all three wire arms 500 under -Dre-frame.debug=false). The skip sits at the SHARED projection chokepoint, so both postures answer alike — a skip on the production path alone would leave a rejected redirect stamping 500 in DEV through the trace-buffering path while production answered 200, a dev/prod wire asymmetry on a security surface. Plus one arm of :rf.error/schema-validation-failure — the :boundary? true rejection (:source :boundary, :where :event). The category's other arms stay diagnostic, so this is the :rf.error/no-such-handler shape rather than a whole-category promotion: the Channel column is per-category, and the arm-level nuance rides the row's prose. Spec 010 keeps the boundary check UNGATED because it is the production answer for untrusted ingress, so the rejection is real in production and needs a production signal — the skipped handler produces no :db, so without one the always-on :events record would read :outcome :ok for a refusal. The rejection fans a NON-EVENT union record through the :error-emit/dispatch-error-record hook from the router's pipeline tail, off the :rf/boundary-rejected? marker the router stamps on the refusal — one emit site for both enforcement routes, so a rejection can never produce two records — and that same marker settles the dispatch :outcome :rejected. The record is STRUCTURAL-ONLY (:where / :source / :event-id / :failing-id / :schema-id / :frame / :recovery, and nothing payload-derived); the paragraph below says why that is stricter than a scrub rather than weaker. Unlike the three redirect-gate rejections above, this record IS projection-eligible, deliberately: a boundary rejection on a server frame is exactly a 400 (RFC 9110 §15.5.1), the SSR default projector's existing :where :event arm already answers one, and letting it project is what keeps a boundary rejection on an SSR frame from answering a silent 200. This set is exactly the rows the Channel column below marks always-on. Registration-time / dev-only-validation categories (the :rf.error/machine-* registration rejections — e.g. :rf.error/machine-spawn-bad-shape, :rf.error/machine-spawn-all-bad-shape; NOTE the runtime reject :rf.error/machine-spawn-unregistered-type is the exception — it is always-on per above, not a registration-time category — :rf.error/reg-sub-bad-args, :rf.error/at-boundary-missing-schema, the :rf.warning/* advisories, the :rf.epoch/* rejections) stay dev-trace-only, because they fire on dev-only paths production never reaches, so there is nothing to survive. The candidate-rejection arms of :rf.error/schema-validation-failure and :rf.error/malformed-schema are the stated exception, and only to the extent §The promotion criterion says: dev-gated, on the :errors stream under their own gate, on no axis at all in a release build — so they change nothing about this set, which is the set that SURVIVES ELISION. :rf.error/schema-validation-failure is not among the dev-trace-only categories: its validate-*! arms are dev-only in exactly that way, but its boundary arm is not, and that arm is the one promoted above. The :recovery column below is the runtime's built-in, framework-owned recovery; it is NOT app-steerable (there is no per-frame :on-error recovery policy — see §What IS available in production).
:rf.error/schema-validation-failure is promoted one arm at a time, and the split is worth stating rather than leaving the row to carry it alone. Most of the category's arms are genuinely dev-only — the validate-*! family in schemas.cljc elides with the rest of the diagnostic channel, and production never re-runs those paths. The :boundary? true arm does not. That flag is the production answer for untrusted ingress — that is the whole point of it — and per 010 §Production builds the flagged handler's own :schema runs on every build, at the router's step-1 site and on the original dispatched event, so dev and production check the same value at the same point. The rejection is therefore real in production: the handler is skipped and the bad payload never reaches app-db. What must also survive is the signal. Emitted only through trace/emit-error!, gated on interop/debug-enabled?, a boundary rejection under :advanced + goog.DEBUG=false would report nothing on either axis — and because the skipped handler produces no :db, the always-on :events record for that dispatch would read :outcome :ok: a refusal reported as a clean settle. Production-reachable and production-observable are different claims. All three legs of the promotion criterion hold: the arm is production-reachable, a refused payload at an untrusted ingress is a contract breach the next operation cannot see locally, and its silence compounds across a long-lived SSR host under probing, which is exactly when it matters. So the arm rides the always-on axis, the row below reads always-on, and the dev-only arms are named in the row's own prose.
Dev-only and on-the-stream are not in tension, and the :where :app-db arm is where that has to be said plainly. The boundary arm above is on the axis because its CHECK survives elision. The candidate-rejection arms are not — but a check that runs only in dev still reports, in dev, on the stream an application registers to hear about its own errors, because the alternative is a whole discarded transaction reported to nobody. So :where :app-db at :rollback? true fans a structural-only record onto :errors from inside validate-app-schema!'s own debug-enabled? gate: present exactly when the check is, absent from a release build with it, and invisible to the Channel column, which grades survival rather than presence. The class is complete rather than wider: :where :machine-data at :rollback? true does the same from inside validate-machine-data!'s gate, and so do both :rollback? true sites of :rf.error/malformed-schema (the per-entry rejection and the router's wholesale-throw backstop). To the developer these are ONE symptom — the app stopped updating — reached through the value, the machine's :data, the schema FORM, or the validator itself; promoting one of the four would leave the other three silent for no reason a reader could state. What is NOT promoted is equally deliberate and is the line the promotion is drawn on: :rollback? false, a local skipped write. :phase :spawn, :machine-output and every recover-and-continue arm stay trace-only. The rich diagnosis — :value, :explain, the leaf :path — stays on the dev trace, which the record does not alter, so Xray, Story and the epoch recorder read the full diagnosis.
The always-on record is structural-only, and that is STRICTER than a scrub rather than weaker. A validation failure's natural detail is the value that failed — which is why the dev trace carries :value, :received and :explain. At a boundary that value is attacker-controlled or user-private by definition, and it can carry secrets under keys the declared schema never anticipated, so no key-wise redaction rule can be trusted over it: the record is built from identifiers the framework already knows (:where / :source / :event-id / :failing-id / :schema-id / :frame / :recovery) rather than filtered down from the diagnostic tags. :reason is omitted for the same reason and not by oversight — the dev trace's :reason interpolates the offending value into its prose. The dev/production difference that remains is one of DETAIL, not existence: local debugging keeps the payload, and an off-box shipper learns which event, in which frame, failed which schema.
Reading the two right-hand columns. This paragraph is the single statement of which slots a row's :tags cell omits, and why each is omitted; Spec-Schemas §Per-category :tags schemas cites it rather than restating it.
Read which surface the row documents first. The sixth column is headed :tags because most rows emit a trace event, but the catalogue carries three kinds of row, and the column names the payload of whichever surface that row's category actually reaches:
- Trace-event rows — the category emits through
re-frame.trace/emit!/emit-error!. The column lists the keys that genuinely ride under:tagson the wire, and the row-inherited omission below applies. - Thrown-
ex-inforows — the "Surfaced as a thrown ex-info, not a trace" rows. Their payload is the flat ex-data map built byre-frame.error/throw-error!; there is no:tagsmap at all, so the column names ex-data slots. Per §The thrown-error shape,:rf.error/id,:where,:recoveryand:reasonare required on every thrown runtime error — and a row does not restate:recoveryon that account. The shape declares it once, for every thrown row at once, and theDefault :recoverycolumn is where this row's disposition is stated; a per-row restatement adds nothing and can only drift from the column beside it.:rf.error/derived-container-replacedbelow is the worked example — a thrown row whose cell rosters:rf.error/idand:whereand leaves:recoveryto the shape. - Always-on union-record rows — the category fans a pre-built record through
re-frame.error-emit/dispatch-error-record!. That record is flat too, and both projection listeners (the SSRerror-emit-projection-listener, the frame-sinkroute-error-record!) lift every non-summary slot —:recoveryamong them — onto a synthesised:tags, so a consumer does read it there.
A category that reaches two surfaces states both in one cell; :rf.error/derived-container-replaced is the model (`:reason`; thrown ex-data also carries `:rf.error/id`, `:where`).
On a trace-event row, one slot is row-inherited: :recovery. re-frame.trace/build-event supplies it centrally, so no row rosters it and a trace-event row that lists it is a row defect, not a counterexample. :recovery is envelope-level — never a :tags key at all. Emit sites supply the disposition inside the tags map, but build-event STRIPS it and hoists it to the envelope top level on every branch (the :error branch additionally defaults it to :no-recovery). The Default :recovery column is where a row's disposition is stated; restating it under :tags names a key no consumer can read there. Per Spec-Schemas §:rf/error-event, where the envelope types :recovery at the top level. The category is not a :tags key on any envelope: it rides :operation alone, which every envelope carries.
Note this makes an :op-type reading subtle for a handful of :rf.warning/* rows whose emit site routes through trace/emit-error! (:rf.warning/sub-input-dispose-exception, :rf.warning/restore-quiesce-hook-exception, :rf.warning/teardown-hook-exception, :rf.warning/registration-hook-exception): the :op-type column records the semantic severity the category carries, while the built envelope's own :op-type is :error.
The :tags column is pinned, not documentary. Spec-Schemas §Per-category :tags schemas declares one canonical *Tags schema per trace-event row, and a conformance test diffs the pair as key sets: every key a schema declares must be named in its row's cell. A category that gains a payload key without gaining the cell entry fails the gate. Two things follow for authors. A key must be listed, not merely mentioned: a keyword appearing only inside a cross-reference link in the cell is prose about the key and does not document it. And a firing diff does not automatically convict the row — a schema declaring a key the category never actually carries is the defect, and that fix belongs in Spec-Schemas.
:operation |
:op-type |
Channel | Trigger / meaning | Default :recovery |
:tags |
|---|---|---|---|---|---|
:rf.error/handler-exception |
:error |
always-on | The event handler itself threw (the terminal :before that invokes the registered reg-event 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 / :phase) and emits the matching category; a coeffect supplier throws at context assembly, before the chain runs, and re-frame.cofx emits its category itself. Emitted by re-frame.router/emit-pipeline-exception!. Per 002 §Interceptor chain execution |
:no-recovery — the exception propagates; the cascade halts (no :db install, no :fx) |
:event (the event vector — the bare ERROR-tag spelling re-frame.router/emit-pipeline-exception! stamps, not the dispatch-pipeline :rf.event/v), :event-id, :failing-id (the event id), :handler-id (the event id), :frame, :phase (:before), :reason, :exception, :exception-message |
:rf.error/coeffect-exception |
:error |
always-on | A coeffect supplier threw while it ran during context assembly — the registered reg-cofx value-returning supplier raised. Distinct from :rf.error/unregistered-cofx (a declared id with no registration, the typo case): this is a registered ambient supplier (or recordable generator) whose body threw, which fails the event exactly like a handler throw: the handler is skipped, nothing the interceptors staged is committed (no :db install, no :fx), and the handled-event record reports :outcome :error. Distinct from :rf.error/handler-exception so the failure is attributed to the true failing cofx rather than mis-reported as the event handler. Emitted only by re-frame.cofx, from the satisfaction step at context assembly, before the interceptor chain runs; 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 and the event settles :error: nothing staged is installed and no :fx runs |
:rf.cofx/id, :failing-id (the same cofx id), :rf.trace/event-id (the event whose assembly failed), :frame (when the frame is known), :phase (:before), :reason, :exception |
:rf.error/interceptor-exception |
:error |
always-on | A user interceptor (one registered with rf/reg-interceptor and referenced by id from a reg-event metadata-map / frame-config :interceptors chain — its :before or :after threw) raised. The :phase tag discriminates :before (pre-handler) from :after (post-handler teardown / reshape), and the always-on record carries :phase too, beside the interceptor's :failing-id, so the two stay distinguishable in production (on the projected sink record it rides :tags). 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). Distinct 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 handler-wrapper interceptor (→ :rf.error/handler-exception); a coeffect-supplier throw never reaches the chain (→ :rf.error/coeffect-exception, emitted by re-frame.cofx). The :source-coord tag carries the throwing interceptor's definition-site coord when the resolved interceptor value carries one — the reg-interceptor macro registration's coord stamped onto the value at chain resolution, or a :source-coord handed explicitly to the framework-internal constructor ->interceptor*, which wins (there is no coord-capturing ->interceptor macro; per Spec 001 §Source-coordinate capture); absent for framework interceptors and coord-free programmatic registrations (nothing to jump to). Tools (Xray's Epoch INTERCEPTOR row) render a jump-to-source chip from it — parity with the event-handler / sub / view coords. Emitted by re-frame.router/emit-pipeline-exception!. Per 002 §Interceptor chain execution |
:no-recovery — the cascade halts; the :after pass still completes for teardown but no :db installs and no :fx run |
:event (the event vector — the bare ERROR-tag spelling, not the dispatch-pipeline :rf.event/v), :event-id, :failing-id (the interceptor :id), :frame, :phase (:before / :after), :source-coord ({:ns :file :line}, only when the interceptor value carries one), :reason, :exception, :exception-message |
:rf.error/machine-action-exception |
:error |
always-on | A machine action body threw during a transition — OR a guard body threw (which converges on this same surface per 005 §:rf.machine/guard-evaluated), an :on-done callback threw, or a destroy-time :exit action threw — or an action returned something other than nil or an effects map, whose :exception is then the :rf.error/effect-map-shape ex-info naming the action (per 005 §Errors and Cross-Spec-Interactions §11). Distinct from :rf.error/handler-exception: the machine layer catches the throw and emits the machine-scoped category instead, so consumers see exactly one error per failure with full machine context. Always-on: rides the production-survivable error-emit axis (surface #4) as a NON-EVENT union record via the :error-emit/dispatch-error-record hook (machines ships above core's require graph), ALONGSIDE the dev error trace (DCE'd in production) — the same always-on-plus-dev-trace shape :rf.error/machine-spawn-unregistered-type carries; a 2am machine throw must still reach an off-box shipper on a goog.DEBUG=false build |
:no-recovery — the machine cascade halts atomically: the snapshot does not commit (pre-action [:rf.runtime/machines :snapshots <id>] slice is preserved), accumulated :fx from earlier slots in the same Level-2 cascade is dropped, and the :always microstep does not fire on the failed cascade; :skipped for a destroy-time :exit action (:phase :rf.machine/destroy-exit) — the throw is caught and the destroy proceeds with the pre-cascade snapshot |
:actor-id (the LIVE actor INSTANCE whose action threw; :machine-id is reserved for the registered TYPE), :failing-id (the throwing action / guard keyword — the always-on production attribution, per §What IS available §Attribution rule; falls back to the actor instance id for an anonymous inline fn), :handler-id (the machine id), :state (the active state path — the always-on attribution, same slot :rf.machine/guard-evaluated uses), :frame, :action-id, :state-path, :transition, :reason, :event (the bare ERROR-tag spelling), :exception, :exception-message, :exception-data (the last four are dev-trace-only — the always-on record is STRUCTURAL-ONLY, so the developer's arbitrary :exception-data, which may embed app secrets, does NOT ride it) |
:rf.error/fx-handler-exception |
:error |
always-on | A registered fx threw during effect resolution | :no-recovery — the fx is skipped; run continues if other fx independent |
:rf.fx/id, :rf.fx/args, :failing-id (the fx-id), :frame, :reason, :exception, :exception-message, :rf.fx/from (keyword-redirect only — the ORIGINAL fx-id; see the :rf.fx/handled row) |
:rf.error/sub-exception |
:error |
always-on | A subscription's computation threw — via the reactive recompute path (subs/memo.cljc) OR the pure compute-sub resolution path (subs.cljc). Both paths are production-survivable through the always-on error sink route (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 the configure! process default but not the per-frame SSR projector). Recovery is the framework's built-in "return nil". The two arms are told apart by the PRESENCE of the :where tag (see the :tags column), not by its value — there is no :where :reactive |
:replaced-with-default — the sub returns nil; views see no value |
:sub-query, :rf.sub/id, :failing-id (the sub id), :where (the pure compute-sub arm only — the reactive recompute arm stamps no :where), :frame, :reason, :exception, :exception-message |
:rf.error/no-such-sub |
:error |
always-on | A subscription's declared 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. A re-frame-native view substrate reading through re-frame.subs/compute-sub-with-memo may opt an unknown input MID-GRAPH into the SAME category: the miss emits with :where :observation-cold-probe and substitutes nil exactly like the reactive graph, so an ownership-free read and a reactive read report an unknown mid-graph input identically. One condition, one catalogue id |
:replaced-with-default — the unresolved input is substituted with nil; the sub's body still runs |
:rf.sub/id, :unresolved-input, :resolved-inputs, :frame, :where (the ownership-free read surface only) |
:rf.error/sub-cycle |
:error |
diagnostic | A subscription's declared-input graph closes a dependency cycle — two-or-more subs list each other transitively (:a declares [:b], :b declares [:a]) or a sub lists itself (:self declares [:self]). Detected at subscribe / compute-sub build time (the reactive build's per-thread under-construction stack, or the pure compute-sub's per-call memo), which unwinds the partial build BEFORE any cyclic reaction is cached rather than blowing the host stack with a raw StackOverflowError. Registration cannot catch it (the other sub may register later; parametric inputs are dynamic — subs/cache.cljc's transitive-dependent-closure already treats cyclic declared-input graphs as an acknowledged input class). Classified like the flows typed-cycle precedent :rf.error/flow-cycle — dev-only DIAGNOSTIC (thrown-then-caught to unwind, surfaced on the trace channel, DCE'd under :advanced + goog.DEBUG=false). The :where tag discriminates the build path. Emitted by re-frame.subs. Per 006 §Subscription cache |
:replaced-with-default — the cyclic subscription recovers to a nil-yielding reaction (NOT cached, mirroring the no-such-sub miss), so a registration fix rebuilds cleanly |
:rf.sub/id, :rf.sub/query-v, :where (:subscribe / :compute-sub), :cycle (the closing-repeat sub-id path, e.g. [:a :b :a] or [:self :self]) |
:rf.error/reg-sub-bad-args |
:error |
diagnostic | A reg-sub registration shape is not one of the two accepted forms — the app-db reader (reg-sub id computation-fn) and the declared form (reg-sub id {:inputs …} computation-fn), whose :inputs is a literal vector of query vectors or a fn/Var of the query vector — e.g. a malformed literal :inputs (not a vector of query vectors, or an explicit nil, both refused at registration by the SHAPE-only check), :inputs without exactly one trailing computation fn, a non-fn where the computation-fn was required, or an unrecognised arg sequence. It is ALSO how the two re-frame v1 spellings re-frame2 does not accept surface: a :<- anywhere in the args, or a leading input-fn before the computation fn, is refused with a message naming :inputs and MIGRATION §M-75, so a site that missed the migration fails at namespace load rather than registering a shape the runtime has no arm for. Registration-time / dev-only validation (it fires on the registration path, which production never re-runs), so it stays dev-trace-only and does NOT ride the always-on production error-emit listener. Per 006 §Subscription input producers and API §reg-sub input-production modes |
:no-recovery — the registration is rejected; the malformed reg-sub is a programming error to fix |
:rf.sub/id, :received (the offending arg shape), :reason |
:rf.error/sub-input-fn-exception |
:error |
always-on | A parametric subscription's input-fn threw while materializing a concrete subscription node (the cache-miss / compute-sub resolution path, not the hot recompute path — the input-fn runs only at materialization). Production-survivable through the always-on error-emit listener (surface #4); recovery is the framework's built-in fail-closed. Per 006 §Subscription input producers |
:replaced-with-default — materialize a nil-yielding reaction when safe; do NOT silently treat the failure as "no inputs" |
:rf.sub/id, :rf.sub/query-v, :where (:reactive / :compute-sub), :frame, :reason, :exception, :exception-message |
:rf.error/sub-input-fn-bad-return |
:error |
always-on | A parametric subscription's input-fn returned a value that violates the input grammar — a scalar, map, bare keyword, reaction, derefable, malformed query vector, or any shape other than a vector of query vectors (per Conventions §reg-sub input grammar). Production-survivable through the always-on error-emit listener (surface #4); listener-only. A bad input return is NEVER silently treated as no inputs. Per 006 §Subscription input producers |
:replaced-with-default — materialize a nil-yielding reaction when safe; emit the structured error with the outer query vector and sub id |
:rf.sub/id, :rf.sub/query-v, :where (:reactive / :compute-sub), :returned (the offending return shape / class), :frame, :reason |
:rf.error/schema-validation-failure |
:error |
always-on | A :schema-validated value failed validation. Only one of the category's arms is on the always-on axis, and the Channel column is per-category, so the nuance rides here. The :boundary? true arm (:source :boundary, :where :event) fans a STRUCTURAL-ONLY non-event union record through the :error-emit/dispatch-error-record hook and survives goog.DEBUG=false; the dev-time validate-*! family (:app-db / :fx-args / :sub-return / :machine-data / :machine-output / :sub-override) stays dev-trace-only and elides with the rest of the diagnostic channel — EXCEPT the two CANDIDATE-REJECTION arms at :rollback? true, :app-db and :machine-data, which ALSO fan one structural-only record per failing registration onto the :errors stream from inside their own validator's debug-enabled? gate (validate-app-schema! / validate-machine-data!): present exactly when the check is, absent from a release build with it, and so invisible to the Channel column, which grades what survives elision. The promotion is scoped to :rollback? true, NOT to a :where: :machine-data at :phase :spawn is a local skipped write rather than a discarded transaction, so it stays trace-only with the rest of the family, as :machine-output does. That is the same partial-promotion shape as :rf.error/no-such-handler, whose row reads always-on while only its :kind :route arm rides the axis. The always-on record is emitted from re-frame.router's pipeline tail (emit-boundary-rejection-record!) off the :rf/boundary-rejected? marker the router stamps on the refusal, so the dev and production enforcement routes share ONE emit site and a rejection can never produce two records; the same dispatch settles :outcome :rejected on the always-on :events record. Every arm but :app-db builds its dev trace through the one validation seam (per 010 §The validation seam), so every arm names its :failing-id and :frame the same way and carries :explain-humanized in development; a validator that THROWS on any arm's schema reports :rf.error/malformed-schema instead, never this category |
Per-:where — recovery is NOT uniform across the boundaries this one category spans; see the §Schema-validation-failure per-:where recovery table below. In brief: :event skips the handler; :app-db / :machine-data reject the candidate frame transition before it installs; :fx-args skips just the offending fx and continues; :sub-return / :sub-override report and pass the value through; :machine-output completes best-effort and proceeds. Production builds elide the dev-time validate-*! arms entirely (per Spec 000 §Contract C-000.35), so those boundaries apply only in dev; the :boundary? true arm's check AND its record both run on every build. The category vocabulary is stable (per Spec-ulation), so the per-boundary variance is carried by :where + the mini-table rather than by forking the category |
Always-on record (axis 1, :source :boundary ONLY) — structural only: :where (:event), :source (:boundary), :event-id, :failing-id and :schema-id (all three the event id — the same three-slot shape the dev trace carries, so the two axes name the same fact identically), :frame (may be nil, per the :rf.error/no-frame-context frameless precedent), alongside the :error / :time the union-record helper assocs. NO payload-derived slot: no :value, no :received, no :explain, and no :reason — the dev trace's :reason interpolates the offending value. Dev-gated :errors-stream record (:where :app-db, :rollback? true; DEVELOPMENT BUILDS ONLY) — structural only, and a DIFFERENT closed set from the boundary arm's: :error, :where (:app-db), :registered-path (the registration root, a vector the application author wrote), :event-id and :failing-id (both the dispatched event id), :frame, :rollback? (always true on this route), :reason, :time, plus the envelope-level disposition this row's Default :recovery column already states — ten keys in all, pinned EQUAL by re-frame.schema-rollback-errors-stream-cljs-test. Dev-gated :errors-stream record (:where :machine-data, :rollback? true; DEVELOPMENT BUILDS ONLY) — the sibling closed set, also ten keys and also pinned EQUAL there: :error, :where (:machine-data), :machine-id and :failing-id (both the machine's registered id), :phase (:macrostep / :bootstrap — the closed lifecycle vocabulary, and the two values that carry :rollback? true), :frame, :rollback? (always true on this route), :reason and :time, plus the envelope-level disposition this row's Default :recovery column already states — ten keys in all. :reason is composed from the machine id and the phase alone — two keywords — so neither the failing :data nor the schema form can reach it; :value, :received, :explain, :explain-humanized and :schema are omitted outright and stay on the dev trace. :frame is what routes the record to that frame's :observability :errors sink, and it is threaded from the router down to the emit site rather than stamped: re-frame.trace/stamp-frame supplies the trace's :frame on the way out, but an always-on record has no such bus. :reason rides here where the boundary arm omits it, because this one is COMPOSED from the registered path plus re-frame.error/type-of-value of the failing leaf rather than reused from the trace: a closed type vocabulary, never the value and never the schema form. :path is omitted for a reason of its own and not merely for size — the trace's :path conj's Malli's :in onto the root, and :in segments are not all structural (a :set failure's segment IS the failing element value, a :map-of key rides as the key), so it needs a structural-path projection first. :value, :received, :explain, :explain-humanized and :schema are omitted outright. Dev trace (axis 2) — every arm, and it adds the payload: :where (:event/:sub-return/:app-db/:fx-args/:machine-data/:machine-output/:sub-override), :failing-id, :frame (whenever the failure belongs to one), :value, :received (parallel to :value), :explain (Malli explanation map), :explain-humanized (the operator-readable explanation, per 010 §Humanize-hook), the interpolated :reason, and :sensitive? or :large? (true when the value-bearing slots were redacted or size-elided), plus the per-arm structural extras: :path (:app-db only — the failing leaf), :source (:boundary on the boundary arm), :rf.sub/query-v (:sub-return only — the caller's query vector), :rollback? (:app-db/:machine-data/:machine-output — whether the candidate transition was rejected before install), :registered-path (:app-db only — the registration root), :machine-id and :phase (:machine-data/:machine-output only — the failing machine and its lifecycle position), and :schema (:machine-data/:machine-output only — the registered schema verbatim) — all DCE'd under goog.DEBUG=false |
:rf.error/malformed-schema |
:error |
diagnostic | A schema a validating surface checks against is structurally malformed — a childless [:vector], an unknown op, etc. — so the validator THROWS instead of returning a verdict. Every surface reports the throw as this category at its own :where, never as a pass and never as :rf.error/schema-validation-failure, and applies its own recovery: the one validation seam (per 010 §The validation seam) isolates the throw for every surface but :app-db, so the :event / :fx-args / :sub-return / :sub-override / :machine-data / :machine-output arms skip, reject or pass through exactly as the §per-:where recovery table says, and a recordable coeffect's :where :cofx arm reports this trace ahead of the always-on :rf.error/cofx-value-invalid hard error. The app-db arm has the most at stake. Malli validates schema FORMS lazily (at validate-time), so a bad form that registers — under a validator port with no :compile, since development-build reg-app-schema otherwise throws at registration (per 010 §How the surface works) — then makes the registered validator THROW on the first candidate validation (validation runs over the CANDIDATE frame transition, BEFORE install). validate-app-schema! isolates the throw PER-ENTRY: it surfaces this distinct category, fails CLOSED (REJECTS the candidate — the unvalidated state never installs), and keeps validating the frame's sibling schemas. Without that isolation the throw would abort the whole loop, and a defensive catch reading it as a validation PASS would install an unvalidated commit with no trace and no rejection AND disable candidate validation (incl. the privacy-bearing redaction traces) frame-wide for as long as the bad schema stayed registered — a fail-OPEN reached through the SCHEMA rather than the value. The router's defensive catch emits the SAME category — also fail-closed, :rollback? true, trace-then-REJECT — if a wholesale validator-machinery throw reaches it, so a throwing validator can neither pass unvalidated state nor go invisible. Dev-only: every arm's emit is interop/debug-enabled?-gated, the :boundary? true arm's included, although that arm's check and its fail-closed refusal run in every build. The :machine-output boundary catches the same throw at finalize time and PROCEEDS best-effort (:rollback? false — the machine already finished), per 005 §Completion-output validation. The Channel column stays diagnostic and the dev-gated :errors route rides here instead. Both :rollback? true sites — the per-entry rejection in validate-app-schema! and the router's wholesale-throw backstop — ALSO fan one structural-only record onto the :errors stream, under the same rule §The promotion criterion states for the candidate-rejection class: the STREAM is always-on, the PRODUCER is dev-gated, and a release build carries neither. The column does not move for that, because it grades what SURVIVES ELISION and a dev-gated route does not. The router's site needs an EXPLICIT debug-enabled? check where the two validators inherit one — run-candidate-validation! itself runs in every build; both validators return true from inside their own gate in production, so the backstop is unreachable there anyway, and stating the gate is what makes that structural rather than incidental. The :machine-output arm is NOT promoted (:rollback? false) |
:no-recovery — the malformed schema is a programming error; the candidate is rejected (the per-entry AND the machinery-throw case) and a distinct trace fires so the developer fixes the registration |
:where (:app-db/:event/:fx-args/:sub-return/:sub-override/:machine-data/:machine-output/:cofx/:resource-params/:mutation-params), :reason (the throwing-validator message), :failing-id + :frame (the id whose check threw, and its frame — on the :app-db arm the event id whose transition was rejected; the router's wholesale-throw backstop names no registration), the surface's structural tags (:event-id, :rf.sub/id, :rf.fx/id, :machine-id, :phase, :rf.cofx/id, :source), :path / :registered-path (:app-db — registration root, a structural locator, no user value), :schema (the malformed form), :rollback?. The failing value is intentionally NOT carried on any arm — the validator never proved the slot's sensitivity, so omitting it is fail-closed. Dev-gated :errors-stream record (:rollback? true sites only; DEVELOPMENT BUILDS ONLY) — structural only, and a DIFFERENT closed set from the trace above: :error, :where, :registered-path (the PER-ENTRY site only — the router's wholesale-throw backstop names no registration, so its set is the same one MINUS this key), :event-id and :failing-id (both the dispatched event id), :frame, :rollback? (always true), :reason and :time, plus the envelope-level disposition this row's Default :recovery column already states; both sets pinned EQUAL by re-frame.schema-rollback-errors-stream-cljs-test. :reason is a CONSTANT framework sentence on the record and is NOT the trace's, which interpolates THE THROWING VALIDATOR'S MESSAGE — unbounded, author-controlled, and on Malli's own form errors a pr-str of the offending schema. :schema and :path are omitted with it |
:rf.schema/violation |
:warning |
diagnostic | A reg-app-schema registration found a live app-db value present at its path that does not validate against the schema it declares: either the first registration of the path, over a value already there, or a re-registration that changed the schema (a file save re-evaluating reg-app-schema during hot-reload). A path absent from the live app-db is never checked, on any registration, and a re-registration whose schema is unchanged is a silent no-op. 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 registration against pre-existing state. Per 010 §Schema migration on hot-reload. Default is log-and-continue. There is no :rf.spec/violation category; 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; absent on a first registration, which has no prior declaration), :post-reload-schema (the newly-registered schema form), :mismatching-value (the current app-db value at :path that fails the new schema), :frame |
:rf.error/drain-depth-exceeded |
:error |
always-on | A family hit its depth budget (:drain-depth): one external event or dispatch-sync seed plus everything it dispatched, transitively, reached the limit with work still in its internal lane — a runaway / infinite dispatch cascade (per 002 §Run-to-completion §Rules rule 3). The family's next (unstarted) event halts and the family's remaining internal-lane events are discarded; the already-settled events stay durable (no rollback under per-event epochs, :rollback? false), and external input queued behind the family is kept and runs next under a fresh budget. Always-on: the halt is inherently DATA-dependent and PRODUCTION-only — under goog.DEBUG=false the dev trace is DCE'd, so on the dev trace alone a halted drain would ship NOTHING to any sink and simply go silent (a DoS surface per Security §Error catalogue). All three legs of §The promotion criterion hold (production-reachable; a corrupted-invariant contract breach the next operation cannot see; silence compounds with process lifetime), so the halt fans a STRUCTURAL-ONLY non-event union record out through the always-on axis (surface #4) via error-emit/dispatch-error-record! (the frame-teardown-report sibling; the halt has no handler in scope to rethrough, per 011 §rethrow / Spec-Schemas §:rf/epoch-record) so an off-box shipper sees the halt under goog.DEBUG=false. Cycle evidence: :tail-event-ids is the ring of the last K settled event-ids — the repeating suffix IS the runaway cycle (structural ids only, never event args). The rich human :reason prose + the full :last-event vector stay on the dev-only trace/emit-error! (DCE'd in production, per the §ungated direct-call dev-only diagnostic prose fold). Emitted by re-frame.router/handle-depth-exceeded! |
:no-recovery — always indicates a bug; the family halts and its internal-lane work is discarded |
ALWAYS-ON record (structural-only): :depth (the family's event count), :queue-size (the family events discarded), :last-event-id, :tail-event-ids (the family's cycle-evidence ring), :dropped-event-ids (the discarded family events), :rollback? (false). DEV-TRACE (adds): :frame, :last-event (the full halting-event vector), :reason (the runaway-cycle hint) |
:rf.error/no-such-handler |
:error |
always-on | A registrar-shaped lookup missed. Covers three distinct failure modes, discriminated by the :kind tag (mandatory on every emit): (1) :kind :event — a dispatch / dispatch-sync arrived with no registered event handler (emitted by router.cljc); (2) :kind :frame — a Tool-Pair surface (restore-epoch!, or a pair-tool injection via replace-frame-state!) addressed a frame-id that is not in the frame registrar (emitted by epoch.cljc; see Tool-Pair §Surface behaviour against destroyed frames); (3) :kind :route — :rf.route/handle-url-change (or a route-url caller) saw a URL that matched no registered :path pattern (emitted by routing.cljc; see 012 §Route-not-found and the default-projector mapping at 011 §Default projector). Consumers route on :kind for per-mode handling; tools that want a single "registrar miss" filter match the operation keyword alone. The :kind :event dispatch-miss and the :kind :route URL miss are both production-survivable through the always-on error-emit listener (surface #4) — the route miss as a NON-EVENT union record fanned through the :error-emit/dispatch-error-record hook (routing ships above core's require graph), carrying :kind / :frame / :time / :recovery / structured :reason and a :url whose query and #fragment carrier VALUES are redacted at the emit site per EP-0015, so a production SSR host answers 404 for an unroutable URL instead of a soft-404 200. Because the category reaches the SSR projector in production, 011 §Default projector gates its 404 arm on :kind :route: a :kind :event / :kind :frame miss is a server defect and projects the locked generic 500. Recovery is the framework's built-in :replaced-with-default |
:replaced-with-default — no-op; emit the trace + always-on listener record |
:kind (one of :event, :frame, :route — mandatory), plus mode-specific keys: :rf.event/v + :rf.trace/event-id + :frame (:kind :event); :frame (:kind :frame); :url + :frame (:kind :route) |
:rf.error/no-frame-context |
:error |
always-on | A frame-scoped op 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 category is defined by the ABSENCE, not by a list of ops — and, unlike its one-door sibling :rf.error/ambient-frame-refused (whose one door is the READER every ambient resolution consults, not a requiring primitive), not by a single primitive either. Any surface that REQUIRED a stamp can report one missing: re-frame.frame's three requiring primitives — require-current-frame! (the ambient scope read, so EVERY op that can meet the refusal can meet this absence), require-frame-stamp! (the CARRIED-stamp seam — a framework fx, timer, managed-HTTP reply, history listener, SSR hydration or machine-lifecycle callback whose token arrived carrying no frame) and require-frame-provider-target! (a public frame-provider handed an explicit nil :frame, which establishes no usable scope) — plus explicit-target guards that throw it flat (re-frame.resources). Those names are illustrative and grow with the API; the CONDITION is what this row contracts. Illustrative firing cases: an ambient op in a view rendered under no boundary and no with-frame, 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 correlation graph — the capture site's own dispatch-id, read off the in-scope handler scope, and the ONLY ancestry key either frame-resolution payload carries: the parent link is a property of that dispatch, walked from it through the graph, never a second key restated here — fully attributing a callback captured at handler X in frame Y whose continuation fired with no stamp. A security surface for lost / tampered frame context (per Security §Error catalogue). Emitted by re-frame.frame/emit-no-frame-context!, reached from each requiring primitive above (which emits, then throws) |
: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 — the op kind the requiring call site named, an OPEN set and never a closed pair: core's own bare ops (:subscribe / :subscribe-once / :dispatch / :capture-frame / :current-frame-id / :use-frame / :clear-sub-cache! / :frame-provider / …), the namespaced ids the carried-stamp seam names (:rf.http/managed, :rf.route/navigate, :rf.ssr/hydrate, :rf.machine/after-schedule, …), and whatever a pass-through caller supplies (re-frame.schemas.storage forwards its OWN caller's keyword, so the set is open by construction). :where (the resolving call site — a SYMBOL, e.g. 're-frame.subs/subscribe, 're-frame.router/build-envelope, 'rf.ssr/hydrate!; the 1-arity requiring forms omit it), :event-id (the query-id / event-id the op carried — present only for ops that carry one), :reason (the composed three-ways-to-establish-a-scope sentence), plus the capture-site ancestry correlation key :rf.trace/dispatch-id |
:rf.error/bad-frame-provider-arg |
:error |
always-on | A public frame-provider's :frame was non-nil but neither a keyword nor a live frame value (a string {:frame "app"}, a number, a collection, …). frame-provider accepts a frame TARGET — a frame-id keyword OR a live frame value (make-frame's return token) — so anything else is a bad public provider argument, distinct from absence (:rf.error/no-frame-context — a nil :frame) and from a disturbed reader-side React-context read (:rf.error/frame-context-corrupted). Validated at every public provider entry point — Reagent re-frame.views.provider/frame-provider and the shared UIx-spine core re-frame.substrate.spine/build-frame-provider-element — BEFORE the value reaches React Context, so the bad value is never silently coerced to a registered keyword frame by the lower-level context reader's prop-stringified-keyword coercion. Rides the always-on error axis (surface #4) so it survives goog.DEBUG=false, then throws. Emitted by re-frame.frame/emit-bad-frame-provider-arg! (via require-frame-provider-target!) |
:supply-frame-target — the provider fails fast; supply a frame-id keyword (e.g. :todo) or a live frame value |
:received (the offending value), :where (the validating provider call site), :reason |
:rf.error/bad-subscribe-opts |
:error |
diagnostic | subscribe or subscribe-once was handed a second argument that is neither a map nor nil — a bare frame id, (rf/subscribe [:q] :frame-b), or v1's dynamic-args vector, (rf/subscribe [:q] [dyn]). The second argument is the opts map, {:frame target} per 002 §Frame-targeted dispatch and subscribe, and (:frame opts) reads nil off a keyword or a vector, so either shape would otherwise read the AMBIENT frame silently — another frame's value, with nothing to say so. nil is no opts and reads the ambient frame as the 1-arity does. Thrown ex-info at the call site, not a trace, so diagnostic-channel: a caller bug the caller observes where it made it. Emitted by re-frame.subs's subscribe / subscribe-once. Per 006 §Lookup algorithm |
:pass-an-opts-map — the call throws; name the frame under :frame, (rf/subscribe [:q] {:frame :frame-b}), and put a query's parameters in the query vector itself |
:received (a redaction-safe shape summary of the offending value), :where ('rf/subscribe / 'rf/subscribe-once), :reason |
:rf.error/bad-unsubscribe-arg |
:error |
always-on | unsubscribe was handed a query vector — (unsubscribe [:q]), the shape copied from the subscribe row — rather than the reaction subscribe returned. unsubscribe releases by handle (per 006 §unsubscribe): a vector names a cache slot, not the share the caller holds, so it is refused rather than read as a release. Distinct from the releases that are silent no-ops by contract — nil, a never-cached reaction, and a reaction whose slot was evicted or whose frame was destroyed. Rides the always-on error axis (surface #4) so it survives goog.DEBUG=false, then throws. Emitted by re-frame.subs/unsubscribe |
:supply-reaction — the release fails fast; keep the value of (subscribe q opts) and pass it back, (unsubscribe r) |
:received (the offending vector), :where ('re-frame.subs/unsubscribe), :reason |
:rf.error/ambient-frame-refused |
:error |
always-on | A frame-scoped op resolved its frame AMBIENTLY inside a render extent that REFUSES ambient frame resolution — a substrate whose render phase imposes its own read discipline established the extent through re-frame.frame/call-with-ambient-frame-refused (the REFUSAL tier of the resolution chain: the two scope tiers answer WHICH frame is current, this one lets a substrate say NO FRAME IS LEGAL HERE). The category is defined by the DOOR, not by a list of ops — and the door is the READER, re-frame.frame/resolve-current-frame, not the requiring primitive: every ambient resolution goes through that reader, and the refusal is applied THERE — it withholds the ambient answer (and, where the extent declared an :extent-frame, a MISMATCHED carried one too), while require-current-frame! is the caller that turns a withheld answer into this error. The distinction is load-bearing rather than pedantic: subs/subscribe's 1-arity — the framework's per-read path — inlines (or (resolve-current-frame) (require-current-frame! …)) so its error payload is built only on the path that reads it, and must NOT be collapsed; an ambient subscribe that RESOLVES therefore never reaches the requiring primitive at all, and a refusal enforced only there would miss exactly the op EP-0038 HD-002 clause (a) is about. So every op that resolves its frame ambiently and REQUIRES one reaches this refusal — the reads (subscribe, subscribe-once), dispatch, and equally use-frame, clear-sub-cache!, elision's declarations / sensitive-declarations, and route-link's render-site resolution. Registrations — reg-flow / clear-flow, reg-http-interceptor / clear-http-interceptor, reg-app-schema — read no frame, so they never meet this refusal. An op that TOLERATES a nil resolution takes its own frameless branch instead, because withholding the answer is the reader's half and raising is the requiring caller's. Those names are illustrative and grow with the API; the CONDITION — resolving a frame ambiently inside a refusing extent — is what this row contracts. Distinct from absence, which is the whole reason it is its own category: a frame IS in scope, so :rf.error/no-frame-context's recovery — establish a scope — cannot fix it, and reporting it as absence would send the author to add a second frame boundary that changes nothing. The refusal withdraws the AMBIENT (React-context) tier ONLY — an explicitly carried stamp still carries, both as {:frame <id>} (which never consults the resolver) and through with-frame (the dynamic tier), per 002 §Frame target resolution — with ONE exception, opt-in and substrate-declared: where the refusal names an :extent-frame, a with-frame stamp naming a DIFFERENT frame is refused too (the MISMATCH arm), because a body whose ambient ops target :b while its own reads, lowered intents and children target :a holds TWO frames, and frames are ISOLATED contexts. A MATCHED stamp still carries — refusing it would make with-frame and {:frame …} disagree — a refusal naming no :extent-frame admits every carried stamp, and the {:frame …} override is untouched either way, since it never reaches the resolver. Firing case: a Fresco boundary body (EP-0038 HD-002 clause (a)), where an ambient rf/subscribe would mutate the subscription cache during the render phase while contributing ZERO collector edges, leaving a boundary that never re-renders when that subscription moves. The two PURE doors are the one carve-out, and it is keyed on the declaration (002 §The refusal tier): a refusing extent that DECLARED its :extent-frame ANSWERS that frame to current-frame-id (identity only) and to capture-frame's 0-arity (a frame-locked api, no subscription or dispatch at capture time) when nothing is carried — no edge, no mutation, nothing for the fence to protect — so inside a Fresco body those two resolve the boundary's own frame rather than raising this. They still raise it on the MISMATCH arm (the mismatch is checked first) and inside an extent that declared no frame; the 1-arity (capture-frame <frame-id>) never consults the resolver at all. Stateful ambient operations are refused regardless. The refusing substrate supplies the sentence naming what to write instead and it is appended verbatim to the composed :reason. Rides the always-on error axis (surface #4) so it survives goog.DEBUG=false — what it prevents is a silently frozen boundary, which has no symptom at the point of the mistake — then throws. Emitted by re-frame.frame/emit-ambient-frame-refused! (via require-current-frame!) |
substrate-supplied — :read-through-the-boundary-collector for Fresco, with core defaulting to :use-the-substrates-own-read-surface; the op fails fast, and the fix is to use the read / dispatch surface the enclosing render extent provides or to carry the frame explicitly; on the MISMATCH arm the fix is narrower — carry the EXTENT'S OWN frame if that is what you meant, or move the operation outside the render |
:operation — the op kind the resolving call site named, an OPEN set and never a closed pair: whatever require-current-frame! was called with (:subscribe / :subscribe-once / :dispatch / :capture-frame / :current-frame-id / :use-frame / :clear-sub-cache! / :route-link / …). :substrate (the refusing substrate, e.g. :fresco) and :extent (the refusing extent, as a symbol) are SUBSTRATE-supplied, so both are absent under the fail-closed default refusal. :extent-frame — THE FRAME THE REFUSING EXTENT IS RENDERING — is the one substrate-supplied key core READS rather than passes through: declared on the refusal detail, normalized to an id by ambient-frame-refused-payload, and present whenever the substrate named one (naming it is what arms the mismatch arm at all). :carried-frame — the stamp that WAS carried — is present on the MISMATCH arm ONLY: set by require-current-frame! and by nothing else, and the key the payload builder discriminates the two :reason sentences on. The pair is load-bearing together, because a mismatch diagnostic that names only one of the two frames sends the author looking for the wrong one. :where (the resolving call site), :event-id (the query-id / event-id the op carried — present only for ops that carry one; :capture-frame, :current-frame-id, :use-frame and the other no-id surfaces omit it), :reason (composed, one sentence per arm — the ambient-find arm says a frame IS in scope and an explicitly carried one still carries; the mismatch arm names BOTH frames and says why a carried stamp lost for once; the substrate's own sentence is the tail of either), plus the capture-site ancestry correlation key :rf.trace/dispatch-id — the same single key its :rf.error/no-frame-context sibling carries, and for the same reason: ambient-frame-refused-payload reads the in-scope handler scope's dispatch-id and nothing else, so a parent link is walked from that dispatch rather than restated on the payload |
:rf.error/frame-root-missing-id |
:error |
diagnostic | The ENSURE component rf/frame-root (create-if-absent / reuse-no-reseed / provide-id, no destroy-on-unmount) was mounted with no keyword :id. frame-root creates a frame via make-frame and registers it under :id in the one frame registry, so :id is REQUIRED — an idempotent re-mount under the same id is how the frame-root reuses the live frame without re-seeding across hot reload, and an anonymous frame-root could neither be reused idempotently nor addressed by descendants. It fails fast at mount rather than minting an un-addressable anonymous frame. (Distinct from :rf.error/frame-provider-frame-absent, the SCOPE frame-provider {:frame …} absent-frame boundary; from :rf.error/frame-root-given-frame, the :frame-on-a-root did-you-mean; and from :rf.error/bad-frame-provider-arg, the non-keyword-:frame boundary on the scope shape.) Mount-time validation, thrown ex-info, not a trace; diagnostic-channel. Emitted by re-frame.views.frame-boundary/require-frame-root-id!. Per 002 §frame-root and API §Registration |
:supply-frame-id — frame-root fails fast; supply a keyword :id (its required key), or switch to rf/frame-provider {:frame …} to scope an already-created frame |
:where (the validating frame-root call site), :reason, :received (the offending :id) |
:rf.error/frame-root-given-frame |
:error |
diagnostic | The ENSURE component rf/frame-root was given a :frame — the SCOPE key. frame-root CREATES / REUSES a frame under :id; it does not scope an existing one, so a :frame prop is a did-you-mean configuration error naming frame-provider. Covers the both-keys case ({:id … :frame …} on a frame-root is still a :frame-on-a-root error). Mount-time validation, thrown ex-info, not a trace; diagnostic-channel. Emitted by re-frame.views.frame-boundary/reject-frame-root-frame!. Per 002 §frame-root and API §Registration |
:use-frame-provider-to-scope — to SCOPE an already-live frame, use rf/frame-provider {:frame …}; to CREATE-if-absent, pass frame-root {:id …} |
:received (the offending :frame), :where (the validating frame-root call site), :reason |
:rf.error/frame-root-reconfigured |
:error |
diagnostic | A MOUNTED rf/frame-root's :id / opts changed after it committed. A committed frame-root scopes exactly ONE frame for its lifetime; re-pointing it at a different frame id — or a different make-frame configuration — is a configuration error, not a reconfiguration the boundary supports (committed reconfiguration support is unearned machinery, and the pre-split silent useRef ignore hid a real mistake). Render-phase validation (the committed opts baseline is compared against the render's opts), thrown ex-info, not a trace; diagnostic-channel. Emitted by re-frame.views.frame-boundary/require-unchanged-root-opts!. Per 002 §frame-root and API §Registration |
:remount-with-a-new-key — to scope a DIFFERENT frame, give the frame-root a React key that changes so it remounts; to reconfigure the SAME frame (new :images / :initial-events), call rf/make-frame with the same :id directly |
:committed (the committed opts), :received (the changed opts), :where (the frame-root render site), :reason |
:rf.error/frame-provider-frame-absent |
:error |
diagnostic | The SCOPE-only component rf/frame-provider ({:frame existing-id} — provides an ALREADY-CREATED frame id through React context; creates / refreshes / destroys nothing) named a frame that is NOT live in the registry (never created, or destroyed). Scoping a subtree to an absent frame would silently establish a context every descendant subscribe / dispatch then mis-resolves, so it is a configuration error: the provider fails loud at mount rather than scoping descendants to a phantom frame. This is the guardrail Story + Xray rely on (mounting their tool/app frame's panel subtree against an existing frame). (Distinct from :rf.error/frame-root-missing-id, the ENSURE frame-root {:id …} missing-id boundary; from :rf.error/bad-frame-provider-arg, the non-keyword-:frame boundary; and from :rf.error/no-frame-context, the nil-:frame / no-scope boundary.) Mount-time validation, thrown ex-info, not a trace; diagnostic-channel. Emitted by re-frame.views.frame-boundary/require-live-frame-for-scope! (adapter substrates). Per 002 §frame-provider and API §Registration |
:ensure-or-create-the-frame — the provider fails fast; ensure the frame is created (e.g. rf/make-frame) before this provider mounts, or use the ENSURE component rf/frame-root {:id …} to create-if-absent |
:frame (the absent frame id), :where (the validating provider call site), :reason |
:rf.error/frame-provider-given-id |
:error |
diagnostic | The SCOPE-only component rf/frame-provider was given an :id — the ENSURE key. frame-provider provides an ALREADY-CREATED frame through React context and creates NOTHING, so an :id prop is a did-you-mean configuration error naming frame-root. Covers the both-keys case ({:frame … :id …} on a provider is still an :id-on-a-provider error). Mount-time validation, thrown ex-info, not a trace; diagnostic-channel. Emitted by re-frame.views.frame-boundary/reject-frame-provider-id!. Per 002 §frame-provider and API §Registration |
:use-frame-root-to-ensure — to CREATE the frame if absent (and reuse it if present, without re-seeding), use rf/frame-root {:id …}; to scope an already-live frame, pass frame-provider {:frame …} |
:received (the offending :id), :where (the validating provider call site), :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 |
always-on | A reg-event handler (or an :after interceptor) produced a malformed effect-map shape. Three cases, split across two planes by the commit boundary. Cases (a) and (b) are the ENVELOPE, and they REFUSE the event. (a) bad top-level key — a key outside the closed set #{:db :rf.db/runtime :fx}; a classification key (:sensitive / :large / :clear-sensitive / :clear-large) is refused with a :reason naming reg-classification, where app-db classification is declared (015 §Static app-db classification) (per MIGRATION §M-8 and Spec-Schemas §:rf/effect-map; :rf.db/runtime is the reserved framework-authority state effect, not a shape error). (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. Both are checked at the router's FINAL-effects boundary (in re-frame.router/commit-and-flow!, immediately before the commit, so an :after-interceptor-inserted key is caught by the same check as a handler-returned one — ONE check, ONE boundary). The check is a pure, non-throwing first-defect validator (re-frame.events/effect-map-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-effect-map-shape! and aborts the event with NO :db / :rf.db/runtime commit and no :fx. No partial commit, and one emission naming the FIRST offending key, not one per key. Uniform in every build and not configurable: erasing the abort in production would make dev abort what production commits. Case (c) is a ROW ON THE do-fx PLANE, and it still recovers. (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). That entry is dropped and its sibling entries still run; a nil / empty entry is the legal conditional-fx no-op and is NOT reported. Case (c) sits POST-commit on the best-effort do-fx plane (re-frame.fx/fx-entry-ok?), which is why the granularity line falls where it does — the same pre-commit-transactional / post-commit-best-effort asymmetry the fx contract already has. All three fan through error-emit/emit-error-both!, riding the always-on error-emit axis (surface #4) alongside the DCE'd dev trace, so the sink route carries the fault into production under goog.DEBUG=false — the always-on record carries :offending-key as its lone discriminator, while the rejected :value and the interpolating :reason ride the dev trace only. That promotion is the point: the failure being reported is a DROPPED EFFECT, which has no symptom at the dispatch call site |
Per-case. Cases (a)/(b): :fix-effect — move the offending key into :fx (e.g. {:fx [[:dispatch [:saved]]]}) or give :fx its outer vector; a case-(a) map carrying none of the closed keys is most likely the new app-db itself, and its :reason says to return it as {:db <new-db>}; the event aborts pre-commit until corrected. Case (c): :logged-and-skipped — the malformed entry is dropped and its sibling :fx entries still run |
: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/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 commit-fx-effects, which the :rf/event-handler interceptor runs on the handler's return |
:no-recovery — the offending return is dropped; the dispatch is treated as a no-op |
:event-id (first of the event vector, when vector-shaped), :event (the event vector — the bare ERROR-tag spelling), :returned (the offending value), :returned-type (the runtime type), :reason |
:rf.error/override-fallthrough |
:error |
always-on | An override was specified but no matching id existed | :replaced-with-default — use the registered fx as if no override existed |
:failing-id (the fx-id the override failed to redirect), :overrides-map, :looked-up-id, :frame, :reason |
:rf.fx/handled |
:rf.fx |
diagnostic | An fx was successfully dispatched (the runtime reached the fx and either ran the registered handler without exception or completed the reserved-fx-id action). Emitted by re-frame.fx/handle-one-fx on the success path so the :rf/epoch-record :effects projection captures one entry per dispatched fx (per Spec-Schemas §:rf/epoch-record). A keyword-redirected fx (an :fx-overrides id-redirect, per 002 §:fx-overrides) stamps the original fx-id as :rf.fx/from on this trace and on the sibling arg-bearing :rf.error/fx-handler-exception / :rf.fx/skipped-on-platform emits (:rf.fx/id carries the redirect target; the tag vocabulary :rf.fx/override-applied already uses), so the classification chokepoint composes both registrations' declarations (per 015 §Registration-owned transient classification) and tools see the redirect fact on the arg-bearing trace itself, not only on the separate :rf.fx/override-applied event |
n/a — success-path trace, not an error/warning | :rf.fx/id, :rf.fx/args, :frame, :rf.fx/from (keyword-redirect only — the ORIGINAL fx-id) |
:rf.fx/skipped-on-platform |
:warning |
diagnostic | An fx was skipped because its :platforms excluded the active platform (per 011) |
:skipped — documented; not really an error |
:rf.fx/id, :rf.fx/args, :rf.fx/platform, :rf.fx/registered-platforms, :frame, :rf.fx/from (keyword-redirect only — the ORIGINAL fx-id; see the :rf.fx/handled row) |
:rf.cofx/skipped-on-platform |
:warning |
diagnostic | A declared ambient cofx's value-returning supplier was skipped because its :platforms excluded the active platform (per 011). Mirrors :rf.fx/skipped-on-platform; the supplier is NOT invoked and no value is delivered into :coeffects. Emitted from ambient-supplier execution in re-frame.cofx (the run-ambient-supplier step reached via a handler's :rf.cofx/requires declaration) after registry lookup succeeds but the platform predicate rejects |
:skipped — that coeffect's delivery is skipped; the event handler still runs |
:rf.cofx/id, :frame, :rf.cofx/platform, :rf.cofx/registered-platforms |
:rf.ssr/hydration-mismatch |
:warning |
always-on | A React-recoverable hydration divergence on a hydrating root. Every React-family root (Reagent, reagent-slim and UIx through the spine's mount; Fresco through its hydrating door) installs an onRecoverableError reporter in every build, and an error React recovers from inside that root's adoption window (shut on the hydration commit by a dedicated closer) — a text-content mismatch, or a missing / extra / wrong-type element, at any depth — surfaces here. It reports hydration recoveries, not exhaustive server-vs-client divergence: an attribute-only mismatch (a stale class / style / ARIA value, the root view's own markup included) takes React's development-only console.error path, is not patched, and produces no trace and no record. The structural record is on the always-on axis: the reporter fans a STRUCTURAL-ONLY union record (:where, :recovery, :time, and :frame when the root door knows it) through :error-emit/dispatch-error-record, so external monitoring sees a production recovery; React's message rides the DCE'd dev trace alone, because in development it carries app text. There is no strict mode: CI fails a build by watching the root's public :on-recoverable-error callback. Recoverable degradation: non-projection-eligible, so it never flips an SSR response status. Per 011 §Hydration-mismatch detection (the adoption channel and its attribute-only boundary) |
:warned-and-replaced — React's native adoption is literally warn-and-replace (it re-renders the divergent subtree on the client) |
:error (React's message, or nil), :where (the root door — re-frame.substrate.spine/make-render or re-frame.fresco.impl.mount/hydrate-root!). No hash and no :root-id, whichever root emits it; per Spec-Schemas §Per-category :tags schemas |
:rf.ssr/host-adopted |
:info |
diagnostic | A defhost declaration under :server :client-only completed its adoption crossing — the host region had rendered its declared :fallback (server bytes, and hydration's first client pass, both under the gate's SERVER snapshot) and a later render found adopted? true, so React's post-hydration pass replaced the placeholder with the foreign component. Emitted once per DECLARATION, not per site and not per root: mint-host! mints one gate per defhost and the crossing state is closed over by it. A fresh createRoot mount emits nothing — such a root consults no server snapshot, renders the component on its first pass, and crosses nothing. Debug-gated and DCE'd. Emitted by re-frame.fresco.impl.codec/mint-host-gate! |
n/a — success-path trace, not an error/warning | :host (the declaration's name), :where (re-frame.fresco.impl.codec/mint-host-gate!) |
: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 |
:warned-and-applied — the trace fires; hydration proceeds with the server-supplied app-db (the runtime does not abort hydration on version drift) |
:expected (server-supplied value), :actual (client-side runtime value) |
:rf.ssr/schema-digest-mismatch |
:warning |
diagnostic | The hydration payload's :rf/schema-digest differs from the digest of the client's currently-registered app-schema set. Emitted by the :rf.ssr/check-schema-digest fx dispatched from the reference :rf/hydrate handler. Useful for catching deploy drift where server and client bundles were built against different schema sets. Per 011 §The :rf/hydrate event |
:warned-and-applied — the trace fires; hydration proceeds with the server-supplied app-db |
:expected (server-supplied digest), :actual (client-computed digest) |
:rf.ssr/compatibility-check-skipped |
:warning |
diagnostic | The :rf.ssr/check-schema-digest fx fired but its :schemas/app-schemas-digest actual-value hook is not registered (the schemas artefact is not on the classpath), so the comparison cannot be made. The :rf.ssr/check-version fx does NOT emit this — it resolves its client-side "actual" from the SSR artefact's compiled-in pattern-protocol constant, which always resolves. The fx never throws — degraded-but-running is the locked posture. Per 011 §The :rf/hydrate event |
:skipped — the comparison is no-opped; hydration proceeds |
:check (the calling fx-id, :rf.ssr/check-schema-digest), :reason (why no actual value could be resolved) |
:rf.error/hydration-frame-id-mismatch |
:error |
always-on | The hydration payload's :rf/frame-id (the frame the server stamped at render time) is present-and-different from the frame being hydrated into — the runtime will not silently install a server slice into a different frame than the one it was rendered for. TWO emit sites share this id: (1) the :rf/hydrate HANDLER guards the direct-dispatch-sync split path: a present-and-different :rf/frame-id against the dispatch target (:rf.frame/id) fails CLOSED — app-db AND runtime-db left unchanged, no compatibility-check fxs — emitting a dev trace AND an always-on error-emit record (off-box-shippable under goog.DEBUG=false, the :rf.error/malformed-hydration-payload sibling: corrupt/wrong-frame hydration INPUT is a fail-closed boundary event, not a dev teaching diagnostic). Emitted by re-frame.ssr.hydrate/hydrate-event-handler (ssr/hydrate.cljc). (2) the boot helper hydrate! validates the payload's :rf/frame-id against the explicit :frame target and THROWS pre-dispatch — surfaced as a dev trace (interop/debug-enabled?-gated) AND a thrown ex-info that aborts the boot. The boot THROW is the diagnostic-channel sibling of the handler's always-on record (a thrown abort cannot also ride the always-on listener); the row is graded always-on by its production-reachable handler path. Emitted by re-frame.ssr.boot/validate-payload-frame-id! (ssr/boot.cljc). Per 011 §The :rf/hydrate event and 011 §Client flow |
:no-recovery (handler: fails closed, frame-state unchanged) / :supply-matching-frame (boot: aborts) — pass the frame the server stamped, or correct the server's render frame |
:where (rf.ssr/hydrate or rf.ssr/hydrate!), :frame, :failing-id (:rf/hydrate), :target-frame, :payload-frame-id, :reason |
:rf.error/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 |
:rf.ssr/destroy-frame-failed |
:warning |
diagnostic | A best-effort SSR frame teardown (destroy-frame-quietly!) threw while tearing the request frame down; the throw is swallowed (it must not mask the real handler error, which has already been materialised) and surfaced on the trace bus rather than escalating to a user-visible 500. Emitted by re-frame.ssr.ring.lifecycle/destroy-frame-quietly! (ssr-ring/lifecycle.clj). Per 011 §The Ring host adapter |
:warned-and-skipped — teardown continues best-effort; the failed destroy's cleanup may be incomplete |
:frame, :reason, :ex-class |
:rf.ssr/ssr-non-integer-status |
:warning |
diagnostic | The Ring response materialiser saw a non-integer :status (almost certainly a caller bug). It will not guess a coercion ("404" is not assumed to mean 404), so it fails closed to a valid 500 Ring response and surfaces the defect rather than ship a malformed map. This is the DEV half (axis 2) of that one report: it keeps the offending VALUE, which the always-on twin :rf.error/ssr-ring-response-status-invalid deliberately does not carry, and both are emitted from the single site re-frame.ssr.ring.pipeline/report-non-integer-status! so the two axes cannot disagree. Reached from re-frame.ssr.ring.pipeline/fail-closed-status (ssr-ring/pipeline.clj), which must be called ONCE per materialisation — its defect arm has a side effect. Per 011 §HTTP response contract |
:failed-closed-to-500 — the response status is forced to 500 (a valid, fail-closed Ring response) |
:where (:ssr-ring/ssr-response->ring-response), :status, :status-type, :reason |
:rf.ssr/ssr-non-string-header-value |
:warning |
diagnostic | A Ring response header carried a non-string value (Ring header values must be strings or a vector of strings) — almost certainly a caller bug. The materialiser coerces it to its string form so the wire shape is valid, and surfaces the defect on the dev trace bus. Emitted by re-frame.ssr.ring.headers/merge-pair-into-header-map (ssr-ring/headers.clj). Per 011 §HTTP response contract |
:warned-and-coerced — the value is coerced to its string form; the response is well-formed regardless of the warning |
:where (:ssr-ring/merge-pair-into-header-map), :header, :value-type, :reason |
:rf.ssr/ssr-redirect-no-target |
:warning |
diagnostic | A :rf.server/redirect set :redirect with no :location — a 3xx with no Location header is a malformed wire response (the browser has nowhere to go). The runtime accepts a target-less redirect at the fx boundary (the location is 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.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 same fail-OPEN class the schemas and routing boundaries refuse. Both partitions validate fail-CLOSED before installation: the malformed payload is REJECTED and the existing client frame-state is left unchanged. Always-on: corrupt hydration INPUT is a fail-closed boundary event (not a dev teaching diagnostic), so it rides the always-on error-emit axis (surface #4) ALONGSIDE the dev trace — an off-box shipper on a goog.DEBUG=false client build must still see the rejected payload. Two emit sites: the hydrate-event-handler shape-guard fires with the resolved :frame; the read-server-payload PRE-FRAME parse guard fires a FRAMELESS always-on record (:frame nil) — read-server-payload runs before hydrate! resolves the target frame, so there is no frame to carry, exactly the :rf.error/no-frame-context frameless always-on precedent. Emitted by re-frame.ssr.hydrate/hydrate-event-handler (shape guard) and re-frame.ssr.boot/read-server-payload (frameless parse guard). Per 011 §The :rf/hydrate event |
:no-recovery — the malformed payload is rejected; the client frame-state is left unchanged and the host renders client-only (degraded-but-running). No compatibility-check fxs fire (no trustworthy server slice to compare against) |
:where (rf.ssr/hydrate or rf.ssr/read-server-payload), :frame (the resolved frame, or absent/nil on the frameless parse path), :failing-id (:rf/hydrate), :reason |
:rf.warning/cross-frame-dispatch-sync-during-drain |
:warning |
diagnostic | A dispatch-sync! was issued against a target frame while a different frame is mid-drain (same-frame reentry is already rejected as :rf.error/dispatch-sync-in-handler). The cross-frame case is not rejected — frames are independent state machines per 002 §Run-to-completion §Rules rule 1 — but the drains interleave (the target frame drains to settled while the caller's frame is still in flight, then the caller continues), which is rarely the caller's intent. Surfaced for observability tools; the dispatch proceeds. Per 002 §Cross-frame dispatch-sync |
:no-recovery — the dispatch proceeds; the warning is purely diagnostic. Frames are independent state machines so the cross-frame drain is not a contract violation, but the interleaved ordering is rarely intentional |
:caller-frame (the frame read from *current-frame*, or :rf/none when unbound), :target-frame (the dispatch-sync!'s target), :other-frame (an arbitrary mid-drain sibling — typically the caller's frame), :event (the dispatched event vector), :reason |
:rf.warning/unknown-dispatch-opt |
:warning |
diagnostic | A dispatch / dispatch-sync opts map carried one or more keys outside the recognised known-dispatch-opts set — the runtime reads only the known keys in build-envelope, so an unrecognised key (almost always a typo — :fram for :frame) is silently swallowed and changes nothing. Surfaced loudly per the no-silent-swallow posture; one warning per offending dispatch call. Dev-only (interop/debug-enabled?-gated, DCE'd in production). Emitted by re-frame.router.diagnostics/emit-unknown-dispatch-opts-warning! (core/router/diagnostics.cljc). Per 002 §dispatch |
:no-recovery — the dispatch proceeds unchanged (observational, never refusal); fix the misspelt opt or move a custom payload into the event vector |
:event, :event-id, :unknown-keys, :known-keys, :detected-at, :reason |
:rf.warning/non-serialisable-event-payload |
:warning |
diagnostic | A dispatched event's payload carries a host handle — a fn, Promise, AbortController, DOM node, or RegExp. The detection set is the re-frame.reply/host-handle? deny-list minus instants: an instant (#inst, a host date) is EDN and round-trips through pr-str / read-string, so it never warns here. The reply invariant is stricter (per Managed-Effects §The reply map): the reply map and a durable reply target walk with the recordable allow-list, re-frame.recordable/explain-non-recordable under the {:instants? false} leaf policy, which refuses every value that is not plain EDN data — this whole set, any other host object, and an instant too, because a durable reply timestamp is an epoch-millisecond long. The payload is walked via a budget-bounded traversal (re-frame.reply/walk-find-host-handle-bounded; a budget-exhausted walk is a false negative, not a false positive). Per Conventions §Event payloads SHOULD be serialisable data: a SHOULD, not the :rf.cofx structural-EDN MUST — permissive :any payloads and ad-hoc test payloads still rely on carrying a live value occasionally, so this warns rather than rejects. Dev-only (interop/debug-enabled?-gated wholesale, DCE'd in production). Emitted by re-frame.router.diagnostics/emit-non-serialisable-event-payload-warning! (core/router/diagnostics.cljc), called from re-frame.router/build-envelope. Per 002 §The :rf.cofx envelope field |
:no-recovery — the dispatch proceeds unchanged (observational, never refusal); replace the handle with its data projection (an id, a snapshot, a plain value) |
:event, :event-id, :path (the offending value's path inside the event vector), :reason |
:rf.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 (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 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. So a db handler returning nil can never wipe app-db to nil; the guarantee is structural, 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 frame's config declared :url-bound? true while another live frame already owns the URL. Thrown at registration by the routing artefact's :preflight-config member (002 §Lifecycle contributions), before any write; re-registering the owner is not a second binding. Per 012 §Multi-frame routing |
:no-recovery — the registration throws and writes nothing (a refused re-registration leaves the frame as it was); destroy the owner, or re-register it with :url-bound? false, before binding another frame |
:existing-frame, :offending-frame |
:rf.error/resource-missing-scope-policy |
:error |
diagnostic | A reg-resource registration declared no valid :scope policy. :scope is REQUIRED and fail-closed, and is EXACTLY one of two shapes: :rf.scope/global (an explicit, auditable global claim) or {:from-db <resource-scope-id>} (a named resolver registered with reg-resource-scope). Every other value — a bare :rf.scope/* keyword outside the enum, an app-namespaced keyword, a literal tuple / map / string, a fn — is rejected here too. There is no implicit [:rf.scope/global] default — "I forgot this read is user-scoped" is unrepresentable at registration rather than an Xray heuristic. Registration-time / dev+prod (a caller bug); surfaced as a thrown ex-info, not a trace. Per 016 §Scope resolution |
:fix-registration — the call throws; the offending resource is NOT registered. The fix: declare an explicit :scope policy |
:resource-id, :scope (the rejected value), :reason |
:rf.error/resource-bad-spec |
:error |
diagnostic | A reg-resource registration omitted the REQUIRED :request (the Spec 014 managed-HTTP args map; :params-schema is optional) — or the spec was not a map, OR its :request was PRESENT but NOT CALLABLE, OR a freshness-policy value is out of its domain: a PRESENT :gc-after-ms that is neither :never nor a positive number of milliseconds (absent defaults to 300000ms), or a PRESENT :stale-after-ms that is not a non-negative number of milliseconds (absent means never time-stale, 0 means stale the instant it loads). For either key an explicit nil, a negative number, a string, or any keyword other than :gc-after-ms's :never is refused. (The REQUIRED, fail-closed :scope policy is validated FIRST and separately, raising the dedicated :rf.error/resource-missing-scope-policy.) :request is a handler the runtime invokes as (request-fn params ctx), so a non-callable third slot is rejected AT REGISTRATION rather than displaced to the first read — where a number/string surfaces as a raw host cast error with no ex-data, and a merely-invokable keyword/map silently yields nil and lowers an empty request. 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 :request and make it a fn (a Var holding one is also accepted); give :gc-after-ms :never or a positive number and :stale-after-ms a non-negative number, or omit them |
:resource-id, :value (the offending spec when it was not a map, or the offending :request when it was not callable), :gc-after-ms / :stale-after-ms (the offending value, on those two checks), :reason |
:rf.error/resource-non-edn-params |
:error |
diagnostic | A resource params (or scope) map is not a portable CEDN-1 identity at the cache-key boundary: it carries a host / opaque value — a function, promise, DOM node, AbortController, or raw JS object — or a non-portable number — a float, ratio, decimal, NaN / infinity, or an integer outside the safe range. The scoped resource key MUST be serializable EDN (key-order-independent, recursively EDN) with ONE identity on both hosts. Floats are refused deliberately: the hosts disagree about them (2.0 is a double on the JVM and the integer 2 on CLJS, and NaN is not equal to itself), so a float has no single identity, and CEDN-1 admits only safe-range integers (Conventions §Canonical EDN identity). An instant (#inst, a host date) is in-domain and accepted: it canonicalizes to the tagged-instant tuple. Mutation params run through the same canonicalizer, so a host value or a float in them raises this id too, with the mutation id in :resource-id. Thrown ex-info from the canonicalization boundary (ensure / refetch / remove / subscribe, and mutation execute). Per 016 §Resource identity / §Canonicalization rule |
:fix-params — the call throws; the params / scope are not cacheable until the value is portable EDN (encode a float as a fixed-point integer or a string) |
:resource-id, :kind (:params / :scope), :value, :reason |
:rf.error/resource-invalid-params |
:error |
diagnostic | A resource's params failed conformance against its :params-schema, which only a resource that declares one has (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 and a {:large? true} slot as :rf.size/large-elided, in every element when the mark sits inside a collection's element schema; when the schema walker cannot see every mark (the schema is or embeds a [:ref …], carries a local {:registry …}, or embeds a compiled child) the whole :params slot fails closed to :rf/redacted; 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, :explain-humanized (development: the seam's redacted humanized explanation); a validator that throws reports :rf.error/malformed-schema (:where :resource-params) first, then throws this id under :reason :malformed-schema with :params :rf/redacted, :sensitive? true and no :error |
: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-reserved-request-key |
:error |
diagnostic | A resource's :request (the Spec 014 managed-HTTP args map it returns) supplied one of the runtime-owned reply-addressing / request-correlation keys — :request-id, :on-success, :on-failure, or :reply-to. The resource runtime OWNS reply addressing: it supplies the first three from the scoped resource key and current generation so the internal reply verifies frame + work-id + generation before writing, and :reply-to cannot sit beside them because managed HTTP refuses mixed addressing. 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 inside the ensure / refetch / mutation-execute event, so the event commits nothing. Per 016 §Transport |
:fix-registration — the call throws; the request is NOT lowered (no managed-HTTP request reaches the wire) and no entry or instance is left in flight. The fix: remove :request-id / :on-success / :on-failure / :reply-to from the :request return — the runtime addresses the reply, and a caller's continuation is the call-site :reply-to on :rf.resource/ensure or :rf.mutation/execute |
:keys (the rejected reserved key vector), :resource/key, :reason |
:rf.error/resource-ssr-blocking-timeout |
:error |
diagnostic | One or more BLOCKING SSR resources for the current nav-token did not settle (:loaded / :error) within the server-render deadline (Spec 016 §SSR and hydration — blocking timeout policy). Rather than hang the request indefinitely, the timeout settles each unsettled blocking entry as a structured first-load failure ({:status :error :error {:kind :rf.http/timeout :reason :ssr-blocking-timeout …}}) so the renderer sees a structured :error (never a hung :loading) and records the route blocking failure so it can choose error markup / a skeleton / an application fallback. Emitted by re-frame.resources.ssr/settle-blocking-timeout on the SSR frame; a server-side (JVM) trace, not a thrown ex-info — the request continues to render. Per 016 §SSR and hydration. Classified DIAGNOSTIC (not always-on): the failure IS recorded in observable state (the resource entry settles to a structured first-load failure, hydrates to the client, is subscribable and Xray-visible); an operational resource timeout is data-plane telemetry, not a framework-contract breach. Named home: the resources trace family + the observability-sink routing — its operator-observability route is the data-plane sink, not the always-on framework-error axis |
:settled-as-first-load-failure — each timed-out blocking entry settles to a first-load :error; the render proceeds against the settled state and the host hands the route-blocking-failure record to the renderer |
:where (re-frame.resources.ssr/settle-blocking-timeout), :frame, :timed-out (vector of the scoped resource keys that timed out), :limit-ms (the render deadline), :reason |
:rf.error/resource-route-plan |
:error |
diagnostic | The effective resource plan could not be FORMED. The failure set is the whole planning boundary, not just params/scope: a fail-closed :params / :scope / :when throw on a contributing declaration, a missing or cyclic :after edge, an unresolved or cyclic :parent (branch resolution is fail-loud, never silently truncated), or a cycle created by identity collapse — all enumerated at 016 §Effective parent-chain resource plans. The set does not stop at the plan's own shape. Resolving a contributing declaration's resource runs re-frame.resources.registry/require-resource-spec! under :where rf.resource/route-entry, INSIDE the planner's per-declaration try, so that registry's own fail-closed rejections are route-plan failures too: most commonly :rf.error/resource-not-registered — the route declares a :resources entry naming an id no reg-resource has registered — and likewise :rf.error/resource-invalid-params and :rf.error/resource-scope-unresolved-reference; and, from the resource-SCOPE registry those same declarations reach, :rf.error/resource-scope-not-registered — a declaration whose :scope is a {:from-db …} reference naming an id no reg-resource-scope has registered, raised by re-frame.resources.scope-registry/require-scope-resolver! inside the same try. This is the arm a reader tracing an unregistered resource has to be able to find: the plan fails WHOLLY exactly as above, and the rejection's own id is NOT this row's :operation but its NESTED :cause (see the :tags column). Registration ordering is what makes it reachable in practice — a :url-bound? true frame does its initial URL → owner-slice sync AT CONSTRUCTION, so a route matching the current URL is entered, and its :resources planned, INSIDE make-frame, before a later reg-resource line has run (012 §popstate drives the URL-owner frame, both directions). Emitted as an error trace and, on the activation path AND the same-token replan path (012 §Replanning the active route's resources — a committed failed replan), also recorded on the route slice's :error (visible to the :rf/route sub + Xray); NEVER swallowed as a silent cache miss. The plan fails WHOLLY — :resources planning is atomic at this boundary. A failed plan contributes an empty next-ownership set: no partially-planned owner is attached and no partial ensure is dispatched, while every owner held only by the previous plan is still released (012 §Failed activation, 016 §Plan diff and owner handoff). FIRST-error-wins names WHICH failure is reported when several declarations are unplannable — it does not mean surviving siblings ensure anyway; they do not. The route still commits its target and URL (a committed failed activation, addressable and error-renderable) and readiness projects :error, but nothing from the invalid plan runs and the target's :on-match events do not fire. On the warm-mode prefetch path (:plan-cause :prefetch) the same diagnostic fires with no :nav-token, touches no route state, and alters no readiness — a preload owns none. Per 016 §Route integration |
:fix-params for a params / scope failure (make the resolver produce serializable EDN conforming to :params-schema, or gate the declaration with :when); :fix-when for a throwing :when, :fix-scope for an unresolvable :scope, :fix-after for a missing / cyclic :after or a collapse-created cycle, and :fix-parent for an unresolved / cyclic :parent; and :fix-registration for a registry rejection raised while resolving a declaration's resource — the token all five registry rejections above carry — register the resource, and satisfy its params / scope contract, BEFORE the plan runs; on a :url-bound? frame "before" includes before the frame is constructed |
:route-id (the LEAF activation target), :resource-id (only when one declaration owns the failure), :contributor ({:route-id :local-id} — the CONTRIBUTING route + local declaration, so an ancestor's failure is not reported as the leaf's; absent for a failure belonging to no single declaration), :nav-token (activation attempts and replans — the token that is committing or staying; absent for :prefetch), :plan-cause (:prefetch on a warm-mode preload failure; :replan on a same-token replan failure; absent on an activation failure — the door cause is already on the preceding :rf.route/planned), :replan-cause (the :rf.route/replan-resources caller's :cause, verbatim; only with :plan-cause :replan — the row's own :cause slot is the ex-data, never the caller cause), :cause (the underlying canonicalization / validation ex-data — and the DISCRIMINATOR for the registry arm above: that rejection's own :rf.error/id rides NESTED here, e.g. :rf.error/resource-not-registered, never as this row's :operation), :reason |
:rf.error/resource-route-blocking |
:error |
diagnostic | A BLOCKING route resource FAILED its first load. A blocking route resource keeps the route transition :loading (it is the route's SSR wait point); when one settles as a first-load failure the runtime flips the route transition to :error and populates [:rf.runtime/routing :current :error] with this structured error, so a failed required server-state read is observable in route state rather than a permanent skeleton. The error envelope carries the resource's own :error first-load failure. Per 016 §Route integration. Classified DIAGNOSTIC (not always-on): like :rf.error/resource-ssr-blocking-timeout, the failure is recorded in observable route state ([:rf.runtime/routing :current :error], the :rf/route sub + Xray); it is data-plane telemetry, not a framework-contract breach. Named home: the resources trace family + the observability-sink routing |
:no-recovery — the route is in :error; the app reads :rf.route/error and renders an error view, retries via :rf.resource/refetch, or re-navigates |
:resource-id, :nav-token, :error (the resource's first-load failure envelope), :reason |
:rf.error/resource-route-read-ambiguous |
:error |
diagnostic | A [:rf/route-resource <resource-id>] read found the active route's effective plan holding that resource under MORE than one identity (an author's profile beside the viewer's, say). The read never picks one: it throws, naming every identity, and the reactive path surfaces it as :rf.error/sub-exception. Per 016 §Route-bound reads |
:fix-params — read those identities with [:rf/resource {…}] and explicit params |
:resource-id, :identities |
:rf.error/infinite-missing-page-accessor |
:error |
diagnostic | An :infinite resource accumulated a non-vector / enveloped page (e.g. {:items […] :page-info …}) but declares no :page->items accessor — so the merged :rf.resource/items headline read cannot flatten it. The flatten rule is loud, not magic (R3): an already-vector page flattens by identity; a non-vector page REQUIRES a :page->items (a keyword key or a (fn [page] → items)); the framework NEVER guesses :items / :data. Runtime-detected at the merge site — registration-time 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-load-more-owner-ignored |
:warning |
diagnostic | A :rf.resource/load-more was given a non-nil :owner. A load-more is OWNERLESS by contract: the feed's liveness is the ROUTE owner's (the route that ensured page 0), and a load-more is a user-caused page extension during that route's lifetime, NOT a new owner. A supplied :owner is a recognised-but-unhonourable input (typically a consumer copying the ensure / refetch payload shape) that, attached, would add a SECOND durable owner to the feed (:active-owners + :owner-index) and silently extend its liveness / GC lifetime until an explicit :rf.resource/release-owner — the owner LEAK. Continuation is safe (the page-append path is proven correct), so this is a WARNING, not an error: the owner is NORMALIZED to nil (it reaches NEITHER :active-owners, the :owner-index, NOR the work record; :cause is untouched, attribution preserved) and the page still fetches + appends. Emitted ONCE per load-more, on EVERY branch (issue / skip / dedupe / no-feed), by re-frame.resources.events (resources/events.cljc). Per 016 §Causal event — load-more and Conventions §No silent swallow |
:remove-owner — the owner is dropped; the page still appends. The :hint names the fix (remove :owner from the load-more payload) |
:rf.frame/id, :resource, :resource/key, :owner (the ignored owner), :cause, :hint |
:rf.error/mutation-bad-spec |
:error |
diagnostic | A reg-mutation registration omitted a REQUIRED key — :request (the Spec 014 managed-HTTP args map the write lowers into) or :params-schema (validates + canonicalizes the write's params) — or the spec was not a map, OR its :request was PRESENT but NOT CALLABLE. :request is a handler the runtime invokes as (request-fn params ctx), so a non-callable third slot is rejected AT REGISTRATION rather than displaced to the first write — where a number/string surfaces as a raw host cast error with no ex-data, and a merely-invokable keyword/map silently yields nil and lowers an empty request. Registration-time / dev+prod (a caller bug); surfaced as a thrown ex-info, not a trace. Per 016 §Mutations / EP-0003 §Mutations |
:fix-registration — the call throws; the offending mutation is NOT registered. The fix: declare :request + :params-schema, and make :request a fn (a Var holding one is also accepted) |
:mutation-id, :value (the offending spec when it was not a map, or the offending :request when it was not callable), :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). A host / opaque value or a float in the params does NOT raise this id: the params are stored on the durable instance row and closed over by :invalidates / :patches, so they run first through the shared resources canonicalizer, which raises :rf.error/resource-non-edn-params with the mutation id in :resource-id. Thrown ex-info from the execute handler's validation 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 and large slots → :rf.size/large-elided in every collection element, the whole :params slot → :rf/redacted when the schema walker cannot see every mark, 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 |
:mutation-id, :params, :error (the Malli explainer payload), :reason, :explain-humanized (development: the seam's redacted humanized explanation); a validator that throws reports :rf.error/malformed-schema (:where :mutation-params) first, then throws this id under :reason :malformed-schema with :params :rf/redacted, :sensitive? true and no :error |
:rf.error/reply-invalid-target |
:error |
diagnostic | A reply target handed to re-frame.reply/normalize-target (the shared uniform-reply-envelope substrate every managed async family — HTTP, resources, machines, route loaders — lowers its completion onto) is malformed: not an event-vector prefix [:event-id arg ...] (a bare keyword, an empty vector, or a non-keyword-headed vector), a descriptor whose :event is not an event-vector prefix, or a value that is neither a vector nor a map. The substrate FAILS CLOSED rather than let a bogus {} / {:event nil} / {:event :x} travel on to complete and become a garbage dispatch shape. Thrown ex-info from re-frame.reply/normalize-target (inherited by every downstream consumer — complete, durable-target). The reply-specific category rides :rf.error/kind; the canonical :rf.error/id is the closed-table projection. Per Managed-Effects §The reply target |
:no-recovery — the call throws; supply an event-vector prefix [:event-id arg ...] or a well-formed descriptor map |
:rf.error/kind (:rf.reply/invalid-target), :where (:rf/reply-to), :target (the malformed target), :event (the offending :event on the descriptor path), :reason |
:rf.error/reply-non-map-reply |
:error |
diagnostic | A reply handed to re-frame.reply/validate-reply is NOT a map — the uniform reply envelope MUST be a map carrying :status (the closed taxonomy) plus :value / :error slots (Managed-Effects §The reply map), so a scalar reply is rejected rather than validated field-by-field against a non-map. Thrown ex-info from re-frame.reply/validate-reply (the pure validator throws ONLY on a non-map argument; a merely-malformed map is returned as data problems, not thrown). Per Managed-Effects §The reply map |
:no-recovery — the call throws; build the reply as a map ({:status … :value/:error …}), not a scalar |
:rf.error/kind (:rf.reply/non-map-reply), :where (:rf/reply-to), :reply (the non-map value), :reason |
:rf.error/reply-non-data-target |
:error |
diagnostic | re-frame.reply/durable-target — invoked before a reply target could become DURABLE (a stored continuation, an attempt record, a replay log) — refused the target: a field (:event / :suppress) holds a value that is not plain EDN data, as the recordable allow-list (re-frame.recordable/explain-non-recordable, {:instants? false}) judges it — a fn anywhere (inside a list, as a map key), an atom, a Promise, a Date, any host object. A durable reply target MUST be data-only; a smuggled non-serializable value is an app/family bug. Thrown ex-info from re-frame.reply/durable-target. Per Managed-Effects §The reply target |
:no-recovery — the call throws; replace the value at :path with its data projection (an id, a snapshot, a plain value) |
:rf.error/kind (:rf.reply/non-data-target), :where (:rf/reply-to), :path (the path to the offending slot), :bad-type (the offending value's host type, as a string — never the value itself), :reason |
:rf.error/on-destroy-handler-exception |
:error |
always-on | The user-supplied :on-destroy event handler (or any handler in its dispatch drain) threw while destroy-frame! was firing it. Teardown does NOT abort — the throw is caught and every downstream step (machine cascade, sub-cache disposal, post-dead cleanup, :rf.frame/destroyed, registry dissoc) still runs (per 002 §:on-destroy handler throw semantics). This is the discriminable teardown signal: the router ALSO surfaces the same throw as a generic :rf.error/handler-exception (the production source of record for the handler throw itself), but the discriminator — it happened during destroy — rides the always-on axis so an operator on a goog.DEBUG=false host can tell an :on-destroy failure (a teardown / resource-leakage class — a throwing :on-destroy can leak resources mid-teardown) from a generic handler throw. It is ALSO the ONLY always-on coverage for the defence-in-depth re-throw branch (a fault inside dispatch-sync! itself, which never produced a router :rf.error/handler-exception). frame.cljc cannot static-require re-frame.error-emit (load order), so the emission rides the :error-emit/dispatch-on-error late-bind hook (the same hook the no-frame-context emit uses). Per 002 §Frame lifecycle and EP-0008 |
:ignored — teardown continues best-effort (the discriminable signal is the production-survivable breadcrumb; recovery is not what promotion changes — the channel is) |
:frame (the frame being destroyed), :event (the elided :on-destroy event vector), :event-id (the event-vector head), :exception, :exception-message (when the throw came through the router-converted trace), :where (:fire-on-destroy-event!) |
:rf.error/frame-teardown-failed |
:error |
always-on | One bounded always-on report per frame destroy that had at least one failed teardown step — an artefact's lifecycle-contribution member, or one of the runtime's own guarded steps (per §Observability channels and the promotion criterion). destroy-frame! runs a best-effort recipe of each loaded artefact's contribution members (the machines :exit cascade among them) plus a few of the runtime's own guarded steps; rather than fan out one always-on emission per failed step (an SSR per-request-destroy × M req/s flood of the production error shipper), the runtime accumulates the per-step failures through the shared record-teardown-failure! emit boundary and emits one :rf.error/frame-teardown-failed record carrying a :hook-failures vector. This is the production-survivable fact: skipped teardown is a resource-leakage class (stale schemas, flow rows, orphaned timers, cross-request contamination the next operation cannot see locally) that compounds with process lifetime — all three legs of the promotion criterion hold. The destroy IS the fact; the failed steps are detail rows, and one record preserves the which-steps-failed-together correlation external shippers will not reliably re-group. Emit-safety (finally-shaped flush): the :hook-failures entries are accumulated during the teardown walk and flushed through a finally-shaped emission boundary, so that if teardown itself aborts after (say) step 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. What a sink receives: the report is emitted after the destroyed frame has left the registry, so it carries no frame authority — the destroyed frame's own :observability :errors sink receives nothing, and the process-default :errors sink receives it projected under no governing frame. Under an off-box egress profile (the default :rf.egress/off-box-observability among them) :error, :frame and :time survive and the :tags slot carrying :hook-failures and :reason fails closed to :rf/redacted, so an off-box sink never reads :hook-failures; an :rf.egress/local-raw entry keeps them, and the corpus-wide register-error-listener! record carries the raw vector. The dev-only per-failure diagnostic (:rf.warning/teardown-hook-exception, next row) is the diagnostic-channel companion. Per 002 §Frame lifecycle and EP-0008 |
:ignored — teardown continues best-effort; the report is the production-survivable breadcrumb (riding the always-on axis changes the channel, not the recovery) |
:frame (the frame being destroyed), :hook-failures (a vector of {:hook <contribution-id or step-key> :phase <phase> :exception <ex> :where <catch-boundary>} — one entry per failed teardown step; :hook names the lifecycle contribution whose member threw (:flows, :machines, …), with :phase the phase it threw in (:pre-dead, :post-dead, or :after-dissoc for a continuation), OR one of the runtime's own step keys (:frame/pre-dead, :frame/tear-down-without-machines, :trace.tooling/release-frame-ring!), which carries no :phase; :where is the boundary that caught it: :safe-call-contribution! for a member, :safe-teardown-step! for a runtime step), :reason |
:rf.warning/teardown-hook-exception |
:warning |
diagnostic | A best-effort teardown step — a member of an artefact's lifecycle contribution (its :on-frame-destroyed in the :pre-dead or :post-dead phase, or the continuation a post-dead member handed back for after the dissoc, 002 §Lifecycle contributions) OR one of the runtime's own guarded steps (:frame/pre-dead, :frame/tear-down-without-machines, :trace.tooling/release-frame-ring!, run under safe-teardown-step!) — threw while destroy-frame! was tearing the frame down. Teardown continues best-effort (the throw is swallowed so one failing artefact can't block the rest of the recipe); this per-failure warning is the dev diagnostic breadcrumb at its causal position so a leaked cleanup (flow rows, side-channel atoms, trace rings) is traceable in long-lived SSR / test / tooling processes. It rides the diagnostic channel (dev-only, DCE'd in production); the production-survivable always-on fact is the single :rf.error/frame-teardown-failed report (previous row), which carries the same per-step detail under :hook-failures. (Per-step visibility is on the diagnostic axis; only the always-on emission collapses to one report.) Emitted through the shared record-teardown-failure! boundary in frame.cljc — the single trace/emit-error! call every best-effort teardown catch site funnels through: safe-call-contribution! for a member (recording :where :safe-call-contribution!, the contribution's :id as :hook, and its :phase), safe-teardown-step! for a runtime step (recording :where :safe-teardown-step!), and the after-dissoc continuations, recorded like a member with :phase :after-dissoc under structural delivery, since the frame has left the registry and no ring may retain the warning. dev-only (interop/debug-enabled?-gated) |
:ignored — teardown continues; the failed step's cleanup may be incomplete |
:hook (the contribution id, or the runtime's step key such as :frame/pre-dead), :phase (the member's phase; absent for a runtime step), :frame (the frame being destroyed), :exception, :where (the catch boundary that recorded it: :safe-call-contribution! for a member, :safe-teardown-step! for a runtime step) |
:rf.warning/sub-input-dispose-exception |
:warning |
diagnostic | A best-effort sub-cache release threw. Declared-input release: a layer-2+ subscription's recursive disposal released its declared-input refs through the identity-guarded internal release (unsubscribe-if-reaction) once per input (symmetric with the per-input subscribe bumps taken at build time), and ONE input's release threw — most plausibly from a buggy custom-substrate adapter -dispose. The per-input release walk is best-effort: the throw is swallowed so the REMAINING inputs still release (a skipped sibling release would leak its ref-count, which compounds with process lifetime), and this per-input warning is the dev breadcrumb at its causal position so the otherwise-invisible leak is traceable in long-lived SSR / test / tooling processes. Mirrors frame.cljc's safe-call-contribution! / 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. Eviction: disposing an evicted slot's reaction threw — what escapes dispose! is a consumer's on-dispose callback — while the sub cache evicted it; the eviction goes on, and the warning names the slot and the eviction path. Emitted from subs.cljc's release-input-ref! (the cached reaction's :on-dispose callback, the symmetric :not-cached-release escaped-caching path, and the :sub-cycle-unwind release of the inputs a declared-input cycle abandoned) and from subs/cache.cljc's warn-dispose-failure! (each eviction path); dev-only (interop/debug-enabled?-gated via trace/emit-error!, DCE'd in production) |
:ignored — the release is best-effort; a failing input's ref-count may leak while the remaining inputs still release, and an eviction completes without the consumer callback that threw |
:frame (the disposing frame), :rf.sub/query-v (the input query vector whose release threw, or the evicted slot's own), :exception, :where (:on-dispose / :not-cached-release / :sub-cycle-unwind for an input release; for an eviction its :rf.sub/reason, :no-more-derefers / :hot-reload / :cache-clear / :frame-destroy) |
:rf.warning/restore-quiesce-hook-exception |
:warning |
diagnostic | An optional late-bound restore-time host-transient quiesce hook (:machines/on-frame-restored!, :http/abort-in-flight-for-frame!) threw while perform-restore! was quiescing the orphaned async host work of an epoch restore. Epoch restore installs the captured durable frame-state WHOLESALE, then cancels/clears the async host work the unwound epochs spawned (machine :after host-clock timers, non-resource managed-HTTP in-flight handles) so a late pre-restore completion cannot deliver to its original :rf/reply-to target (per Managed-Effects §SSR, preload, hydration, and restore). The quiesce runs only AFTER a successful install and is best-effort — a throwing subsystem hook is swallowed so it cannot strand the others, mirroring destroy-frame!'s safe-call-contribution! posture; this per-hook warning is the dev breadcrumb that a restore-time cleanup leaked. Emitted from epoch/tool_pair.cljc's quiesce-orphaned-async-host-work!; dev-only (interop/debug-enabled?-gated via trace/emit-error!) |
:ignored — the restore stands; the failed hook's host-transient cleanup may be incomplete |
:hook (the late-bind hook key that threw), :frame (the restored frame), :exception |
:rf.warning/unknown-configure-key |
:warning |
diagnostic | (rf/configure! …) was handed a top-level key the runtime does not read, and that key is BARE (:epoch-histroy) or FRAMEWORK-namespaced (:rf.foo/bar). configure!'s vocabulary is closed and its keys are bare, so an unrecognised bare key reads as a typo of a real key and applies nothing — the shape Conventions §No silent swallow says MUST signal. A USER-namespaced key (:myapp/thing) is an extension key and passes in silence, so a wrapper may thread a composed config value straight through. The typo warning names every offending key and the full known set (per API §Configure keys). Emitted by re-frame.core/configure!; dev-only (interop/debug-enabled?-gated, DCE'd in production) |
:ignored — the call returns nil and the named key applies nothing (observational, never refusal); fix the misspelt key, or namespace a genuine extension key under your own namespace |
:unknown-keys, :known-keys, :detected-at, :reason |
:rf.warning/trace-buffer-unrecognised-opts |
:warning |
diagnostic | (rf/configure! {:trace-buffer ...}) was handed an opts map without a usable :events-retained — a {:depth N} shape, a negative value, or a non-numeric value. :events-retained N (non-negative integer) is the SOLE recognised opt (per §Retention contract — the single knob :rf.trace/events-retained). Emitted from trace/tooling.cljc's configure-trace-buffer!; dev-only (interop/debug-enabled?-gated, DCE'd in production). The loud-not-silent guard against believing retention was tuned when the call did nothing |
:ignored — the call is a no-op; the process-default events-retained is unchanged. The :reason names the fix ({:events-retained N}) |
:opts (the rejected opts map), :reason |
:rf.error/unknown-registry-kind |
:error |
diagnostic | re-frame.registrar/register! — the single chokepoint every reg-* macro funnels through — was called with a kind outside the closed v1 registrar-kind set (re-frame.registrar/kinds). The check runs FIRST, before the slot is written, any hook fires, or :rf.registry/handler-registered / :rf.registry/handler-replaced emits. The registrar kind set is closed-and-Spec-owned (adding a kind is a Spec change), so an unrecognised kind reaching register! signals a mis-wired internal caller (a framework reg-* surface, or a framework-internal register! call) rather than ordinary app code, which never calls register! directly. Surfaced as a thrown ex-info from re-frame.registrar/register!, not a trace. Per 001 §Registry model |
:fix-registration — the call throws; the registry is left untouched (the guard precedes the swap!). Fix the calling reg-* surface to pass one of the registered registry kinds |
:kind (the offending kind value), :id (the offending id), :reason |
:rf.error/trace-buffer-flat-only-filter |
:error |
diagnostic | A (rf/trace-buffer frame-id opts) read without :flat true carried an event-level filter key with a non-nil value — :operation, :op-type, :since, :severity, :handler-id, :source or :sensitive?. Each matches a field of a raw trace event that an event bundle does not carry (§Filter vocabulary), so a bundle read cannot apply it; ignored, the key would keep every bundle, and (rf/trace-buffer f {:op-type :error}), the natural call for "the runs that errored", would answer with all of them. A nil value is no constraint and passes. Thrown ex-info from the read, not a trace; dev-only, because the ring and its reader exist only under interop/debug-enabled? and a production read returns []. Emitted by trace/tooling.cljc's trace-buffer. Per §trace-buffer API |
:pass-flat-true — the read throws; add :flat true to filter raw events, or keep bundles with a :pred over their :trace-events |
:flat-only-keys (the offending keys, in filter-table order), :where ('rf/trace-buffer), :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 |
: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-hook-exception |
:warning |
diagnostic | A hook the registrar runs on a registration change threw: a registration hook, run on every register! (first-time or re-registration), or a replacement hook, run on a re-registration and on a hot-reload sweep's deletion (the sub cache's invalidation is the in-tree one). Hooks run isolated, so the throw is swallowed — the registration completes and the remaining hooks still run — and this warning is the breadcrumb that a hook's own bug left its consumer's state unrefreshed, such as a sub cache still holding a replaced body. The registrar reaches the trace surface through the :trace/emit-error! late-bind hook, because re-frame.trace requires it, so the envelope's own :op-type is :error (see the note above on rows routed through trace/emit-error!). Emitted by registrar.cljc's report-hook-exception!; dev-only (interop/debug-enabled?-gated, DCE'd in production). Per 001 §Hot-reload trace surface |
:ignored — the registration proceeds; the throwing hook's own work for this change is lost |
:hook (:registration / :replacement), :kind, :id (the registration whose change ran the hook), :exception |
:rf.warning/registration-collision |
:warning |
diagnostic | A reg-* re-registration of a (kind, id) carried captured source coordinates — its (ns, file, line, column) envelope — that differ from those of the most recent registration of that (kind, id). The warning compares coordinates, not fn identity, and decides nothing: a re-registration from the same namespace replaces that namespace's source slot, while one from a different namespace is retained beside it, so an image selecting both fails assembly with :rf.error/image-duplicate-id, and a frame made without :images raises :rf.error/ambiguous-registration when it uses the id. It fires for either, so dev tools can flag an accidental id clash or a moved registration. A re-eval of the same source location is silent, and a registration with no captured coordinates (programmatic / REPL) does not participate. Emitted at most once per (kind, id) within a runtime process; production builds elide it. Per 001 §Re-registration of a different function — collision warning |
:ignored — the registration proceeds exactly as it would without the warning; the warning is purely diagnostic |
:kind, :id, :previous-coords, :source-coords |
:rf.error/at-boundary-missing-schema |
:error |
diagnostic | A reg-event-* call declared :boundary? true (per 010 §Production builds) but the registration's metadata-map carried no :schema KEY. The flag 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) drop :boundary? from the metadata-map |
:reg-fn (the calling reg-fn's name as a string), :id (the offending event-id), :reason |
:rf.error/reg-event-bad-interceptors |
:error |
diagnostic | A reg-event-* metadata-map carried a malformed :interceptors value — a non-vector, or a vector carrying a non-interceptor entry (a value that is not a map carrying :id / :before / :after). The malformed chain cannot be honoured and is not silently dropped or coerced (consistent with the existing reg-event arg policing — :rf.error/reg-event-bare-interceptor / :rf.error/reg-event-bad-middle-slot). Surfaced as a thrown ex-info from reg-event-*, not a trace. Per 001 §Allowed forms of the middle slot |
:fix-registration — the call throws; the offending handler is NOT registered. The fix: make :interceptors a vector of interceptor references (a bare keyword id or an [id arg] 2-vector) — register the interceptor with rf/reg-interceptor (the public authoring form) and reference it by id (chains are reference-only, an inline value raises :rf.error/inline-interceptor-removed) |
:reg-fn (the calling reg-fn's name as a string), :id (the offending event-id), :got (the malformed value), :expected, :reason |
: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 a positional interceptor vector, (reg-event-* id [i1 i2] handler), which is not a middle-slot form. 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 |
:rf.error/reg-event-bad-arity |
:error |
diagnostic | A reg-event-* call used an unsupported tail shape. This includes a metadata map followed by a positional interceptor vector, (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 |
:rf.error/reg-event-bare-interceptor |
:error |
diagnostic | A reg-event-* call supplied a bare interceptor map where metadata was expected, e.g. (reg-event-db id mw/some-interceptor handler). Because interceptors are maps, a bare interceptor map would read as metadata and silently drop the chain; the registrar rejects it loudly. Surfaced as a thrown ex-info from reg-event-*, not a trace. Per 001 §Allowed forms of the middle slot |
:fix-registration — the call throws; the offending handler is NOT registered. Write (reg-event-db id {:interceptors [mw/some-interceptor]} handler) |
:reg-fn, :id, :got, :expected, :reason |
: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. The reserved set is #{:rf/set-db :rf/install-frame-state :rf/settle-flows}: the framework registers :rf/set-db and :rf/install-frame-state itself (into both the regular registrar and the image standard registry), and :rf/settle-flows is retired, reserved and never recycled. 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 |
:rf.error/set-db-bad-value |
:error |
diagnostic | The framework-standard [:rf/set-db x] event was dispatched with a missing, nil, non-map, or EXTRA-trailing-args argument. :rf/set-db REPLACES the whole app-db partition with the supplied map ({:db new-db}), so it validates exactly one map argument — [:rf/set-db {…}] (no second-argument meaning); a trailing arg ([:rf/set-db {} :junk]) is a mis-call and fails loud rather than being silently ignored. Set app-db empty with [:rf/set-db {}]. The bad-argument diagnostic is raised through error/throw-error! from inside the :rf/set-db handler body, so it THROWS. At RUNTIME the interceptor chain catches that throw and surfaces it in-band as :rf.error/handler-exception (dispatch-sync does not re-raise). When [:rf/set-db x] is an :initial-events setup step it is a setup-step failure under strict construction (per EP-0027 §Failure): the runner detects the captured in-band handler-exception, tears the partial frame down, and re-raises as :rf.error/initial-events-step-failed. Raised by the :rf/set-db handler in events.cljc. Dispatch-boundary |
:no-recovery — the dispatch throws; app-db is NOT changed. The fix: pass a map (use [:rf/set-db {}] to empty app-db) |
:event (the offending argument), :rf.event/v (the event vector), :reason |
:rf.error/reg-event-db-removed |
:error |
always-on | reg-event-db was called. It is not a registration function (no alias): the var exists only to fail loud, with 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) |
:rf.error/reg-event-fx-removed |
:error |
always-on | reg-event-fx was called. It is not a registration function (no alias): the var exists only to fail loud. 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) |
:rf.error/reg-event-ctx-removed |
:error |
always-on | public reg-event-ctx was called. It is not a public registration function: the context -> context mechanism is a framework-internal primitive, off the public surface. 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) |
:rf.error/invalid-interceptor |
:error |
diagnostic | A reg-interceptor call received a malformed descriptor — neither {:before f} / {:after f} / {:before f :after g} (static) nor {:factory f} (parameterized), nor a migration-boundary interceptor value carrying :before / :after (per 002 §Error model). Also fires when a migration-boundary interceptor VALUE carries an :id disagreeing with the positional registration id. Registration-time / dev-only validation (it fires on the reg-interceptor path, which production never re-runs), so it stays dev-trace-only. Surfaced as a thrown ex-info from reg-interceptor*, not a trace. Emitted by interceptor_registry.cljc. Per 001 §Interceptors |
:fix-registration — the call throws; the interceptor is NOT registered. The fix: use one of the four descriptor shapes (or match the value's :id) |
:id (the offending interceptor id), :got (the malformed descriptor), :expected, :reason |
:rf.error/unregistered-interceptor |
:error |
diagnostic | A chain referenced an interceptor id with no registration. Event/frame :interceptors chain: the id has no reg-interceptor registration (per 002 §Validation and resolution timing). A live reg-event / make-frame fails at registration so typos die before dispatch; a dispatch-time occurrence is the defensive guard against corrupt state / a hot-reload race. Surfaced as a thrown ex-info, not a trace; dev-trace-only (the registration / chain-assembly path). Emitted by interceptor_registry.cljc's resolve-ref. HTTP chain: an id in the frame's :http-interceptors has no reg-http-interceptor definition, so the managed request fails when it is issued, before any :before runs (per 014 §Activation). Thrown as an ex-info by http/middleware.cljc's capture-chain. Per 002 §Validation and resolution timing |
:fix-registration — register the interceptor (reg-interceptor, or reg-http-interceptor for an :http-interceptors id) before referencing it by id |
:ref (the offending reference), :id (the unregistered id), :reason; the HTTP chain adds :frame and :http-interceptors (the frame's chain) |
:rf.error/invalid-interceptor-ref |
:error |
diagnostic | An event/frame :interceptors chain entry is neither a keyword id nor an [id arg] 2-vector reference (per 002 §Interceptor references). An inline interceptor value gets the dedicated :rf.error/inline-interceptor-removed instead; this category is 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 |
: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 value-Var, a locally-bound interceptor symbol) — in a chain position. Interceptor chains are reference-only: register the interceptor with reg-interceptor and reference it by id. Fails at reg-event registration (validate-meta-interceptors!) for an early death, and re-guarded at dispatch-time chain assembly (resolve-chain) for a frame-level chain / corrupt state. The framework's own appended handler-wrapper (:rf/default? true) is exempt — it is framework machinery, not an authored chain entry. Surfaced as a thrown ex-info, not a trace; dev-trace-only (the registration / chain-assembly path). Emitted by events.cljc (registration) and interceptor_registry.cljc's resolve-chain (dispatch). Per 002 §Event and frame chain grammar |
:fix-registration — register the interceptor with reg-interceptor and reference it by a bare keyword :my/ic or an [id arg] 2-vector |
:entry / :offending (the inline value), :id (event id, registration path), :expected, :reason |
:rf.error/interceptor-factory-arity |
:error |
diagnostic | A parameterized [id arg] reference targets an id that is NOT a :factory interceptor, OR a bare-keyword ref names a :factory (which requires an [id arg] form), OR a :factory cannot build for the supplied arg (it threw, or returned a non-descriptor / non-interceptor) (per 002 §Error model). Surfaced as a thrown ex-info from chain assembly, not a trace; dev-trace-only. Emitted by interceptor_registry.cljc's resolve-ref / resolve-factory. Per 002 §Interceptor references |
:fix-registration — reference a :factory interceptor as [id arg] (and a static one as a bare keyword); ensure the factory builds for the arg |
:ref (the offending reference), :id, :reason |
:rf.error/interceptor-override-invalid |
:error |
diagnostic | An :interceptor-overrides map carried a malformed key (not an interceptor reference — neither a keyword id nor an [id arg] 2-vector) or a malformed replacement (neither another interceptor reference nor nil-to-remove). An inline interceptor value as a replacement is rejected here (per 002 §:interceptor-overrides). Surfaced as a thrown ex-info from dispatch-chain assembly when the merged frame/per-call override map is applied, not a trace; dev-trace-only (the chain-assembly path). Emitted by router.cljc's apply-icpt-overrides. Per 002 §:interceptor-overrides |
:fix-overrides — make each override key an interceptor reference and each replacement a reference or nil |
:key (the offending override key), :replacement (its value), :reason |
:rf.error/path-interceptor-bad-path |
:error |
diagnostic | The standard [:rf.interceptor/path <path-vector>] reference carried a non-vector or otherwise malformed path argument (per 002 §Standard :rf.interceptor/path). Surfaced as a thrown ex-info from the standard path :factory (which runs at chain assembly), 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 |
:rf.error/unknown-listener-stream |
:error |
diagnostic | A register-listener! / unregister-listener! call named a stream outside the closed vocabulary :trace / :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 two raw dev observation streams (production observation is register-observability-sink! plus a frame :observability policy or the configure! process default) |
:where (the user-facing verb symbol — 'rf/register-listener! / 'rf/unregister-listener!), :stream (the offending stream), :valid (the closed vocabulary), :reason |
: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) |
:rf.error/image-zero-match |
:error |
diagnostic | An image assembly :select-ns :include 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) |
:rf.error/ambiguous-registration |
:error |
always-on | A frame made without :images — which seals no image generation and resolves the registration store live — used a (kind, id) that more than one namespace registers, so it runs neither: load order never picks a survivor, and every other id keeps working. The usual cause is a handler moved between namespaces where the dev hot-reload sweep (001 §Re-registration) does not remove the old slot — a REPL re-evaluation, or an old namespace left registering nothing; a late-loaded module duplicating an id does the same in production. Raised where the id is used. A dispatched event, and an :fx entry, behave as an unknown one would (the event does not run; the fx is skipped and the walk continues) and fan ONE always-on record per use through emit-error-both! (re-frame.router.diagnostics/handle-ambiguous-handler!, re-frame.fx), because all three legs of the promotion criterion hold: it is production-reachable, it is locally invisible (the event or effect simply does nothing), and nothing else names the cause. A navigation whose URL's best match is such a route fails closed to :rf.route/not-found and fans one record the same way, against the navigation event in flight (re-frame.routing.registry). Every other use — a subscription, a coeffect, an interceptor, rf/handler-meta {:frame f …} — throws the canonical ex-info at the call that used the id. A read that only needs the id's registration treats it as having none: the dispatched-event trace gate and an error record's :source-coord skip it, and its egress classification fails closed (the whole value :sensitive). Frames with explicit :images never raise it: they fail assembly with :rf.error/image-duplicate-id. Emitted by re-frame.live-frame/live-view. Per 002 §Image resolution and composition |
:clear-the-id-and-re-evaluate-its-owner — the use does not run. The :reason names the recovery: (rf/clear kind id), then re-evaluate the namespace that should own the id |
:registration (the [kind id]), :namespaces (every namespace registering it, sorted; nil stands for a registration with no source namespace), :reason; an fx use adds :failing-id (the fx id), and a route match adds :failing-id (the route id). The thrown ex-info carries :kind, :id, :registration, :namespaces |
:rf.error/image-duplicate-id |
:error |
diagnostic | Image assembly selected ≥2 DISTINCT registrations (different impls / source coordinates) for the same (kind, id) within one image’s selection — including the default projection of an explicit descriptor pool (make-frame with a pool and no :images) (EP-0026 §Layered Resolution / §Default Image). A frame made without :images over the live store seals nothing, so it never raises this: using the id raises :rf.error/ambiguous-registration instead. The central "order never silently decides a winner" guarantee: a genuine collision is an ERROR, not a last-write. A deliberate override is expressed ACROSS images — define the winner in a LATER image and compose; the later image wins and the shadow is reported on :rf.gen/shadows. Assembly-time validation (rf/make-frame), thrown ex-info, not a trace; the assembly path is not re-run per event in production, so it stays diagnostic-channel. Emitted by image_assembly.cljc’s resolve-collision. Per 002 §Image resolution and composition |
:narrow-the-selection-or-rename-the-id — the call throws; narrow the :select-ns selection so only one is selected, rename the duplicate id, or move the intended override into a later image |
:image, :kind, :id, :colliding-coordinates (the source coordinates of every colliding descriptor) |
:rf.error/image-within-image-collision |
:error |
diagnostic | Within ONE image, an INLINE :registrations entry collided with a :select-ns-selected registration for the same (kind, id), or two inline entries defined one (kind, id). An image must resolve cleanly to ONE descriptor per (kind, id) — there is no within-image winner rule; an image may carry both :select-ns and :registrations, but they MUST be disjoint. A deliberate override is expressed as a LATER image (image-order layering, EP-0026 §Layered Resolution). Assembly-time validation (rf/make-frame), thrown ex-info, not a trace; diagnostic-channel. Emitted by image_assembly.cljc’s resolve-collision. Per 002 §Image resolution and composition |
:move-the-override-to-a-later-image-or-deduplicate — the call throws; define the override in a later image of the :images vector, or remove the duplicate inline entry |
:image, :kind, :id, :colliding-coordinates |
: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) |
: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 interceptor id absent from the generation, whatever its namespace (a published :rf.interceptor/* standard is in the generation; a misspelt one is not) — "event references missing interceptor"; (2) a :resource descriptor's {:from-db <scope-resolver-id>} derived-scope reference (Spec 016 §Resolver references) naming a :resource-scope resolver that is NOT selected into the generation — "resource references missing scope resolver" (a concrete :scope like :rf.scope/global or a [:rf.scope/session …] tuple names no resolver to validate). Assembly-time validation (rf/make-frame), thrown ex-info, not a trace; diagnostic-channel. Emitted by image_assembly.cljc's check-references!. Per EP-0023 §Image Validation |
:select-the-missing-registration-or-fix-the-reference — the call throws; select the namespace that registers the referenced interceptor / reg-resource-scope's the scope resolver, or correct the reference |
:image, :kind, :id, :rf.provenance/ns (the referencing descriptor's provenance namespace), :coordinate (its source coordinate), :missing-reference (the [:interceptor ref-id] / [:resource-scope scope-id] of the unresolved reference) |
:rf.error/image-standard-replacement-forbidden |
:error |
diagnostic | An application registration collided with a framework STANDARD registration. Standards are PROTECTED — they are not part of app image layer order and each encodes an execution invariant, so shadowing one is a correctness violation, not an app policy choice; there is NO public opt-in (EP-0026 §Framework Standard Registrations). Refused when it is made: a reg-* with namespace provenance for a standard's (kind, id) throws before anything is written (registrar.cljc’s refuse-standard-replacement!), and an image whose selection or inline entries still collide with a standard fails assembly (rf/make-frame, image_assembly.cljc’s check-standard-collision!). Thrown ex-info, not a trace; diagnostic-channel |
:rename-the-app-id-or-deselect-it — the call throws; rename the app registration’s id so it does not shadow the standard, or do not select the namespace that defines it |
:kind, :id, :standard-coordinate, :app-coordinate (the colliding app descriptor’s source coordinate) |
:rf.error/image-duplicate-image-id |
:error |
diagnostic | In a MULTI-image :images composition every image MUST be nameable by a DISTINCT id (EP-0026 §Image Keys) — the shadow report identifies each image by id. TWO conditions fail here, both under this id: two images sharing an :id (they could not name exactly one image each), OR an anonymous image (no id) — un-nameable, it cannot participate in composition (an anonymous loser/winner would leave a degenerate {:image nil :shadowed-by nil} shadow-report entry). Anonymous images are exempt ONLY in a single-image composition (a lone image has no cross-image shadow to name — the local-test / default-image case). Assembly-time validation (rf/make-frame), thrown ex-info, not a trace; diagnostic-channel. Emitted by image_assembly.cljc’s check-unique-image-ids! |
:give-each-image-a-distinct-id (duplicate-id branch) / :give-each-image-in-the-composition-a-distinct-id (anonymous branch) — the call throws; give each image in the composition a distinct :id (a lone single-image composition may stay anonymous) |
:duplicate-image-ids, :image-ids, :anonymous-image-count, :image-count |
:rf.error/make-frame-bad-opts |
:error |
diagnostic | An rf/make-frame call's opts ARGUMENT was not a MAP — nil, a keyword, a vector, a string, or any other non-map (EP-0024 §One constructor — opts is the map carrying :images / :id / :capabilities / :adapter + record-config keys including :initial-events; API §make-frame). The public constructor has a MAP-shaped contract, so a non-map opts is rejected at the boundary BEFORE any destructuring / (apply dissoc opts …). nil is REJECTED too: the all-defaults frame is (make-frame {}) (there is no zero-arity), so nil names nothing the empty map does not — it is only ever a typo or plumbing failure. Frame-creation-time validation (rf/make-frame), thrown ex-info, not a trace; the frame-creation path is runtime/SSR logic not re-run per event, so it stays diagnostic-channel. Emitted by live_frame.cljc's validate-opts!. Per API §make-frame and 002 §Per-instance frames |
:pass-an-opts-map — the call throws; pass an opts map, e.g. (rf/make-frame {:images [my-image]}) (an all-defaults frame is (rf/make-frame {})) |
:received (a redaction-safe shape summary of the offending non-map value) |
:rf.error/configure-bad-arg |
:error |
diagnostic | An rf/configure! call's argument was not a MAP — the keyed form (rf/configure! :trace-buffer {…}), a vector, nil, or any other non-map (API §Configure keys). configure! takes ONE nested config map, so a non-map is rejected at the boundary before any subsystem is configured. The check is ALWAYS-ON — a plain runtime guard, deliberately NOT an assert — because assert is elided wherever the host compiler drops assertions (CLJS :elide-asserts true, JVM *assert* false), and under elision an assert-based check would let every non-map call return nil, apply nothing and emit no diagnostic, so the programmer would believe trace / epoch / elision configuration was installed when it had been silently dropped. Process-configuration-time validation (rf/configure!), thrown ex-info, not a trace; a boot-path caller bug, so it stays diagnostic-channel. Emitted by core.cljc's configure!. Per API §configure! |
:pass-a-config-map — the call throws; pass one nested config map, e.g. (rf/configure! {:epoch-history {:depth 100}}) — the subsystem key goes INSIDE the map |
:received (a redaction-safe shape summary of the offending non-map value) |
:rf.error/make-frame-bad-images |
:error |
diagnostic | An rf/make-frame call's :images opt was not a VECTOR (EP-0023 §Image Composition / §Public API — ":images, always a vector"). :images is the only spelling and it is always a vector; even a single image is a one-element vector. A bare image map, a seq, or any other non-vector is rejected rather than coerced, so the one-spelling contract does not erode. Frame-creation-time validation (rf/make-frame), thrown ex-info, not a trace; the frame-creation path is runtime/SSR logic not re-run per event, so it stays diagnostic-channel. Emitted by live_frame.cljc's validate-images!. Per EP-0023 §Image Composition |
:wrap-the-images-in-a-vector — the call throws; supply :images as a vector, e.g. :images [my-image] |
:images (the offending non-vector value) |
: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), :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), :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), :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), :reason |
:rf.error/initial-events-step-failed |
:error |
diagnostic | An :initial-events setup step FAILED during frame construction (EP-0027 §Failure). Construction-time :initial-events is STRICT: ANY setup-step failure tears the partially-created frame DOWN (destroy-frame!) so no half-created frame is left live, then raises this. The runtime's traced-and-recover leniency does NOT apply during construction. A failure is EITHER an escaping throw out of dispatch-sync (a coeffect-resolution throw — unregistered / missing-required declared cofx — escaping context assembly) OR an in-band failure the chain CAPTURES (so dispatch-sync returns nil normally): a handler-body throw surfaced as :rf.error/handler-exception (the [:rf/set-db x] bad-arg case — :rf/set-db raises :rf.error/set-db-bad-value from inside the handler via error/throw-error!), a user-interceptor throw (:rf.error/interceptor-exception), a coeffect-supplier throw (:rf.error/coeffect-exception), or a flow throw (:rf.error/flow-eval-exception). The runner detects an in-band failure by installing a transient always-on error listener around each step dispatch (matching those PRE-COMMIT categories against the frame). A POST-COMMIT :rf.error/fx-handler-exception (an :fx handler threw AFTER the db committed) is NOT a setup-step failure — the event committed and the fx throw is best-effort (the FX atomicity asymmetry); the SSR server error projector catches such render-walk/cascade fx throws, and a THROWN setup step is instead the OUTER :on-error transport path (011 §:on-error vs :error-view). A step whose event has no registered handler is not a setup-step failure either: it reports :rf.error/no-such-handler, as any dispatch does, and construction continues. 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), :reason |
:rf.error/initial-events-runner-unavailable |
:error |
diagnostic | A frame was constructed with non-empty :initial-events, but the setup runner is unavailable — re-frame.router is NOT loaded, so the :router/dispatch-sync! late-bind hook the runner reaches dispatch-sync through is unregistered (EP-0027 §Construction). :initial-events is dispatched through the router's synchronous path; require re-frame.router (or re-frame.core, which does) before constructing a frame with :initial-events. It fails loud, tearing down the partial frame first so no half-created, never-setup frame is left live. The common path (re-frame.core requires re-frame.router, publishing the hook before any runtime frame construction) never triggers it. Frame-construction-time, thrown ex-info, not a trace; diagnostic-channel. Emitted by frame.cljc's run-setup-events!. Per EP-0027 §Construction |
:require-re-frame-router — the call throws after tearing down the partial frame; require re-frame.router (or re-frame.core) before constructing a frame with :initial-events |
:frame (the id under construction), :step-count (the dropped step count), :reason |
:rf.error/frame-construction-in-handler |
:error |
diagnostic | A frame was constructed (make-frame) INSIDE an event handler — a run was in flight (trace/*handler-scope* bound) at construction (EP-0027 §Construction). Frames are created by the VIEW (frame-root, the ENSURE boundary — frame-provider is SCOPE-only and creates nothing) or at TOP LEVEL (make-frame in tests, boot, SSR per request); a handler changes app-db, and the view materializes frames from it. Construction mid-run is a fail-loud error, never an async-queued deferral. The just-created container is torn back down before throwing, so no half-registered frame is left. Construction-time, thrown ex-info, not a trace; diagnostic-channel. Emitted by frame.cljc's construction engine. Per 002 §Frame creation and EP-0027 §Construction |
:construct-frames-in-view-or-top-level — the call throws; move the frame creation to a frame-root in the view tree, or to an explicit make-frame at top-level boot |
:frame (the id under construction), :reason |
:rf.error/frame-construction-in-progress |
:error |
diagnostic | A frame construction could not acquire its per-frame-id transaction because the id is already reserved by in-flight construction/destruction, or still names a lifecycle-dead/provisional raw row awaiting exact settlement. Admission is fail-fast on both hosts: no wait or queue, no adapter allocation, no adoption/refresh of the provisional or dead row. The reservation spans adapter callbacks, provisional seating, trace policy, synchronous setup, lifecycle trace/hook publication, finalisation, and exact rollback; same-id re-entry from any of those callbacks therefore gets this typed loss. Disjoint ids remain independent. Construction-time, thrown ex-info, not a trace; diagnostic-channel. Emitted by frame.cljc's construction transaction engine. Per 002 §make-frame is atomic |
:retry-after-frame-transaction — the call throws; retry only after the owning transaction settles |
:frame (the contended id), :reason (:reservation-held, :lifecycle-closing, :lifecycle-dead, or :orphaned-provisional), :owner-kind |
:rf.error/frame-id-taken |
:error |
diagnostic | A frame construction requested internal CREATE-EXCLUSIVE mode (the reserved :rf.frame/must-create? key) but the id already names a live final frame. In-flight same-id construction/destruction is the distinct fail-fast :rf.error/frame-construction-in-progress, never this category. Exclusive mode is the primitive a fresh-isolated-frame test contract rests on: a harness that must install a FRESH ISOLATED frame passes must-create, so an ambient final frame under the same id is a COLLISION — never a silent adoption or surgical refresh (either would hand back ambient state and quietly pass an assertion that never met a clean frame). No in-repo harness requests exclusive mode; only the framework's own tests exercise it. The mode is normative surface rather than residue, because the reserved key's CONSUMER is live production code — frame.cljc reads :rf.frame/must-create? and raises this category — so the row states the primitive a harness acquires fresh frames with, never a report of a producer in service. Ordinary (non-exclusive) sequential construction is UNAFFECTED: it falls through to idempotent re-registration. Construction-time, thrown ex-info, not a trace; diagnostic-channel (the failure is fully observable at the throwing call). Emitted by frame.cljc's transaction engine. Per 002 §make-frame is atomic |
:use-a-distinct-frame-id — the exclusive construction throws; use a distinct frame id, or destroy the pre-existing frame before constructing (the caller seeds the fresh frame itself) |
:frame (the colliding id), :reason |
:rf.error/reprojection-failed |
:error |
always-on | A frame made with explicit :images failed its REFRESH: after a reg-* marked the live-frame projection dirty, re-resolving the frame's image composition against the current registrations threw, so the frame stays on its PRIOR generation for EVERY id — the old handler keeps running, and new or re-edited handlers never take effect. The ordinary cause is a cross-namespace duplicate (:rf.error/image-duplicate-id): moving a reg-* to another namespace leaves the old namespace's slot behind, because re-evaluating that namespace without the form does not remove it, and a late-loaded module duplicating an id does the same in production. All three legs of the promotion criterion hold: it is production-reachable; it is locally invisible, because every dispatch to the frame succeeds against a program the registrations no longer describe; and the silence compounds, because no later registration lands until the duplicate is cleared. re-frame.live-frame's resilient sweep (reproject-live-frames-resiliently!, run by the deferred next-tick flush and by the read-time consult in frame resolution) isolates EACH frame in its own try/catch and continues to every other frame, fanning ONE NON-EVENT union record per failed frame through dispatch-error-record! — the corpus listeners, the frame's :observability :errors sink and the dev console fallback — beside its dev trace. The sweep runs only after a reg-* marked the projection dirty, so the record arrives once per failed refresh: plain dispatches to the frozen frame never repeat it, a refresh that fails again reports again, and a successful refresh reports nothing. The sweep itself is ungated, because keeping a frame's generation in step with the registration pool is a CORRECTNESS invariant — make-frame seals a generation for every frame made with :images and registrar/lookup resolves through that seal. A frame made without :images seals nothing and never reaches the sweep: an id it cannot resolve raises :rf.error/ambiguous-registration where it is used. Production elision comes from REACHABILITY instead: only make-frame installs the wiring (ensure-reprojection-installed!), so an app that never constructs a frame never roots the sweep, its assembly graph or this record. The synchronous reproject-live-frames! / flush-pending-reprojection! keep their throw-through contract and emit nothing. Per EP-0023 §Hot Reload |
:kept-prior-generation — the failed frame stays on its prior generation until the cause is cleared; the sweep continues to every other frame. For a duplicate id the :reason names the recovery: (rf/clear kind id), then re-evaluate the namespace that should own the id |
:frame (the frame left on its prior generation), :reason; for a duplicate-id cause :registration (the [kind id]) and :namespaces (every namespace registering it, sorted); for any other cause :exception. The dev trace carries the same slots plus :exception and :where (:reproject-live-frame!) |
:rf.warning/reprojection-flush-failed |
:warning |
diagnostic | The belt-and-suspenders OUTER try/catch around the deferred (next-tick) reprojection flush (deferred-flush!) — a defensive last resort for a failure OUTSIDE the per-frame boundary itself (e.g. enumerating image-loaded-frame-ids, the dirty-flag reset!), vanishingly unlikely but diagnosed here rather than silently discarded. A deferred background tick has no caller to surface a throw to (it runs on a next-tick macrotask / the JVM executor thread, outside any reg-* call frame), so the failure is DIAGNOSED, not re-thrown; the category stays on the diagnostic channel: it guards an internal fault outside the per-frame boundary, and every per-frame failure reports itself on the always-on axis as :rf.error/reprojection-failed. The flush itself is NOT dev-only (see :rf.error/reprojection-failed for why the maintainer is ungated and how the production elision survives); this diagnostic is, gated on interop/debug-enabled? inside trace/emit-error!, so it DCEs in production. Emitted by live_frame.cljc's deferred-flush!. Per EP-0023 §Hot Reload |
:no-recovery — the deferred flush tick ends; the dirty flag was already cleared before reprojecting, so a re-entrant reg-* during the flush still re-arms a fresh tick |
:exception, :where (:deferred-flush!) |
:rf.warning/schema-walker-opaque |
:warning |
diagnostic | A reg-app-schema (or reg-app-schemas) call was made with a schema the walker cannot fully introspect — a genuinely opaque value at the ROOT (a compiled m/schema object, a map, or any other non-vector, non-keyword value), a vector form embedding such a value at a NESTED schema position, or a vector form carrying a local {:registry …} whose keyword references the walk resolves nowhere, or an explicit [:ref …] reference form, whose target the walk resolves nowhere. 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. That is a DISCOVERY claim and not a disclosure one — a validation failure against such a schema still REDACTS. Every redaction surface fails CLOSED on the flag it cannot see rather than shipping the value: re-frame.schemas.validate's redact-validation-tags, which the validation seam applies to every surface's failure tags but :app-db's (the event, fx and sub families, machine :data and :output, :sub-override, the production boundary, route params and the always-on :rf.error/cofx-value-invalid), ORs the recursive schema-has-opaque-child? into its sensitivity decision; validate-app-schema! ORs the same predicate into the one decision that governs every slot of an app-db failure; and re-frame.schemas.storage's hot-reload :rf.schema/violation emit carries the identical posture. So the value-bearing slots scrub to :rf/redacted and :sensitive? is stamped — for a nested opaque child, a local {:registry …} and an explicit [:ref …] alike — per 010 §Compiled / opaque schemas fail closed. What the skipped flags DO cost: such a schema's validation failures always redact, even when nothing in it is sensitive. The :large? walker (extract-large-paths-from-schema) also won't see its :large? declarations, so an opaque schema's large slots earn no :rf.size/large-elided marker from the owner-local size-elision classifiers; on a validation failure the fail-closed sensitive redaction subsumes that arm (sensitive wins over large). 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 (there is no handler-meta :sensitive? coarse fallback — 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. Production elides via goog.DEBUG. Per 010 §The :schema value is opaque to re-frame |
: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, :local-registry, :unknown — a ROOT-shape classification, so a NESTED opaque value or a nested local registry leaves the root a plain vector form and reports :unknown), :reason (an actionable string that names the vector-form fix) |
:rf.warning/large-value-unschema'd |
:warning |
diagnostic | The re-frame.elision/elide-wire-value walker observed a large string at a path carrying no :large classification, anywhere in the walked value — including inside a subtree it returned unchanged because no declared path reaches it. Emitted at most once per (path, frame) pair. Advisory: classify the path :large with a reg-classification beside the feature that writes it when the value should be elided — a {:large? true} schema slot prop is NOT a route into durable classification (EP-0015 §8). The keyword name stays as-is for wire compatibility. 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-nil non-:rf/public-error shape (false included), or the frame's configured :public-error-id names no registered projector; the runtime fell back to the locked generic-500 public shape. A projector that returns nil is not a failure: the default projector answers the same trace event and nothing is recorded. Always-on: rides the always-on error-emit axis (surface #4) ALONGSIDE the dev trace, making the §Server error projection promise ("monitor dashboards see when the public boundary fell back to the generic-500 shape") TRUE under -Dre-frame.debug=false. NON-PROJECTING + RE-ENTRY GUARD: this IS the fallback path, so the always-on emit must be one-shot and must never re-enter projection — error-emit-projection-listener explicitly SKIPS this category (its recursion guard), and project-error fires at most once per call, so the record ships to off-box shippers only and cannot drive a second projection. Per 011 §Where sanitisation happens — before render |
:replaced-with-default — runtime falls back to the locked generic-500 public-error shape |
:projector-id, :original-operation, :projection-failure-reason, :reason, :exception-message (the projector-threw arm), :returned (the non-conforming-shape arm) |
:rf.error/ssr-head-resolution-failed |
:error |
always-on | An SSR host adapter's resolve-head (per 011 §Head/meta contract) caught a throw from the active route's :head fn (the ssr/head-model walk or the ssr/head-model->html emit). The host adapter degrades to an empty head fragment so the request still produces a response; the structured record carries the exception for production observability. Always-on: rides the always-on error-emit axis (surface #4) ALONGSIDE the dev trace — per 011 §resolve-head emits …-failed ("the always-on error-emit substrate carries the trace to user observability stacks"). 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 | TWO SURFACES share this id. The id is REUSED rather than multiplied — nothing about the second failure is Fresco's own — but the two differ in every column to the right, so read WHICH surface you have before reading its disposition. (1) THE JVM HOST-ADAPTER RECORD — always-on, synthesised, projected. An SSR host adapter caught a render-time Throwable while building the response body (the validate-tag-name! rejection of e.g. (keyword "has space"), a view-fn (throw (ex-info ...)), a hiccup-walker structural error). Synthesised by re-frame.ssr/project-render-exception! (see re-frame.ssr.error-listener/project-render-exception!) so render-time and drain-time SSR failures unify under the same error projector — the wire body is the projector's :message / :code for both paths. Always-on: rides the always-on error-emit axis (surface #4) ALONGSIDE the dev trace so an off-box shipper on a -Dre-frame.debug=false JVM SSR host sees the structured render-failure record. PROJECTION-ELIGIBLE: the projection that stamps the response status is driven DIRECTLY by project-render-exception!; the buffered duplicate the always-on listener appends is cleared in the same call (consume-pending-traces!), so promotion does NOT double-stamp or re-project — the wire status is unchanged. Emitted by the JVM reference adapter's re-frame.ssr.ring.pipeline/build-full-response (the outer try/catch around render-to-string). (2) THE FRESCO RENDERER'S TYPED REFUSAL — a THROW, and no record at all. re-frame.fresco.server/refuse-recovered-render-error!, raised from BOTH its render and render-body doors when the pass returned markup but the always-on error-emit stream recorded an error the runtime RECOVERED from — a wrong page served as a success is worse than a loud failure, and no downstream projector can see that recovered record: on render-body it was made in the Node process while the projector is on the JVM, and on render the per-frame buffer never holds it because the cold compute-sub read stamps :frame nil. It calls re-frame.error/throw-error! and nothing else: it does NOT call project-render-exception!, emits NO always-on record, and stamps NO status, so none of surface (1)'s synthesis / promotion / projection sentences reach it. What travels is the THROW — from render-body the sidecar turns it into its render-threw refusal, which the JVM adapter re-ids as :rf.error/ssr-node-refused (re-frame.ssr.ring.node) before the request lands on the error view; from render it propagates to the host's own caller. The always-on Channel beside this row is therefore surface (1)'s: the Channel column is per-category and arm-level nuance rides the row's prose. Per 011 §View-time exceptions |
(1) The JVM host-adapter record: :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. (2) The Fresco refusal: :fail-the-render — the literal :recovery its ex-data carries. Nothing is projected and no status is stamped: the page is failed rather than served, and the fix belongs to the surface the recorded error names, not to the renderer. A render-body refusal is then recovered from downstream under :rf.error/ssr-node-refused and that row's disposition |
(1) The JVM host-adapter record (:tags): :frame, :exception, :exception-message, :ex-class. (2) The Fresco refusal is a thrown ex-info, so its slots are FLAT ex-data rather than :tags keys (per §Reading the two right-hand columns): the four §The thrown-error shape requires, plus :frame, :recorded (how many recovered errors the pass recorded) and :record (the first of them — the evidence naming the surface to fix). It carries NO :exception / :exception-message / :ex-class: there is no caught Throwable on this path |
:rf.error/ssr-ring-error-view-failed |
:error |
always-on | A caller-supplied :error-view (registered-view keyword or 1-arity fn) FAILED while rendering the projected-error body: it either THREW, or it depended on a reactive sub that RECOVERED to nil under production hardening (which does not throw but buffers a fail-closed projection the host detects via a pending-error-trace peek). Either way the Ring host adapter falls back ONCE to the locked default error template from the ORIGINAL public-error, WITHOUT re-projecting — "a buggy error-view must not bypass the error boundary". The recoverable-degradation sibling of :rf.error/ssr-head-resolution-failed (same fragility class). Always-on: rides the always-on error-emit axis (surface #4) ALONGSIDE the dev trace so an off-box shipper on a -Dre-frame.debug=false JVM SSR host sees a broken error-view. NON-PROJECTING: fires from the projected-error arm (a drain-time 5xx, a render-time throw, or a post-render recovered-to-nil sub — Spec 011 §Drain-time error classification) AFTER the projected status was ALREADY stamped; the always-on error-emit-projection-listener skips this category (re-frame.ssr.error-listener/non-projection-eligible-errors), so promotion ships the off-box record but NEVER re-projects or flips the stamped status. Emitted by re-frame.ssr.ring.pipeline/resolve-error-body in the Ring host adapter; symmetric helpers in other host adapters MUST emit the same category |
:fell-back-to-default-error-template — the buggy error-view is discarded; the locked host default error template renders the body; the projected status is unchanged |
:frame; and either :exception (the throwable's message) + :ex-class (the throw variant), or :reason (:reactive-sub-recovered-to-nil-in-error-view, the recovered-to-nil variant) |
:rf.error/ssr-ring-response-status-invalid |
:error |
always-on | The Ring response materialiser saw a non-integer :status on the resolved response accumulator and REWROTE the response to a fail-closed 500 (re-frame.ssr.ring.pipeline/fail-closed-status — Ring statuses must be integers, and "404" is not assumed to mean 404). The rewrite is the point: an app's 200 becomes a 500, so it must not happen in silence. Always-on: with only the :rf.ssr/ssr-non-integer-status dev warning above, an operator on a -Dre-frame.debug=false JVM SSR host would see a 500 with no record of why on either axis. It fans a NON-EVENT union record through the :error-emit/dispatch-error-record hook ALONGSIDE that dev warning. What reaches it: the reserved :rf.server/* fx guard their own args in every build, so the framework's own fx path cannot feed the flip ([:rf.server/set-status "not-an-int"] is refused upstream and the projector stamps a 500; [:rf.server/redirect {:status "302"}] never populates :redirect). What remains is a HOST that hand-builds the accumulator (re-frame.ssr.response/swap-response!) or calls the PUBLIC materialiser with its own response map — the last-line backstop this net exists for, which is exactly why a silent one is not good enough. NON-PROJECTING: listed in re-frame.ssr.error-listener/non-projection-eligible-errors — it fires at MATERIALISATION time, strictly after the response is resolved and flushed, so a projection could only fight the 500 it is already reporting. The record is FRAMELESS (:frame nil, the :rf.error/malformed-hydration-payload spelling): the materialiser is a pure response-map to Ring-map fn with no frame argument, and an ambient read would populate the slot on the error arm while leaving it nil on the very path this record exists for. Emitted by re-frame.ssr.ring.pipeline/report-non-integer-status!; symmetric materialisers in other host adapters MUST emit the same category |
:failed-closed-to-500 — the response status is forced to 500 (a valid, fail-closed Ring response); the offending status is never coerced or guessed |
Always-on record (axis 1) — the closed set #{:error :frame :time :where :status-type :reason :recovery}, built as a literal at re-frame.ssr.ring.pipeline/report-non-integer-status! and pinned by status_rewrite_always_on_test: :frame (always nil), :where (:ssr-ring/ssr-response->ring-response), :status-type (the offending value's CLASS NAME — program structure, the documented :ex-class residual class, never the value), :reason (:non-integer-status, a closed framework keyword — never prose). Dev trace (axis 2) — adds the offending VALUE: :status (the raw non-integer, e.g. "404") and a prose :reason, alongside the same :where / :status-type. The raw :status is deliberately axis-2-only and its ABSENCE from the record is asserted by name, because an absence nothing asserts is a bound that does not bound |
:rf.epoch.cb/listener-exception |
:error |
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 :error, emitted through emit-error!; the operation namespace :rf.epoch.cb names the time-axis tooling family, NOT a runtime :rf.error/*. A dev/tooling diagnostic, not a production-reachable runtime error — it FAILS the promotion criterion and correctly stays diagnostic. Emitted by re-frame.epoch.listeners (the time-axis tooling family consumed by the Xray spec) |
:no-recovery — the listener invocation is over; the next cascade re-invokes the same fn afresh, no automatic remediation between now and then |
:frame, :cb-id, :rf.epoch/id, :message |
:rf.warning/mutation-scope-mismatch |
:warning |
diagnostic | A mutation's :invalidates descriptor resolved a scope that matched NO cache entry while the SAME tags DO match an entry in a DIFFERENT scope — the invalidation silently missed (the scoped read is never refreshed, no error is raised because a scoped invalidation matching nothing in its own scope is a legitimate "no match here"). The mutation-scope footgun: a :rf.scope/global-defaulted execution scope (no payload / spec :scope) invalidating tags owned by a session-/tenant-/user-scoped resource. Fires at mutation settlement (:after-success / :after-settle / :after-failure) and on :before-request timing, per dispatched descriptor; a :cross-scope? true descriptor (the audited deliberate escape) is never flagged, and a tag with no cache entry in ANY scope (a true nothing-to-invalidate) does not warn. Reuses the SHARED re-frame.resources.events/match-invalidation-keys :other-scope-hit? signal so the diagnostic, the dispatched :rf.resource/invalidate-tags, and the settlement trace never disagree. A dev advisory (dedupe-keyed so it fires once per [mutation-id descriptor-scope other-scope sorted-tags]), interop/debug-enabled?-gated and DCE'd in production — FAILS the promotion criterion and stays diagnostic. Emitted by re-frame.resources.mutation-events. Per 016 §Mutation scope is two distinct scopes |
:fix-scope — declare the matching scope on the execute payload :scope, or use a per-target :invalidates descriptor {:scope … :tags …} (the :hint names the fix) |
:rf.frame/id, :mutation, :instance, :descriptor-scope, :mutation-scope, :other-scope, :tags, :hint |
:rf.warning/optimistic-force-clobber |
:warning |
diagnostic | An optimistic mutation rolled back with :on-conflict :force over one or more entries whose per-entry :revision had moved since the optimistic apply (a competing authoritative write landed in between) — so :force restored the recorded (now-stale) :before, clobbering that concurrent write (EP-0019 Decision 3). :force is the deliberate single-writer last-write-wins escape; this advisory makes an unexpected clobber loud (the default :on-conflict :invalidate defers to the read path and never clobbers). A dev advisory, interop/debug-enabled?-gated and DCE'd in production — FAILS the promotion criterion and stays diagnostic. Emitted by re-frame.resources.mutation-events. Per 016 §Optimistic settle |
:review-on-conflict — if the forced entries can be written concurrently, use the :invalidate default (refetch the authoritative value on conflict) instead of :force |
:rf.frame/id, :mutation, :instance, :forced-keys, :reason |
:rf.warning/optimistic-tags-descriptor-skipped |
:warning |
diagnostic | A malformed :optimistic-tags descriptor — a non-map entry, a non-collection :tags, or a missing :patch fn — was warn-and-skipped rather than thrown (EP-0019). The :optimistic-tags normalization runs inline at :rf.mutation/execute time, BEFORE the request lowers, so a throw aborted the whole event and the authoritative write never fired — strictly worse than :invalidates, which validates post-write at settle. The malformed descriptor is dropped (the well-formed descriptors in the same plan still apply); the optimistic paint is reversible best-effort, so the authoritative reply still settles the cache via :populates / :invalidates. The fail-closed SCOPE boundary is unaffected (it lives downstream in target resolution — a nil-resolving {:from-db …} still drops the target with :target-unresolved evidence). A dev advisory, interop/debug-enabled?-gated and DCE'd in production — FAILS the promotion criterion and stays diagnostic. Emitted by re-frame.resources.mutation-events. Per 016 §Optimistic mutations |
:fix-descriptor — give the descriptor a :patch fn and a :tags collection (the :reason names the offending shape) |
:rf.frame/id, :mutation, :reason, :descriptor |
:rf.warning/mutation-target-skipped |
:warning |
diagnostic | A recoverable settle-time mutation target (an :populates / :patches / :removes arm reaching the post-write apply) was dropped-and-warned rather than stranding the committed mutation: an UNREGISTERED resource, a non-map target, or a non-keyword :resource. The server write already committed, so dropping the one bad sibling (while the valid siblings in the same arm still apply) beats stranding the whole instance — but the developer still needs the loud, recoverable tripwire (the asymmetry-fix is NOT a silent swallow). Cache-identity CORRUPTION (a reserved-scope typo / non-EDN scope) never reaches here — it still THROWS in the runtime classifier, fail-closed. A dev advisory, one-shot idempotent per [mutation-id arm reason resource target] (so a re-executed mutation warns once per genuine bad target), interop/debug-enabled?-gated and DCE'd in production — FAILS the promotion criterion and stays diagnostic. Emitted by re-frame.resources.mutation-events. Per 016 §Map-form exact resource targets |
:fix-mutation-target — fix the target so the cache consequence lands (register the resource, or supply the map-form exact target {:resource <id> :params … :scope …}; the :hint names the offending shape) |
:rf.frame/id, :mutation, :instance, :arm, :reason (:unregistered-resource / :non-keyword-resource / :non-map-target), :resource, :target, :hint |
:rf.error/redirect-invalid-url |
:error |
always-on | The :rf.server/redirect fx received a :location string that could not be parsed as a URL, or a scheme-bearing http(s) URL whose host is not extractable — an opaque http:evil.example.com or an authority-less http:/evil, which parse cleanly with a nil host yet navigate off-origin (:reason :scheme-without-host, and the arm additionally carries :scheme-class). Per 011 §Redirect precedence. The redirect is rejected; the response accumulator's :redirect slot is unchanged. Always-on: fans a NON-EVENT union record through the :error-emit/dispatch-error-record hook ALONGSIDE the dev trace, so a -Dre-frame.debug=false host reports the rejection to an off-box shipper instead of rejecting it in silence; the record is a closed PROJECTION of the diagnostics — #{:frame :recovery :reason :scheme-class}, every value framework-owned — so no URL, no URL component and no allowlist rides off-box, while the dev trace keeps the EP-0015-scrubbed :location beside them. NON-PROJECTING: listed in re-frame.ssr.error-listener/non-projection-eligible-errors, so the rejection ships a record but NEVER stamps a status — promotion changes what shippers see, never what the wire does. See the always-on paragraph above for why that half is load-bearing. Emitted by re-frame.ssr.response/redirect-fx. |
:no-recovery — the redirect is rejected; no Location header is set |
Always-on record (axis 1) — the closed projection #{:frame :recovery :reason :scheme-class}, every value a framework keyword or the frame's own id: :frame, :reason (:parse-failed or :scheme-without-host), plus :scheme-class on the :scheme-without-host arm, where it is :http or :https (a slot whose source did not parse is omitted rather than carried as nil, so the per-arm key sets differ — the parse-failure arm carries no class at all). Dev trace (axis 2) — adds the URL and the raw components: :location, EP-0015-scrubbed inside the shared builder, and the raw :scheme spelling, alongside the same :frame / :reason. See the always-on paragraph above for why the record is built FROM the allow-list rather than scrubbed down to it, and why it carries the CLASS rather than the parsed component |
:rf.error/redirect-scheme-rejected |
:error |
always-on | The :rf.server/redirect fx received a :location whose scheme is one of the rejected set (javascript:, data:, vbscript:) — these schemes have no safe interpretation as redirect targets (XSS via javascript:, data-URL phishing, IE-era vbscript:) — or, on the second arm, any scheme outside http / https (mailto:, ftp:, file:, tel: …), which carries :reason :scheme-not-allowed. Consistent with the custom-editor scheme rejection (per Security §Editor URI scheme allowlist). Always-on: fans a NON-EVENT union record through the :error-emit/dispatch-error-record hook ALONGSIDE the dev trace, so a -Dre-frame.debug=false host reports the rejection to an off-box shipper instead of rejecting it in silence; the record is a closed PROJECTION of the diagnostics — #{:frame :recovery :reason :scheme-class}, every value framework-owned — so no URL, no URL component and no allowlist rides off-box, while the dev trace keeps the EP-0015-scrubbed :location beside them. NON-PROJECTING: listed in re-frame.ssr.error-listener/non-projection-eligible-errors, so the rejection ships a record but NEVER stamps a status — a rejected ?next=javascript:alert(1) must not become a 500, or the mitigation would itself be a denial of service. Emitted by re-frame.ssr.response/redirect-fx. |
:no-recovery — the redirect is rejected; no Location header is set |
Always-on record (axis 1) — the closed projection #{:frame :recovery :reason :scheme-class}, every value a framework keyword or the frame's own id: :frame, :scheme-class (the aggregatable probe class — :javascript / :data / :vbscript on the rejected-scheme arm, :other on the non-http(s) arm), plus :reason :scheme-not-allowed on that second arm. Note what the class does NOT do: it never spells the caller's scheme. An arbitrary scheme is caller-authored text, so mailto: and s3cr3t-probe-token: both arrive as :other — the operator keeps the distinction worth aggregating (WHICH class is being probed) and the record keeps no byte the caller chose. Dev trace (axis 2) — adds the URL and the raw components: :location, EP-0015-scrubbed inside the shared builder, and the raw :scheme spelling ("mailto"), alongside the same :frame / :reason. See the always-on paragraph above for why the record is built FROM the allow-list rather than scrubbed down to it, and why it carries the CLASS rather than the parsed component |
:rf.error/redirect-host-disallowed |
:error |
always-on | The :rf.server/redirect fx received a :location whose host is not permitted by the call's policy — either the call carried no :allow — the default, relative-only — and the URL was not a relative reference (it carried a scheme OR an authority, which closes the opaque-URI and protocol-relative bypasses), OR :allow [...] named hosts and the URL's host did not appear among them (an empty :allow refuses every absolute target). :allow :any waives this check and no other. The :reason tag (:relative-only-violation or :not-in-allowlist) discriminates the two modes. Mitigation for the open-redirect class: an attacker-controlled ?next=… URL parameter cannot redirect off-origin unless the call names its host in :allow or spells :allow :any. Always-on: fans a NON-EVENT union record through the :error-emit/dispatch-error-record hook ALONGSIDE the dev trace, so a -Dre-frame.debug=false host reports the rejection to an off-box shipper instead of rejecting it in silence — open-redirect probing against a production app is exactly the signal a security team needs; the record is a closed PROJECTION of the diagnostics — #{:frame :recovery :reason :scheme-class}, every value framework-owned — so no URL, no URL component and no allowlist rides off-box, while the dev trace keeps the EP-0015-scrubbed :location beside them. NON-PROJECTING: listed in re-frame.ssr.error-listener/non-projection-eligible-errors, so the rejection ships a record but NEVER stamps a status — promotion changes what shippers see, never what the wire does. Emitted by re-frame.ssr.response/redirect-fx. |
:no-recovery — the redirect is rejected; no Location header is set |
Always-on record (axis 1) — the closed projection #{:frame :recovery :reason :scheme-class}, every value a framework keyword or the frame's own id: :frame, :reason (one of :relative-only-violation, :not-in-allowlist) — and nothing else, since neither arm has a scheme to class. :host is DEV-TRACE-ONLY. It reads like the most useful thing this row could carry, and it is the one slot deliberately given up: on both arms it is by construction a host the app did NOT authorise (that is what each :reason means), the redirect is already refused so there is nothing left for an operator to block, and it would be this record's only unbounded caller-authored string — able to carry a sentinel outright, and able to be varied per request to write unbounded distinct values into a metrics dimension. :reason keeps the discrimination an operator acts on. Dev trace (axis 2) — adds the URL, the host and the policy: :location, EP-0015-scrubbed inside the shared builder, :host (the rejected host), and :allowlist (the allowlist vector, when supplied), alongside the same :frame / :reason. None of the three reaches an off-box shipper: :allowlist is additionally the app's own security configuration, and :reason :not-in-allowlist discriminates the arm without naming the boundary being probed. See the always-on paragraph above |
:rf.error/redirect-invalid-location |
:error |
diagnostic | The :rf.server/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 CRLF/NUL gate (validate-redirect-location!) fails fast at fx-handler time so the malformed redirect target never reaches the wire, and runs before the URL gate under every :allow policy, :allow :any included (per 011 §Standard fx and Spec 011 §CRLF fail-fast). It is the THROWING half of the fx's checks: the URL gate after it is emit-based and surfaces :rf.error/redirect-invalid-url, :rf.error/redirect-scheme-rejected and :rf.error/redirect-host-disallowed. 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. 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) |
:rf.epoch/restore-unknown-epoch |
:error |
diagnostic | restore-epoch! was called with an epoch-id that is not in the frame's current epoch history (either never recorded or aged out by :depth). Per Tool-Pair §Time-travel |
:no-recovery — restore rejected; the frame's state is unchanged |
:frame, :rf.epoch/id, :history-size |
:rf.epoch/restore-schema-mismatch |
:error |
diagnostic | The recorded :db-after no longer validates against the currently-registered app-schemas set (a schema was added, tightened, or replaced since the snapshot was taken). Per Tool-Pair §Time-travel |
:no-recovery — restore rejected; the frame's state is unchanged |
:frame, :rf.epoch/id, :schema-digest-recorded, :schema-digest-current, :failing-paths |
:rf.epoch/restore-missing-handler |
:error |
diagnostic | The recorded app-db references a registered-id (e.g. an active machine at [:rf.runtime/machines :snapshots <id>], a registered route currently in [:rf.runtime/routing :current]) that is no longer present in the registrar. Per Tool-Pair §Time-travel |
:no-recovery — restore rejected; the frame's state is unchanged |
:frame, :rf.epoch/id, :missing (vector of {:kind :id}) |
:rf.epoch/restore-version-mismatch |
:error |
diagnostic | The frame's recorded :rf/snapshot-version (per Spec-Schemas §:rf/machine-snapshot) is incompatible with the currently-loaded machine definition. Per Tool-Pair §Time-travel |
:no-recovery — restore rejected; the frame's state is unchanged |
:frame, :rf.epoch/id, :machine-id, :version-recorded, :version-current |
:rf.epoch/restore-during-drain |
:error |
diagnostic | restore-epoch! was called while the frame's run-to-completion drain is still in flight (per 002 §Run-to-completion dispatch). Restore is rejected; the user retries after settle. Per Tool-Pair §Time-travel |
:no-recovery — restore rejected; the user retries after settle |
:frame, :rf.epoch/id |
:rf.epoch/restore-non-ok-record |
:error |
diagnostic | restore-epoch! was called against an epoch record whose :outcome is not :ok (a halted-run record kept for devtools introspection; see Spec-Schemas §:rf/epoch-record §Outcomes). Restore is rejected because a halted record's :db-after is partial state the run never settled to. |
:no-recovery — restore rejected; the frame's state is unchanged |
:frame, :rf.epoch/id, :outcome, :halt-reason |
:rf.epoch/replace-during-drain |
:error |
diagnostic | a pair-tool injection (replace-frame-state!) was called while the frame's drain was still running. Pair-tool injection is rejected; the caller retries after settle. Per Tool-Pair §Pair-tool writes |
:no-recovery — pair-tool injection rejected; the caller retries after settle |
:frame |
:rf.epoch/replace-schema-mismatch |
:error |
diagnostic | a pair-tool injection was called with a value that fails the frame's schemas — a PRESENT app-db partition against the :app-schema set the frame resolves, or a PRESENT runtime-db partition against the framework-owned runtime-db validator (reg-runtime-schema). An absent partition key is not walked (it is preserved, not written). The injection is rejected; the targeted partition is unchanged. Per Tool-Pair §Pair-tool writes and 010 §Schemas resolve through the frame's images |
:no-recovery — pair-tool injection rejected; the targeted partition is unchanged. The failing paths are surfaced in :tags :failing-paths |
:frame, :failing-paths |
:rf.epoch/replace-history-disabled |
:error |
diagnostic | a pair-tool injection (replace-frame-state!) was called while the epoch ring buffer is disabled ((rf/configure! {:epoch-history {:depth 0}})). Each injection records a synthetic :rf.epoch/db-replaced undo-anchor so restore-epoch! can rewind PAST it — its caller's invariant is "undo works after this call". Under depth 0 the ring retains no history (Tool-Pair §Time-travel — consume via register-epoch-listener!), so the anchor cannot land and the invariant is unsatisfiable. The injection is rejected loudly rather than returning a false true: a silent success would lie about the undo invariant. The targeted partition is unchanged. Per Tool-Pair §Pair-tool writes |
:no-recovery — pair-tool injection rejected; the caller must re-enable history (:depth > 0) before injecting if it needs undo |
:frame |
:rf.error/replace-frame-state-bad-keys |
:error |
diagnostic | replace-frame-state! was called with a frame-state map carrying no recognized partition key (:rf.db/app / :rf.db/runtime), or carrying an unrecognized key alongside them. replace-frame-state! is a PARTIAL-PATCH surface: a present key replaces that partition, an absent key preserves it, so an accidental typo key (e.g. :rf.db/apps) or an empty {} would otherwise silently no-op the whole call while returning true — a false success against the caller's undo invariant. Checked BEFORE frame resolution (a caller-input-shape error, independent of the target frame's existence); rejected loudly rather than treated as a silent no-op. Per Tool-Pair §Pair-tool writes |
:no-recovery — the injection is rejected; the frame-state is unchanged |
:frame, :reason (:no-recognized-keys or :unknown-keys), :keys (the caller-supplied map's key set) |
:rf.error/no-such-fx |
:error |
always-on | A dispatched fx-id has no registered handler (and was not redirected by :fx-overrides). Per 002 §:fx ordering and atomicity guarantees. Emitted by re-frame.fx/handle-one-fx after override resolution and reserved-id matching both miss |
:no-recovery — the fx is dropped; cascade continues with remaining :fx entries |
:rf.fx/id, :rf.fx/args, :frame |
:rf.error/fx-registration-invalid |
:error |
diagnostic | A reg-fx call carried a malformed registration shape — no callable handler was supplied (e.g. the metadata-only (reg-fx :my/fx {:doc "…"}), whose (fn [ctx args] …) was omitted). Rejected at REGISTRATION time rather than deferring to a misleading fire-time :rf.error/fx-handler-exception (an NPE blaming the ABSENT handler for "throwing"). The same id refuses a reg-fx, or an inline image :reg-fx, of :dispatch or :dispatch-later: the runtime runs those reserved bodies before any registry lookup, so the handler would register without complaint and never run, and an :fx-overrides entry is what replaces one (Conventions §Reserved fx-ids). The input-side counterpart of :rf.error/cofx-registration-invalid (above) — the register site validates the callable BEFORE the registrar write so the fail-loud is symmetric across reg-fx / reg-cofx. Per 001 §Registration. Registration-time. |
:no-recovery — the registration is rejected |
:rf.fx/id, :reason |
:rf.error/unregistered-cofx |
:error |
always-on | A :rf.cofx/requires declaration referenced a coeffect id with no reg-cofx registration (the typo case). Per 001 §:rf.cofx/requires. Fired at registration where statically checkable, else at first processing — typos die before dispatch semantics apply. |
:no-recovery — the registration / dispatch is rejected |
:rf.cofx/id, :failing-id (the declaring handler / entry), :rf.trace/event-id (when available) |
:rf.error/missing-required-cofx |
:error |
always-on | A declared recordable fact is absent and cannot be ensured — the :strict mint policy (no generator runs; the Tool-Pair replay / :test preset binding), or any mode for a provided fact whose value was not stamped onto the token. Per 002 §Mint policies. |
:no-recovery — the cascade halts before the handler runs |
:rf.cofx/id, :failing-id, :rf.trace/event-id (when available) |
:rf.error/cofx-value-invalid |
:error |
always-on | A supplied, replayed, or generated recordable value failed the registration's :schema. Fires in production as well as dev — a causal-token contract validation (the :dispatched-at precedent: folding an out-of-contract value into the ledger is corrupt durable state). Per 002 §Satisfaction. The :schema-when-declared half: 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 :schemas/check! validation seam (a nil validator installed through set-schema-fns! passes; an absent schemas artefact refuses with :rf.error/schemas-artefact-missing in every build; the check fails CLOSED on a validator that throws, withholding the value as :value :rf/redacted with :sensitive? true, and in development builds also reports :rf.error/malformed-schema at :where :cofx). The structural-EDN-always half — rejecting a non-EDN recordable value (a host handle: DOM node, Promise, function, atom, JS / Java object; a Date is NOT one — it reads back as #inst and is recordable, while a JVM java.time.Instant does not read back and is rejected) independent of a declared :schema, with reason :non-edn-recordable-value — covers the SUPPLIED path: 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 ALWAYS-ON — a hard error in production as well as dev, because a non-EDN value folded into the durable record is corrupt state in either. The GENERATED-value structural check covers the generated path: 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 ALWAYS-ON. Emitted from re-frame.cofx's satisfaction step (the :schema half) and generator write-back (the generated structural half) and from re-frame.router's dispatch boundary (the supplied structural half); routing and resources also throw it from their handlers when a supplied or replayed allocation is malformed, which surfaces as :rf.error/handler-exception |
:no-recovery — the cascade halts |
:rf.cofx/id, :value (the :schema half only; the structural half never carries the value), :explain (validator explanation when available), :explain-humanized (development builds), :sensitive? / :large? (true when the seam redacted or size-elided the value-bearing slots), :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, or where a frame made without :images uses it as :rf.error/ambiguous-registration (above), uniformly with every other registered kind. A malformed registration shape (bad metadata, missing supplier) is :rf.error/cofx-registration-invalid (below), not a collision, and the owner-qualified rf.-prefixed-namespace rule is a lint diagnostic, not a registration-time collision (001 §Collisions). Per 001 §Collisions. Registration-time. |
:no-recovery — the registration is rejected |
:rf.cofx/id |
:rf.error/cofx-registration-invalid |
:error |
diagnostic | A reg-cofx call carried a malformed registration shape — an invalid metadata map / provider grade (e.g. {:recordable? false :provided? true}), or no supplier for a non-provided id. Distinct from :rf.error/cofx-name-collision (above), which is reserved for genuine duplicate-ownership / name-collision cases; the registrar discriminates so the taxonomy stays crisp (a bad shape is not a collision). Per 001 §reg-cofx. Registration-time. |
:no-recovery — the registration is rejected |
:rf.cofx/id, :reason |
:rf.error/cofx-request-invalid |
:error |
diagnostic | A malformed :rf.cofx/requires at registration — a non-vector, or a vector carrying a non-id entry. Per 001 §:rf.cofx/requires. Registration-time. |
:no-recovery — the registration is rejected |
:failing-id, :received |
:rf.error/inject-cofx-removed |
:error |
always-on | inject-cofx (or inject-cofx*) was called. It is not part of the API (no alias): the var exists only to fail loud, with a hard error naming :rf.cofx/requires as the replacement; fires in production too (a correctness contract). Per 001 §inject-cofx is removed |
:no-recovery — the call is rejected |
:rf.cofx/id (the id passed to inject-cofx, when available) |
:rf.error/frame-destroyed |
:error |
always-on | A dispatch / dispatch-sync / subscribe arrived against a frame that is unregistered or whose (:lifecycle frame-record) carries :destroyed? true. Per 002 §Frame lifecycle. The runtime recovers (dispatch / dispatch-sync no-op — the event is not enqueued; subscribe returns nil) and emits a production-survivable record through the always-on error-emit listener (surface #4) — NOT just the dev trace. Recovery (not throwing) is race-safe (teardown / hot-reload races vs. real use-after-destroy bugs are indistinguishable) while the broad listener keeps the diagnostic observable in production. Recover-but-emit is the only behaviour in the dispatch / dispatch-sync / subscribe realms this row is about — but NOT the only behaviour under this id. re-frame.ssr.render-state's liveness PRECONDITION (require-live-frame!, guarding project and restore! under :where rf.ssr/render-state) and re-frame.ssr/hydration-payload (under :where rf.ssr/hydration-payload, because a frame that is not live would project an :rf/app-db redacted whole, which the client's malformed-payload guard then refuses) each throw it through re-frame.error/throw-error! and emit no record at all, so the always-on Channel beside this row is the operation realms' — arm-level nuance rides the row's prose. They throw deliberately: the race that makes recovery right in those realms — a teardown / hot-reload destroy is indistinguishable from a real use-after-destroy bug — has no counterpart in a server render, which is a synchronous call from application code whose caller can handle the refusal, and half-projected markup for a dead frame is worse than a loud refusal. The live captured-op counterpart is core's capture-frame incarnation fence: a :dispatch / :dispatch-sync / :subscribe fired from a capture whose pinned incarnation has been SUPERSEDED recovers-but-emits rather than throwing — these ops fire from host cleanup, where a throw would break the teardown — and it emits and DROPS the op instead of delegating on, so the same failure is never ALSO fanned by the ordinary address-directed recover-and-emit path. 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). ONE surface in these realms. The SSR liveness throws — render-state's precondition and hydration-payload's — are outside this cell: each THROWS, carrying its own :recovery :pass-the-live-per-request-frame in the ex-data |
:frame, :event (the attempted event / query vector — elided in the :dispatch / :dispatch-sync realm and fail-closed to :rf/redacted under an unresolvable frame, but egressed RAW in the :subscribe realm because a subscription's query vector is IDENTITY, not payload, and never consults frame policy (see 015 §Data-Classification)), :event-id (the vector head), :op (the failing operation realm — :dispatch / :dispatch-sync / :subscribe — a small closed-enum public realm attribution on every record: the core router stamps :dispatch / :dispatch-sync on the ordinary address-directed path and on every captured-op rejection (the capture-frame stale-op pre-check seam and the late captured-op fences — the A→B incarnation mismatch and the post-token-match / pre-enqueue / pre-drain-acquire window), and the core subs emit-frame-destroyed-recovery! stamps :subscribe, being subscribe-realm by construction. The :subscribe realm is what routes the query vector raw. :op STEERS error-emit/error-source-coord's :source-coord resolution — [:event id] for a dispatch / dispatch-sync, [:sub id] for a subscribe — so an event and a sub sharing a keyword each get their own coord, and a realm with no coord omits the slot rather than take the other realm's. :op says what failed, not where the record may go: whether the record may name the frame-owned :observability :errors route is a separate fact, withheld for every captured-op rejection — whose bare frame id may now name a same-id successor — and kept for the ordinary address-directed path), plus the DEV-TRACE payload slots, which differ per emitter and are reconciled against the live emits in Spec-Schemas §FrameDestroyedTags: the bare :event (router's emitters — the ordinary address-directed recover-and-emit path, and capture-frame's superseded-op seam, which is where the realm split lands: the raw query vector in the :subscribe realm (identity) versus the source-REDACTED body in the :dispatch / :dispatch-sync realms), :query-v + :op :subscribe (subs' recover-and-emit path — subscribe-realm by construction), :reason (:frame-destroyed, router alone). The bare :event spelling is the ERROR-tag spelling and is classification-aware — re-frame.classification/project-trace-event walks :event and :rf.event/v through the same project-event-tags chokepoint; :rf.event/v is the dispatch-PIPELINE spelling and is not stamped by any frame-destroyed emitter. |
:rf.error/write-after-destroy |
:error |
always-on | The substrate adapter's replace-container! choke point was called with a nil container — a scheduled drain raced frame destruction and reached the per-event :db commit after frame/app-db-container started returning nil for the destroyed frame (the choke point covers the router :db commit, drain rollback, flows, epoch restore, and SSR write paths in one place). The underlying adapter's replace-container! is NOT invoked; the write is dropped. It rides the always-on axis: the dropped write is a suppressed write the next op cannot locally observe, is production-reachable in long-lived SSR / multi-frame hosts, and silence compounds with process lifetime — all three legs of the promotion criterion hold. This is the write-path consistency partner of :rf.error/frame-destroyed (previous row), which 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 evaluation threw during the recompute walk inside an event handler's interceptor pipeline (per 013 §Flow tracing) — either the authored :output fn (:phase :derive) or the framework's install of its returned value at the declared :output-path (:phase :output-write). 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. The pass also re-runs the activation checks over the frame's active set, so a re-registration that completes a cycle or an output overlap lands here too, with :exception carrying the :rf.error/flow-cycle / :rf.error/flow-path-overlap throw and no per-flow :flow-id / :phase |
:no-recovery — the cascade halts; the snapshot is uncommitted. The per-flow :rf.flow/failed op-type-:flow event also fires for attribution |
:frame, :event (the event vector — the bare ERROR-tag spelling), :exception (the row's one thrown-value slot, redacted to :rf/redacted with top-level :sensitive? true when the frame declares any :sensitive app-db classification or the row names no :frame — the :rf.flow/failed row's rule), :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), :phase (the failing flow-evaluation phase — :derive or :output-write; without it on this axis a production monitor reads an output-write failure as the application's :output fn throwing and sends the reader to code that did not fail). All three ride the record as top-level slots (lifted out of the thrown :exception's ex-data, where they are spelled :rf.flow/failed-id, :rf.flow/failed-phase and :rf.flow/output-path; each lift is nil-safe, so a non-per-flow throw stamps :where alone), so the flow and the failing phase are still identifiable under an egress profile that drops :exception (the :rf.egress/public-error projection profile, 015 §Data-Classification) — attribution survives egress independent of ex-data |
: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 |
:fix-registration — registration fails (or, on the pure transition surface where registration never ran, the transition throws) |
: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 |
:fix-registration — registration fails (or, on the pure transition surface where registration never ran, the transition throws and the action does not run) |
: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 — a vector, an {:and …} combinator, an {:id … :params …} map (per 005 §Guards). Surfaced at registration time |
:fix-registration — registration fails |
:guard (the offending value), :state, :slot |
:rf.error/machine-bad-action-form |
:error |
diagnostic | A machine's :entry, :exit or transition :action value is neither a keyword nor a fn — a vector of actions, an XState {:type …} object (per 005 §Actions). Surfaced at registration time |
:fix-registration — registration fails |
:action (the offending value), :state, :slot (:entry / :exit / :action) |
: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 | An :on clause, or a transition value in one, is not a form the grammar admits (per 005 §Transition table grammar). A clause is a map from event id to transition, or nil; a transition value is a target keyword, a vector path target, a transition map, a vector of transition maps, or nil. At registration re-frame.machines.lifecycle-fx.validation refuses an :on clause of any other shape, and an :on entry holding any other value, on the machine root, a parallel region body or any state node. At run time re-frame.machines.transition throws it when a macrostep reaches such an entry in a definition that never passed registration, e.g. through the pure machine-transition |
:fix-registration — fix the offending :on clause |
registration: :state, :slot (:on), :value (the offending clause or entry value), :event-id for an entry, and :region on a region body; run time: :value, :slot (the category id) |
:rf.error/machine-bad-always |
:error |
diagnostic | A state node's :always value is not a transition form — a target keyword, a vector path target, a transition map, or a vector of transition maps; nil is no :always (per 005 §Eventless :always transitions). It resolves through the same shared grammar :on / :after use. At registration re-frame.machines.lifecycle-fx.validation refuses it on any state node. At run time re-frame.machines.transition throws it, rather than an uncategorised host throw, at the first macrostep that walks such an :always in a definition that never passed registration, e.g. through the pure machine-transition; that macrostep fails atomically and the snapshot is not committed |
:fix-registration — fix the offending :always |
registration: :state, :slot (:always), :value (the offending value); run time: :value, :slot (the category id) |
:rf.error/machine-bad-raise |
:error |
diagnostic | An action's :fx carried a :raise entry that is not exactly [:raise <event-vec>] — typically XState's raise(event, {delay, id}) spelling [:raise <event-vec> {:delay … :id …}]. A raise takes no options; a delayed event is :after on a state (per 005 §:raise, :rf.machine/spawn, and :rf.machine/destroy are reserved fx-ids inside :fx). The entry is an action's output, so no registration check can see it: the flat and parallel drains throw it where they route the action's fx, at the first macrostep that runs the action |
:no-recovery — the macrostep fails; the snapshot is not committed |
:value (the offending entry), :slot (:raise) |
:rf.error/machine-action-wrote-db |
:error |
diagnostic | A machine action's effect map 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). The pure machine-transition throws it for a snapshot whose :state names no declared state — for a parallel machine, a region whose value names no state of that region. Per 005. (Older drafts spelled this :rf.warning/machine-state-not-in-definition; the :rf.error/ form is canonical) |
:reset-to-initial — at the machine's event handler, a stored snapshot naming an undeclared state is replaced by a fresh initial snapshot (a spawned actor's identity carried across); :no-recovery — the pure machine-transition, which holds no stored snapshot to replace, throws |
:machine-id, :state, :region (a parallel region's refusal, whose :state is that region's value) |
: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) |
:reset-to-initial — the stored snapshot is replaced by a fresh initial snapshot (a spawned actor's identity carried across), and the initial state's :entry fires |
: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). A guarded internal :always with no :target is permitted (the action-microstep pattern); an unguarded one is :rf.error/machine-always-unguarded-targetless. Per 005 §Self-loop forbidden at registration. Registration is rejected |
:fix-registration — registration is rejected |
:state (the declaring state-keyword), :machine-id |
:rf.error/machine-always-unguarded-targetless |
:error |
diagnostic | An :always entry declares neither a :guard nor a :target. It is enabled on every settle and changes no state, so the microstep loop would re-run it until :rf.error/machine-always-depth-exceeded aborts the macrostep. Run-once-on-entering is :entry; a fixed-point loop carries a :guard its action flips false. Per 005 §Self-loop forbidden at registration. Registration is rejected |
:fix-registration — registration is rejected |
:state (the declaring state-keyword), :entry (the offending :always entry) |
: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 |
:fix-registration — registration is rejected |
:machine-id, :state |
:rf.error/machine-bad-schemas |
:error |
diagnostic | A machine spec's :schemas is present but is NOT a map. The machine-level :schemas declaration must be a map of schema categories (e.g. {:data <schema>}). Surfaced at registration time. Per 005 §The :schemas map |
:fix-registration — registration is rejected |
:schemas (the offending value) |
:rf.error/machine-bad-schemas-key |
:error |
diagnostic | A machine spec's :schemas map carries a sub-key outside the closed accepted set #{:data :events :output :tags :meta} — including [:schemas :input] (state input is not adopted). Surfaced at registration time. Per 005 §The :schemas map |
:fix-registration — 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 |
:fix-registration — 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 |
:fix-registration — 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 |
:fix-registration — 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 |
:fix-registration — 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 |
:fix-registration — registration is rejected |
:machine-id, :state, :on-error (the offending value) |
:rf.error/machine-timeout-without-on-timeout |
:error |
diagnostic | A state-level or :spawn-level :timeout was declared with no :on-timeout — a timeout with no transition is meaningless. :timeout REQUIRES :on-timeout. Surfaced at registration time. Per 005 §:timeout / :on-timeout |
:fix-registration — registration is rejected; add :on-timeout or remove :timeout |
:state, :site (:state / :spawn), :timeout (the offending duration) |
:rf.error/machine-on-timeout-without-timeout |
:error |
diagnostic | A state-level or :spawn-level :on-timeout was declared with no :timeout — the transition has no deadline to fire it. Symmetric with :rf.error/machine-timeout-without-on-timeout. Surfaced at registration time. Per 005 §:timeout / :on-timeout |
:fix-registration — registration is rejected; add :timeout or remove :on-timeout |
:state, :site (:state / :spawn), :on-timeout (the offending transition) |
:rf.error/machine-bad-timeout-duration |
:error |
diagnostic | A state-level or :spawn-level :timeout duration is neither a POSITIVE INTEGER (literal ms) nor a valid ISO-8601 duration string ("PT5S", "PT2M", …). The XState "5s" / "10ms" readable shorthand is REJECTED (a deliberate divergence from XState), as are a non-positive integer, a fn, a vector, and a malformed ISO string. Surfaced at registration time. Per 005 §:timeout / :on-timeout |
:fix-registration — registration is rejected; use a positive-integer ms or an ISO-8601 duration string |
:state, :site (:state / :spawn), :timeout (the offending duration) |
:rf.error/machine-timeout-after-collision |
:error |
diagnostic | A :timeout resolves to a millisecond value that is ALSO an explicit :after delay-key on the SAME state node. The two would collide when the timeout desugars onto :after, silently dropping one of the authored intents. Surfaced at registration time. Per 005 §:timeout / :on-timeout |
:fix-registration — registration is rejected; give the timeout a distinct duration, or fold it into the :after entry directly |
:state, :ms (the colliding ms), :after-keys (the node's existing :after delay-keys) |
:rf.error/machine-choice-missing-choice |
:error |
diagnostic | A :type :choice transient state declares no :choice candidate vector — a choice state MUST name the guarded candidates it routes among. Surfaced at registration time. Per 005 §:type :choice |
:fix-registration — registration is rejected; add a :choice [{:guard … :target …} … {:target <default>}] vector |
:state |
:rf.error/machine-choice-without-type |
:error |
diagnostic | A state declares a :choice candidate vector but is not a :type :choice state — a :choice slot is meaningful only on a transient choice state. Surfaced at registration time. Per 005 §:type :choice |
:fix-registration — registration is rejected; add :type :choice, or move the candidates to :always |
:state |
:rf.error/machine-bad-choice |
:error |
diagnostic | A :type :choice state's :choice value is not a declarative, NON-EMPTY vector of guarded-candidate maps. A function-valued :choice is REJECTED (a deliberate divergence from XState — 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 |
:fix-registration — registration is rejected; declare a [{:guard … :target …} … {:target <default>}] candidate vector |
:state, :choice (the offending value) |
:rf.error/machine-choice-extra-keys |
:error |
diagnostic | A :type :choice state also declares ordinary waiting-state behaviour — one or more of :entry / :exit / :on / :always / :after / :timeout / :on-timeout / :spawn / :spawn-all / :initial / :states / :final? / :output-key. A choice state ONLY routes its guarded candidates immediately on entry. Surfaced at registration time. Per 005 §:type :choice |
:fix-registration — registration is rejected; remove the extra keys, or use an ordinary state with :always |
:state, :extra-keys (the forbidden keys present) |
:rf.error/machine-choice-no-default |
:error |
diagnostic | A :type :choice state whose every :choice candidate is GUARDED — there is no unconditional default / else branch. If every guard fails the choice state has no candidate to take and is stuck (the static "no matching candidate + no default" rejection). Surfaced at registration time. Per 005 §:type :choice |
:fix-registration — registration is rejected; add a final unguarded candidate, e.g. {:target <fallback>} |
:state, :choice (the candidate vector) |
:rf.error/machine-choice-self-loop |
:error |
diagnostic | A :type :choice state declares a :choice candidate that targets its own declaring state — an immediate eventless self-loop (runs to depth-exceeded or is a no-op). Rejected exactly as an :always self-loop is. Surfaced at registration time. Per 005 §:type :choice |
:fix-registration — registration is rejected; target a distinct state |
:state |
:rf.error/machine-bad-internal-events |
:error |
diagnostic | A machine's :internal-events declaration is malformed — it must be a set of keywords (#{:tick :retry/internal}). A vector is the rejected XState array form (a deliberate 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. So is a member spelt like an :on-key wildcard (:change/*, :*): membership is exact, so it would fence only its literal self. Surfaced at registration time. Per 005 §Public / private :internal-events |
:fix-registration — registration is rejected; declare a #{…} set of keywords, enumerating each private event |
:internal-events (the offending value), :wildcards (the wildcard-spelt members, when that is the defect) |
:rf.error/machine-unknown-node-key |
:error |
diagnostic | A machine state node (root, descendant, or parallel-region root) declares an unknown bare key — one outside the closed :rf/state-node vocabulary (:on / :entry / :spawn / :always / :after / :tags / :meta / …). A bare unknown key reads as a typo (an XState-trained author's :invoke for :spawn, or :on-entry for :entry) and would be silently ignored — the spawn never fires, the action never runs — so it fails loud rather than swallowing (the no-silent-swallow policy; :meta is the sanctioned bare free slot, and a namespaced key is the open user-extension carve-out that passes). :type :history / :type :choice pseudo-states carry their OWN closed key-sets and are validated by :rf.error/machine-history-extra-keys / :rf.error/machine-choice-extra-keys instead. :on-done on a leaf (a node with neither :states nor :regions) is refused under this id too — it can never fire. So is :regions on a state that is not :type :parallel: the runtime runs :regions only on a :type :parallel machine root, and the message says so. So is a root-only key (:data / :schemas / :internal-events / :guards / :actions / :region-order, or the root's registration metadata such as :doc) on a nested state or a parallel-region body, where nothing reads it. A transition map in any slot (:on / :after / :always / :choice / :on-done / :on-timeout / :spawn :on-error / :spawn :on-timeout) is held to the same rule over its own closed vocabulary — :target / :reenter? / :guard / :action / :meta, plus the DEBUG-only :source-coords / :source-code — because a stray key there is inert too: :targt makes a targetless no-op, XState's :cond fires the transition unguarded, :actions drops the action, :reenter never re-enters. Surfaced at registration time. Per Conventions §Reserved state-node keys + §No silent swallow and Spec-Schemas §:rf/state-node |
:fix-registration — registration is rejected; fix the key (:invoke→:spawn, :on-entry→:entry, :cond→:guard, :actions→:action, :reenter→:reenter?), move a root-only key to the root, use :meta for tooling metadata, or namespace a user extension |
:state, :offending-keys (the bare keys present), :valid-keys (the accepted vocabulary), :slot (the transition slot, on a transition-map refusal) |
:rf.error/machine-parallel-region-order-required |
:error |
diagnostic | A :type :parallel machine with more than eight regions declared no explicit :region-order. A :regions map literal past the array-map threshold is a hash-map whose key iteration is hash order — authored declaration order is lost at read time and diverges between CLJ and CLJS — so the canonical region declaration order (which governs :data accumulation, :fx / cascade ordering, spawn allocation, initial-entry / destroy-exit order, root multi-target apply, and the finalization first-region tie-break) cannot be recovered from the map. Surfaced at registration time. Per 005 §Parallel regions and Spec-Schemas §:rf/state-node |
:fix-registration — registration is rejected; declare :region-order [<region> …] listing every region in declaration order (or keep the region count at/below the array-map threshold) |
:regions (the region keyset), :region-count |
:rf.error/machine-parallel-region-order-mismatch |
:error |
diagnostic | A :type :parallel machine's explicit :region-order is not an exact permutation of its :regions keyset — a missing, extra, or duplicate region. The order vector must list every declared region exactly once. Surfaced at registration time. Per 005 §Parallel regions and Spec-Schemas §:rf/state-node |
:fix-registration — registration is rejected; declare :region-order as a vector with one entry per region, no missing / extra / duplicate |
:region-order (the declared vector), :regions (the region keyset) |
:rf.error/machine-unknown-spawn-key |
:error |
diagnostic | A :spawn spec — or a :spawn-all child spec — declares an unknown bare key outside the closed spawn-spec vocabulary (:machine-id / :data / :id-prefix / :on-done / :on-error / :start / :fixed-actor-id / :timeout / :on-timeout, plus :id on a :spawn-all child; an inline :definition earns the pointed :rf.error/machine-spawn-bad-shape / :rf.error/machine-spawn-all-bad-shape instead). A bare typo (:machine for :machine-id, :on-complete for :on-done) would leave the spawn silently under-specified and mis-fire, so it fails loud (the no-silent-swallow policy); a namespaced key passes (the runtime stamps :rf/parent-id / :rf/invoke-id on declarative spawns, and user extensions may namespace). Surfaced at registration time. Per Conventions §Spawn-spec keys + §No silent swallow and Spec-Schemas §:rf/state-node |
:fix-registration — registration is rejected; fix the key or namespace a user extension |
:state, :where (:spawn / :spawn-all-child), :offending-keys, :valid-keys |
:rf.error/machine-bad-tags |
:error |
diagnostic | A state node's :tags slot is not a set of keywords (#{:loading :busy}). A vector or single keyword is never coerced to a set: :tags is a strict [:set :keyword], mirroring its sibling set-valued slot :internal-events (which hard-rejects exactly that shape). A non-set value, or a set with a non-keyword member, is rejected, and so is a member in a reserved framework namespace (:rf/*, :rf.*/*). Surfaced at registration time. Per 005 §State tags + Spec-Schemas §:rf/state-node |
:fix-registration — registration is rejected; declare a #{…} set of keywords outside the reserved namespaces |
:state, :tags (the offending value), :reserved (the reserved-namespace members, when that is the defect) |
:rf.error/machine-internal-event-reserved |
:error |
diagnostic | A machine declares a reserved :rf/* framework event in :internal-events — the synthetic creation marker :rf.machine/start, the :rf.machine/done completion signal, :rf.machine.timer/after-elapsed, the :rf.machine.spawn/* family, etc. A framework-owned lifecycle event is inherently public traffic threaded through the standard dispatch pipeline; it cannot be repurposed as a machine's private event (the public / private split). Surfaced at registration time. Per 005 §Public / private :internal-events and Conventions §The single-root reserved set |
:fix-registration — registration is rejected; declare a non-reserved internal event name |
:internal-events, :reserved (the reserved members) |
:rf.error/machine-internal-event-external-dispatch |
:error |
diagnostic | An external dispatch of a declared private :internal-event arrived at the machine dispatch boundary (rf/dispatch [:my-machine [:tick]] where :tick is in the machine's :internal-events). An internal event is machine plumbing reachable only via an internal :raise; an outside caller (a view, a test, another handler) must not drive the machine with it. The dispatch is refused — no state change reaches runtime-db (a benign no-op, the committed snapshot untouched), exactly the shape an unhandled event takes. Surfaced at dispatch time. Per 005 §Public / private :internal-events |
:no-recovery — the external dispatch is refused (no state change); raise the event internally via :raise, or expose a public :on clause under a different name |
:actor-id (the LIVE actor INSTANCE that received the refused dispatch; :machine-id reserved for the TYPE), :event (the refused inner event), :event-id (its head), :frame |
:rf.error/machine-cofx-requires-inline |
:error |
diagnostic | A :rf.cofx/requires recordable-coeffect declaration was found on an INLINE machine callback rather than on a NAMED :guards / :actions entry map (the {:rf.cofx/requires [...] :fn (fn [...] ...)} form) — e.g. placed directly on an :on / :always / :after / :on-done / :entry / :exit slot value, or on a :guards / :actions entry that is a map carrying :rf.cofx/requires but no :fn. A fact-consuming guard / action MUST be a named entry so the declared diet sits with the code that can be checked against it (the silent-nil hole consumer attachment closes). Surfaced at registration time by the pure consumer-attachment indexer. Per 005 §Consumer attachment — declaring requirements on named entries and EP-0017 §7. |
:no-recovery — registration is rejected; move the inline fn into :guards / :actions and declare :rf.cofx/requires on its entry |
:where (the declaring site), :offending (the offending entry / value) |
:rf.warning/machine-cofx-consume-undeclared |
:warning |
diagnostic | A recommended consumer-attachment lint (dev-only): a NAMED machine guard / action whose :fn source reads a registered recordable-coeffect leaf off the :rf.cofx record that the entry did NOT declare in its :rf.cofx/requires. The framework cannot ENSURE an undeclared fact, so the read silently binds nil (live) or whatever a fixture happened to supply (test). A source-form heuristic over the macro-captured :source-code — shallow (a recommendation, not a hard gate; the ensure-set still only ensures DECLARED facts, so an undeclared read is un-ensured, never wrong). DCE'd in production. Emitted by re-frame.machines.cofx-attach (machines/cofx_attach.cljc). Per 005 §Consumer attachment and EP-0017 §9. |
:warned-and-proceeded — the lint is advisory; declare the leaf in the entry's :rf.cofx/requires so the framework ensures it |
:machine-id, :slot (:guards / :actions), :entry-id, :rf.cofx/id (the undeclared leaf) |
:rf.warning/machine-cofx-ambient-durable |
:warning |
diagnostic | A recommended consumer-attachment lint (dev-only): a NAMED machine ACTION declared an AMBIENT-grade coeffect id in its :rf.cofx/requires. An action may write durable :data; folding an ambient (re-run-on-replay) read into durable state violates "durable state folds facts, never reads" — the value re-reads the host on replay rather than re-presenting the recorded fact. Mechanically checkable (the id's reg-cofx registers :recordable? false). DCE'd in production. Emitted by re-frame.machines.cofx-attach (machines/cofx_attach.cljc). Per 005 §Consumer attachment and EP-0017 §9. |
:warned-and-proceeded — the lint is advisory; drop the fact's :recordable? false (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 | The :spawn-all slot is not a map, or its :children is not a non-empty vector; a child invoke-spec is missing :id or :machine-id, or carries an inline :definition — a child is a registered machine TYPE, so the reason names the fix: register it with reg-machine and spawn it by :machine-id; :join is neither :all nor :any; the join-event slots are missing per the required-iff rules; or the block declares a bare key outside its closed set (:children / :join / :on-all-complete / :on-some-complete / :on-any-failed; a namespaced key passes) (005 §:spawn-all map keys). A child's own key set is closed too, and an unknown bare child key raises :rf.error/machine-unknown-spawn-key. Surfaced at registration time. Per 005 §Spawn-and-join via :spawn-all |
:fix-registration — registration is rejected |
:machine-id, :state, :reason |
:rf.error/machine-spawn-all-duplicate-id |
:error |
always-on | Two spawns collide on one actor address. The id covers ANY duplicate spawned-actor id, whichever form spawned it — a :spawn-all child or a single :spawn — despite the spawn-all in its name. Three surfacings, one category. (1) Registration — two child invoke-specs inside the same :spawn-all block share a logical :id keyword (each :id must be unique); surfaced at registration time by validate-spawn-all!, which throws (registration rejected). (2) Spawn-time, inside one :spawn-all — two distinct logical children whose spawn args resolve to the same actor address (a :fixed-actor-id literal shared by two children, or a fixed id colliding with a generated <type>#n) would silently overwrite one prepared entry / one live actor; :rf.machine/spawn-all-init's admission preflight detects the aliasing and rejects the whole invoke atomically before any live join, child effect, snapshot, or prepared scratch is published — it seeds the childless reject sentinel exactly like the unregistered-TYPE reject, and emits this diagnostic once naming the offending logical child ids and resolved addresses. Registration guards only logical :ids, so a resolved-address alias passes reg-machine and is caught here. :collisions is an ordered vector of pairs, not a map: the collision GROUPS ride in first-appearance order and each group's child ids in declaration order, so a developer reading the reject to find WHICH declarations collided sees them in the order they wrote them, identically on CLJ and CLJS. A map presentation would degrade past the 8-entry array-map threshold to host-dependent hash order. The same preflight also refuses the whole batch when a child's GENERATED <type>#<n> address is already held by a live actor OUTSIDE the batch — surfacing 3 with the batch boundary removed — so an admitted :spawn-all child never lands on a live occupant. (3) Spawn-time, outside :spawn-all entirely — a single :spawn whose generated <type>#<n> address is already held by a live actor. The declarative allocator's counter lives in the spawning parent's snapshot ([:rf/runtime :spawn-counter]) while the address space it allocates into is frame-global, so a parent destroyed and respawned at the same address begins counting from zero beside its own still-live orphans, and two parents spawning the same child TYPE each mint #1 — the address arrives at a live actor with no author involvement anywhere, which is why it is REJECTED where an occupied :fixed-actor-id the author NAMED is cleanly replaced and raises nothing. Installing over the occupant would be a birth and an unannounced death in one write (no :exit, no teardown, no :rf.machine/destroyed), so the spawn is rejected fail-closed: no snapshot, no spawn-order entry, no :rf.machine.spawn/spawned, no :start dispatch, and the occupant is left exactly as it was. Occupancy is snapshot presence — the same occupant-actor-live? probe the replacement path consults. The collision is DETECTED, not prevented: the counter is not re-homed, so a generated address is still not unique across incarnations; the loss is named rather than silent. Channel. A generated-address collision (surfacing 3, and surfacing 2's live-occupant arm) depends on the runtime population, so production is where it happens: it rides the always-on error-emit axis (surface #4) as a NON-EVENT union record through the :error-emit/dispatch-error-record hook, structural-only as :rf.error/machine-spawn-unregistered-type's is, beside the dev trace, which alone carries the prose :reason. The registration throw (surfacing 1) and the within-batch alias (surfacing 2) stay diagnostic; the Channel column is per-category, and this arm-level nuance rides the prose. Per 005 §Declarative :spawn. Emitted by re-frame.machines.lifecycle-fx.spawn (machines/lifecycle_fx/spawn.cljc). Per 005 §Spawn-and-join via :spawn-all |
:fix-registration — registration is rejected (surfacing 1); :no-recovery — the whole :spawn-all invoke is rejected fail-closed, spawning nothing (surfacing 2, either arm); the one colliding :spawn is rejected fail-closed and the occupant is left untouched (surfacing 3). For surfacing 1 and surfacing 2's alias arm, give each colliding child a distinct :fixed-actor-id, or drop the fixed id so the runtime allocates a unique generated address. For a generated address a live actor holds (surfacing 3, and surfacing 2's live-occupant arm) the runtime can do neither on the author's behalf — picking another address would break the deterministic <type>#<n> sequencing machine-transition's purity contract rests on, and destroying the occupant is reserved to the author by 005's Teardown is explicit in v1 rule — so give that spawn a distinct :fixed-actor-id, or destroy the occupant explicitly before spawning over it |
:state, :duplicate-ids (registration); :parent-id, :invoke-id, :collisions ([[<resolved-address> [<logical-child-id> …]] …] — an ordered vector of pairs in first-appearance order, never a map), :frame (:spawn-all preflight); :machine-id, :failing-id (the occupied generated address), :parent-id, :invoke-id (the :spawn-all invoke for a batch refusal), :frame (generated-address collision, on the always-on record and the dev trace alike; the dev trace adds :reason) |
: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 |
:fix-registration — 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 name a registered machine TYPE: it declares no :machine-id (nothing to instantiate), or carries an inline :definition. A child is a registered type, so its [:rf/runtime :machine-type] is a keyword and its snapshot stays EDN; the reason names the fix: register the child with reg-machine and spawn it by :machine-id. It also refuses a :spawn that is not one spec map — a vector of specs, XState's multi-invoke spelling (N children is :spawn-all) (005 §:spawn map keys). Surfaced at registration time; the same rule is enforced at the spawn effect for a hand-emitted [:rf.machine/spawn …], which no registration sees. Per 005 §Declarative :spawn and Spec-Schemas §:rf/state-node |
:fix-registration — registration is rejected; :no-recovery for a hand-emitted [:rf.machine/spawn …] refused at the spawn effect, which installs nothing |
: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. The spawn is rejected fail-closed — NO snapshot, NO spawned-id allocation, NO [:rf.runtime/machines :spawned …] slot, NO spawn-order record, NO :start (or synthetic [:rf.machine.spawn/spawned]) dispatch — there is no implicit "spec-less spawn" lifecycle. Distinct from the registration-time :rf.error/machine-spawn-bad-shape (which catches the no-id / both-id shape at reg-machine time): THIS is a runtime reject of a well-shaped spawn whose referenced TYPE is not registered (a load-order / platform-gated / typo'd id). Always-on: spawning an unregistered type is a production-reachable fail-closed boundary fact an off-box shipper on a goog.DEBUG=false build must still see, so it rides the always-on error-emit axis (surface #4) as a NON-EVENT union record via the :error-emit/dispatch-error-record late-bind hook (machines ships above core's require graph), ALONGSIDE the dev error trace (DCE'd in production) — the same always-on-plus-dev-trace shape :rf.error/write-after-destroy carries. For :spawn-all, an unregistered child TYPE rejects the whole invoke atomically: :rf.machine/spawn-all-init seeds a childless reject sentinel ({:rf/spawn-all-rejected? true}) at the join slot — physically present in runtime-db but carrying no :children, so it is a reject marker, NOT a live child-bearing join — and the registered siblings' per-child spawns read it and suppress themselves before installing, so a malformed set spawns nothing rather than orphaning the siblings under no live join. Because the sentinel is childless, no child is ever spawned to complete: a stray completion hits the join interceptor's childless-slot no-op so the :all join cannot deadlock, and parent-exit teardown clears the sentinel (per 005 §Spawn-and-join via :spawn-all §Errors). Payload is structural-only (no spawn args — :start / :data may hold application data). Emitted by re-frame.machines.lifecycle-fx.spawn (machines/lifecycle_fx/spawn.cljc). Per 005 §Spawning §Errors |
:no-recovery — the spawn is rejected fail-closed; register the machine (rf/reg-machine) before spawning it |
:machine-id (the unregistered TYPE), :frame, :reason |
: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: 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 |
:rf.error/machine-destroy-bad-arg |
:error |
diagnostic | A :rf.machine/destroy fx received an arg the runtime cannot honour — a map of the wrong shape, or a value that is neither a keyword nor a map; the actor is NOT torn down and NO :rf.machine/destroyed trace fires (fail-loud, no dishonest terminal). Two :causes (the map-form grammar is a discriminated union). :slot-shape-mismatch — a well-formed tracked / spawn-all form whose addressed [:rf.runtime/machines :spawned p i] slot holds the OTHER form's shape (the tracked single-:spawn form resolving a :spawn-all join-state MAP — consuming it as an actor id would clear the join slot and orphan every live child; or the spawn-all form resolving an actor-id KEYWORD). :unknown-shape — an arg matching NONE of the known destroy shapes, any non-keyword non-map value included (the keyword actor-id form, the tracked {:rf/parent-id :rf/invoke-id} :spawn exit-cascade form, or the :rf/spawn-all form); notably the forgery {:rf/actor-id … :rf/reason …}, which would mint a caller-chosen destroyed reason and thereby suppress the cancellation terminal of an in-progress actor at the public reserved-fx boundary. No destroy shape accepts a caller-selected reason. Sibling of the :rf.error/machine-spawn-all-bad-child-id join-child forgery guard. Emitted by re-frame.machines.lifecycle-fx.destroy (machines/lifecycle_fx/destroy.cljc, via machines/lifecycle_fx/traces.cljc). Per 005 §Spawn-and-join |
:no-teardown — the destroy fx is refused; no snapshot / timer / resource teardown runs and no :rf.machine/destroyed trace fires |
:cause (:slot-shape-mismatch / :unknown-shape), :arg (the offending fx arg), :frame |
:rf.warning/spawn-all-join-unsatisfiable |
:warning |
diagnostic | A :spawn-all join just became UNSATISFIABLE: enough children have FAILED, or been explicitly destroyed, that its success condition is unreachable — an :all join after any failure or cancellation, an :any join once every child failed or was cancelled — so the join would otherwise hang forever, silently. An explicitly destroyed child (the :cancelled tombstone) counts as a failed child. A one-shot dev-advisory fired, when the spec declares no :on-any-failed, at the change that FIRST makes the join unsatisfiable: a failure fold, or a keyword destroy of a child of an unresolved join (the join was satisfiable before it and did not resolve on it). With :on-any-failed declared, that failure or destroy resolves the join instead, and no advisory fires. 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 (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 :on-any-failed to resolve the join on a failed or destroyed child, or leave the :spawn-all state to end it |
:actor-id (the parent's live instance address), :invoke-id (the invocation path), :join (the join policy, :all / :any), :done (the resolved-done child-id set), :failed (the failed child-id set), :cancelled (the destroyed child-id set), :total (the child count), :frame, :reason |
:rf.error/machine-after-fn-threw |
:error |
diagnostic | A machine :after fn-form delay resolver threw while computing the delay ms. The exception is observable; the delay then resolves to nothing, so the timer is skipped and also reported as :rf.error/machine-bad-after-delay. Emitted by re-frame.machines.timer (machines/timer.cljc). Per 005 §Delayed :after transitions |
:skipped — the delay-fn throw is caught; no timer is armed, so the state waits for an event |
:exception, :actor-id (the owning actor instance; on a :sensitive machine the row's exception text redacts, per §Privacy), :frame |
:rf.error/machine-parallel-nested-not-supported |
:error |
diagnostic | A parallel region's own state-tree declares :type :parallel (nested parallel regions), or a state below a flat or compound root does (parallel is root-only). Not supported in v1. Surfaced at registration time. Per 005 §Parallel regions and the 005 §Capability matrix |
:fix-registration — registration is rejected |
:machine-id, :state |
:rf.error/machine-non-parallel-root-after-not-supported |
:error |
diagnostic | A non-parallel (flat / compound) machine root declares :after — hand-authored, or lowered from a root :timeout / :on-timeout — and is rejected: root-level :after scheduling + resolution is supported ONLY for a :type :parallel machine root (the root-owned, machine-lifetime deadline). On a flat / compound root the timer would register but NEVER schedule or fire — the birth-time scheduler runs only from the parallel initial cascade, and no resolver fires a flat root's empty decl-path ([]) :after — so the intended "whole-machine deadline" would silently never elapse. Checked on the DESUGARED machine, so a root :timeout is caught via its lowered :after form in the SAME check as a directly-authored root :after. A :type :parallel root's :after is unaffected (that IS the supported, scheduled, resolved feature). Surfaced at registration time. Emitted by re-frame.machines.lifecycle-fx.validation (machines/lifecycle_fx/validation.cljc). Per 005 §Root-level :after |
:fix-registration — the call throws; registration is rejected. Move the deadline onto the machine's :initial state's own :after / :timeout, or make the root :type :parallel |
:after (the offending root :after map) |
:rf.error/machine-root-slot-not-supported |
:error |
diagnostic | A machine root declares a state-node key that no runtime path reads on the root — :spawn-all, :always, :choice, :final?, :output-key, :error?, :deep? or :default-target on any root, or :on-done or :regions on a flat / compound root. The runtime runs :regions only under :type :parallel, so on any other root the message names that missing :type. The root is entered once at birth, never re-entered, and never a final state, so the key would register and do nothing. The root's honoured slots are unaffected: :entry / :exit / :tags, :spawn (one child that lives as long as the machine), :on, and a :type :parallel root's :after / :timeout and action-only :on-done. A flat / compound root's :after / :timeout keeps :rf.error/machine-non-parallel-root-after-not-supported. A parallel REGION BODY, the root of its region's tree, follows the same rule: it honours :entry / :exit / :tags and :on, and a region body declaring :spawn, :spawn-all, :always, :final?, :output-key, :error?, :deep?, :default-target or :regions raises this category too, with :path naming the region. Surfaced at registration time; a machine root's refusal comes before any other check reads the root. Emitted by re-frame.machines.lifecycle-fx.validation (machines/lifecycle_fx/validation.cljc). Per 005 §The machine root |
:fix-registration — the call throws; registration is rejected. Move the key onto a state: for one machine-lifetime child, declare :spawn on the root; for several, wrap the tree in one compound state and declare its :spawn-all there (on a :type :parallel machine, a single-state region); for a region-lifetime :spawn, wrap the region's states in one compound state; mark a top-level state :final?; route :initial into a state whose :always fires at birth; for :regions, declare :type :parallel on the root |
:offending-keys (the refused keys, sorted); :path ([:regions <region>], on a region-body refusal only) |
: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 (canonical region-declaration order — the stable, documented tiebreak, NEVER state-map iteration, which is hash order past eight regions; 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 (canonical region-declaration 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 |
: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 |
:fix-registration — 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 |
:fix-registration — 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) — or a pseudo-state its owning compound's :initial names declares no :default-target, since with nothing recorded the fallback to that :initial would name the pseudo-state itself. Surfaced at registration time by the pure validator. Per 005 §Pseudo-state constraints |
:fix-registration — registration is rejected |
:state (the history node's key), :feature :history, :default-target (the unresolvable value, or nil when none is declared) (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 |
:fix-registration — registration is rejected |
:state (the owning compound's key), :feature :history, :history-keys (the duplicate history-child keys) (see §History-error tag layering below) |
:rf.error/no-such-route |
:error |
diagnostic | A route-url call (or one of its callers) addressed a :route-id that is not in the routing registrar (per 012) |
:no-recovery — the call throws; the caller chooses how to surface the failure |
:route-id |
:rf.error/missing-route-param |
:error |
diagnostic | A route-url build-from-pattern call did not supply a value for a required path parameter (per 012 §URL building) |
:no-recovery — the call throws; the caller chooses how to surface the failure |
:param (the missing param keyword), :route-id |
:rf.error/route-url-non-edn-value |
:error |
diagnostic | A route-url call supplied a used path-param value or a (non-nil) query value that is not an admitted URL scalar — a host value outside the canonical-EDN identity domain (a function, atom / promise, raw JS object, DOM node, or non-portable number), OR an instant / host Date (a portable EDN identity, but not a round-trippable URL segment: its host str is host-divergent and match-url has no instant coercion vocabulary). re-frame2 fails closed at the URL-emission boundary rather than host-stringify the value into a fabricated or host-divergent route identity. The query-KEY side is already guarded by the canonical-order sort (:rf.error/non-edn-identity); this is the path-param + query-VALUE companion. Per EP-0012 §Canonical EDN identity (host str / object stringification / object identity MUST NOT invent a route identity) and Conventions §Canonical EDN identity. The narrower URL-scalar domain (over the general CEDN-1 domain that admits instants + composites) is documented at the route-url boundary. |
:no-recovery — the call throws BEFORE any URL string is built; the caller encodes the value as a portable EDN scalar (e.g. an ISO-8601 string for an instant) at the boundary first |
:route-id, :slot (:params / :query), :param (the offending key), :value, :rf.error/cause (the underlying :rf.error/non-edn-identity ex-data, for the host-value class) |
:rf.error/can-leave-non-boolean |
:error |
diagnostic | A route's :can-leave guard subscription returned a non-boolean value; the contract requires true (allow) or false (block). The runtime BLOCKS the navigation (fail-closed) and emits the loud diagnostic. Emitted by re-frame.routing.decisions (routing/decisions.cljc, closed contract). Per 012 §Navigation blocking |
:blocked-navigation — the navigation is blocked; fix the :can-leave sub to return a boolean ((boolean …) / (not …)) |
:route-id, :query, :value (the non-boolean return), :reason, :frame (the navigating frame, so the diagnostic enters that frame's epoch trace-events and obeys the frame trace-disable gate) |
:rf.error/can-enter-non-boolean |
:error |
diagnostic | A :can-enter guard subscription on the target route or one of its :parent-chain ancestors returned a non-boolean value; the contract requires true (allow) or false (block). The entry mirror of :rf.error/can-leave-non-boolean, sharing the same closed contract. The runtime DENIES the entry (fail-closed — terminal, per 012 §Entry is terminal) and emits the loud diagnostic. Emitted by re-frame.routing.decisions (routing/decisions.cljc, shared closed contract). Per 012 §Navigation blocking |
:blocked-navigation — the entry is blocked; fix the :can-enter sub to return a boolean ((boolean …) / (not …)) |
:route-id (the route whose guard returned it — the target or an ancestor), :query, :value (the non-boolean return), :reason, :frame (the navigating frame, so the diagnostic enters that frame's epoch trace-events and obeys the frame trace-disable gate) |
:rf.warning/can-leave-subs-artefact-missing |
:warning |
diagnostic | A route declared a :can-leave guard but the subscriptions artefact's subscribe-once hook is not bound (the subs feature is not on the classpath), so the guard cannot be evaluated. The navigation is ALLOWED (fail-open: a missing-artefact misconfiguration must not silently trap the user on a page). Emitted by re-frame.routing.decisions (routing/decisions.cljc). Per 012 §Navigation blocking |
:warned-and-allowed — the guard cannot run; navigation proceeds. The fix is to add the subscriptions artefact so the guard is evaluable |
:query, :frame (the navigating frame) |
:rf.error/navigate-bad-request |
:error |
diagnostic | A [:rf.route/navigate {request}] event carried a structurally-invalid request map. The always-on structural gate rejected it BEFORE any guard ran (slice unchanged, no push). :reason names the first violation: :bad-event-arity (the event vector is not exactly [:rf.route/navigate {request}] — e.g. a third positional opts element), :request-not-a-map (a non-map payload), :unknown-keys (a key outside the closed roster :to/:url/:params/:query/:fragment/:replace?/:scroll/:bypass-leave? — NAMESPACED keys included; the offending keys are reported in total canonical order so a heterogeneous EDN-key set never trips compare), :to-url-exclusive (both :to and :url), :url-excludes-address (:url beside :params/:query), :params-requires-destination (:params on an in-place request — path params require a :to/:url destination), :query-not-map (an in-place :query whose value is neither a map nor nil — the map itself, never its members), :bad-scroll (a present :scroll outside the closed scroll vocabulary — :top / :restore, false or nil — rejected before the navigation commits rather than failing inside the :rf.nav/scroll fx), :no-destination-or-change (empty map, pure-policy map, or no destination/in-place change), or :no-current-route (an in-place request before any current route exists). Emitted by re-frame.routing.url-change (routing/url_change.cljc) at the one navigation pipeline's structural gate, which only :rf.route/navigate can fail: the other doors build well-formed requests. Per 012 §Navigation is an event |
:no-recovery — navigation is rejected; the slice is unchanged (no push). Fix the request map's shape |
:where (:event), :reason, :keys (the offending keys), :frame (when scoped) |
:rf.error/prefetch-bad-address |
:error |
diagnostic | A [:rf.route/prefetch {address}] event carried an address prefetch will not plan against. TWO gates run, in this order, both BEFORE any planning (no ensures dispatched, NO summary trace, current route readiness untouched). (1) The always-on structural gate — the value is not a closed :rf/route-address: :reason is :request-not-a-map (a non-map payload), :unknown-keys (a key outside the closed address roster :to/:params/:query/:fragment — a raw :url escape, a policy :replace?/:scroll, or an edit :query-merge all reject here; NAMESPACED keys included; the offending keys are reported in total canonical order so a heterogeneous EDN-key set never trips compare), :missing-to (a missing or non-keyword :to), or :bad-address (a structurally-wrong address value — e.g. a non-map :params / :query, or a non-string :fragment). Prefetch accepts ONLY the named-address form; it never takes a raw :url. (2) The named-destination resolution gate — the address is well-formed but does not resolve against the route registry and its declared schemas, adjudicated by the SAME boundary route-url and the programmatic door use, so prefetch can only ever warm the registered, validated destination a full activation would (without it an unregistered :to would return no work after a success summary trace, and a registered /:id route with :id omitted would warm the WRONG resource identity). :reason is the bare name of that boundary's error id: :no-such-route, :missing-route-param, :route-url-validation, or :route-url-non-edn-value, with :unresolved-destination as the total fall-through — disjoint from the structural reasons above. This gate reports STRUCTURE only: the offending param key or slot under :keys plus the :route-id, never the offending value (the boundary's own ex-data embeds the raw params and an explainer that reproduces them, the carrier class the navigate door redacts at its emit site). Distinct from :rf.error/resource-route-plan (an address that DOES resolve, whose resource plan could not be built — that carries :plan-cause :prefetch, 016 §Route integration). Emitted by re-frame.routing.prefetch (routing/prefetch.cljc). Per 012 §Route-plan prefetch |
:no-recovery — the prefetch is rejected before planning; no ensures dispatched, current readiness unchanged. Fix the address to a closed :rf/route-address naming a registered destination whose params satisfy the route's schemas |
:where (:event), :reason, :keys (the offending keys / param / slot), :route-id (the requested destination, on a resolution rejection), :frame (when scoped) |
:rf.error/replan-bad-request |
:error |
diagnostic | A [:rf.route/replan-resources {request}] event was malformed, or was dispatched with no active route to replan. The payload is ONE closed map {:cause <edn>} whose :cause is REQUIRED and non-nil — the ordinary Spec 016 cause slot, which rides verbatim onto every ensure / adopt the replan dispatches, so silently defaulting it would defeat the command. The always-on structural gate runs BEFORE the slice is read and BEFORE any planning: :reason is :bad-event-arity (not a two-element event vector), :not-a-map (a non-map payload), :unknown-key (a key outside the closed roster #{:cause} — a route address :to rejects here: the command replans the ACTIVE route and takes no destination; the offending keys ride under :keys in total canonical order), or :missing-cause (absent or nil :cause); the handler's own fifth reason, :no-active-route, fires when the frame has no route slice (a silent no-op there is precisely the 2am mystery the command exists to remove). No ensures dispatched, NO planner row, the slice untouched. Distinct from :rf.error/resource-route-plan with :plan-cause :replan (a well-formed request on a live route whose plan could not be built — a committed failed replan, 016 §Route-plan replan — same-token reconciliation). Emitted by re-frame.routing.replan (routing/replan.cljc). Per 012 §Replanning the active route's resources |
:no-recovery — the replan is rejected before planning; nothing was ensured, current readiness unchanged. Fix the event to [:rf.route/replan-resources {:cause <non-nil edn>}], dispatched on a frame with an active route |
:where (:event), :reason, :keys (the offending keys; [] for the arity / map / slice reasons), :frame (when scoped) |
:rf.error/route-link-bad-prefetch |
:error |
diagnostic | A route-link's :prefetch link-behaviour control carried a PRESENT value other than :intent — an unsupported mode (:render, :viewport), a boolean, an explicit nil/false, or a typo. :intent is the only accepted value (there is no render mode, viewport mode, global default, or hover-delay knob), and an ABSENT :prefetch is the only way to be passive. Validated by routing's ONE shared prefetch check on BOTH hosts — reached from href-attrs (the rf/route-link CLJS render and the SSR shell), link-model (the :routing/link-model seam a view substrate's own route-link consumes, likewise), and prefetch-payload itself — so every link surface that reaches the check rejects it identically, server-side included. Installing the intent handlers is a separate obligation the rendering surface owns: re-frame.fresco/route-link validates but wires none in v0. Stripping the bad value instead would render a silently passive link. Emitted by re-frame.routing.link (routing/link.cljc). Per 012 §Linking from views |
:no-recovery — a caller bug at the render site; the render throws. Pass :prefetch :intent or omit the key |
:where (rf/route-link), :slot (:prefetch), :value (the rejected value), :accepted (:intent) |
:rf.error/invalid-route-classification |
:error |
diagnostic | A reg-route's :sensitive / :large projection-relative data-classification declaration is structurally malformed: a non-vector axis ({:sensitive :not-a-vector}), a non-sequential path entry ({:sensitive [:not-a-path]}), or a non-EDN-identity path segment (an opaque host object / fn — the latter surfaced verbatim as :rf.error/bad-path from re-frame.path/normalize-concrete, the fail-closed :rf/path boundary). Thrown at reg-route time (caller bug; dev and prod), BEFORE any state mutates and before the route can ever activate — the fail-loud-on-malformed posture (a FORGOTTEN classification is fail-open). Value-independent: the SHAPE of the projection-relative declaration is validated, never the runtime value. Egress applies the declarations to the current route slice (re-rooted under [:rf.runtime/routing :current …]), so they hold exactly while the route is current. Mirrors frame construction's :rf.error/bad-frame-classification. Thrown by re-frame.routing.classification (reached from re-frame.routing.registry/reg-route). Per 012 §Route data classification |
:fix-route-classification — supply a vector of valid projection-relative :rf/path vectors (e.g. {:sensitive [[:query :token]]}); the call throws until corrected |
:route-id, :axis (the offending :sensitive / :large key), :bad-path / :bad-value, :bad-segment, :rf.error/cause (the inner :rf.error/bad-path id when a segment is non-EDN) |
:rf.error/invalid-url-strategy |
:error |
diagnostic | A frame declared a custom :url-strategy (in its frame config) that is not a valid strategy map — a non-map value (including an explicit nil — presence semantics: only OMITTING the key selects the default), or a map missing a callable leg for a host-required key. On both hosts :encode and :decode must be callable fns; on CLJS the three browser legs :push! / :replace! / :install-listener! must be callable too (SSR never executes them, so JVM validation does not require them). Validated fail-loud at TWO seams: (1) the registration-time PREFLIGHT — re-frame.frame's construction engine runs the routing artefact's :preflight-config member with the final expanded config BEFORE any candidate-derived write, so a malformed declaration on FIRST registration leaves no frame record / registrar row / trace-policy state / listener / trace event / :initial-events effect, and a failed RE-registration preserves every previously committed value and emits no :rf.frame/re-registered; and (2) a DEV-ONLY consult tripwire (re-frame.routing.strategy/url-strategy-from-config, interop/debug-enabled?-gated and DCE'd in production builds). Because the preflight is the SOLE config-commit chokepoint into the frame store the consult points read, the four consult points (the URL-owner listener install reconcile-url-listener!, the route-link href render — re-run per render — and the :rf.nav/push-url / :rf.nav/replace-url fxs) are TRUSTED READS: they resolve an already-validated strategy verbatim and pay no per-consult re-validation. The dev tripwire re-checks only in development, so a future write-path bypass (a typo, a hand-rolled adapter, a hot-reload intermediate value that somehow reached the store) still throws with a canonical structured error rather than a raw host nil-function / TypeError at an arbitrary downstream call site. Extension keys are permitted and preserved; the unset/default branch skips validation (the shipped history-url-strategy is known-good). Thrown by re-frame.routing.strategy (routing/strategy.cljc). Per 012 §URL strategies |
:no-recovery — the registration / resolution throws; fix the declared :url-strategy so every host-required leg is a callable fn (or drop the key to use the default history strategy) |
:url-strategy (the offending value), :required (the host-required leg keys), :missing (the missing / non-callable legs; present on the missing-leg branch, absent on the non-map branch), :frame (the declaring frame id; present on the registration-preflight seam) |
:rf.error/app-schemas-bad-arg |
:error |
diagnostic | a schema read (app-schemas / app-schema-meta / app-schemas-digest) was given an explicit :frame that resolves to a non-keyword frame target (a string, a non-frame map, a vector) — OR reg-app-schemas was given a second argument that is neither an opts map nor a frame (a frame there is :rf.error/app-schema-frame-target) (per 010 §Schemas resolve through the frame's images) |
:no-recovery — the call throws |
:received (the offending value), :expected (the contract string) |
:rf.error/app-schema-frame-target |
:error |
diagnostic | reg-app-schema or reg-app-schemas was given a frame: a :frame key in reg-app-schema's metadata map or a bare frame value in that slot, or a frame-id, a frame value, or an opts map carrying :frame as reg-app-schemas' second argument. An app-db schema is a program registration that every frame resolving it validates against, so a frame target has no meaning, and ignoring it would install a program-wide schema where the author asked for a frame-local one. Thrown at the authoring boundary in dev AND prod, BEFORE anything registers. The message names the route: register the schema at the top level, and give a frame that validates differently a later image selecting a namespace that registers its schema (per 010 §Schemas resolve through the frame's images) |
:no-recovery — the call throws; the registration (or whole batch) is rejected and nothing is stored |
:received (the frame it was given), :path (the registration path, on the singular form), :rf.error/id |
:rf.error/app-schema-bad-path |
:error |
diagnostic | reg-app-schema / reg-app-schemas was called with a path that is not a get-in/assoc-in-shaped path — a non-sequential scalar such as a bare keyword (:n), string, number, nil, map, or set, rather than a sequential collection of keys (or [] for the root). Validated and thrown at registration time, BEFORE anything registers, 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/ssr, …) or the :rf.db/runtime container root. App schemas validate ONLY app-db ((get-in app-db path)) and skip a path app-db lacks, so a runtime path silently installs a contract over a value its validator never reads, one the author falsely believes guards runtime-db — a category error with no behaviour to soft-land and no legitimate caller, hence a hard reject (distinct from the SHAPE error :rf.error/app-schema-bad-path). Validated and thrown at registration time, BEFORE anything registers, so the bad path can never land; reg-app-schemas rejects the whole batch atomically. The runtime-db partition is framework-owned — the framework validates it (machine :snapshots refined per-machine from each machine's [:schemas :data]) and user code MUST NOT register schemas against it (per Conventions §Reserved runtime-db keys) — so the honest remedy is to drop the runtime path, NOT to call a (non-public, framework-owned) runtime-db registrar (per 010 §App schemas validate the app-db partition only and Conventions §Reserved runtime-db keys) |
:no-recovery — the call throws; the registration (or whole batch) is rejected and nothing is stored |
:received (the offending path), :reason (states the honest remedy — drop the runtime path; runtime-db is framework-owned — and does NOT direct the user at a non-public, framework-owned API), :rf.error/id |
:rf.error/app-schema-bad-metadata |
:error |
diagnostic | reg-app-schema was called in its 3-slot (path metadata schema) form with a middle metadata argument that is not a map. Per 001 §Registration grammar the schema is the positional value slot — (reg-app-schema [:user] UserSchema) (2-slot) / (reg-app-schema [:user] {:doc "The user."} UserSchema) (3-slot); the optional middle map carries only metadata (:doc, open :my/* keys; a frame there is :rf.error/app-schema-frame-target). Thrown at the authoring boundary in dev AND prod (a caller bug, not user input), BEFORE anything registers, so a mis-shaped registration can never land. The common slip is passing the schema where the metadata map goes (per 010 §App-db schemas) |
:no-recovery — the call throws; the registration is rejected and nothing is stored |
:path (the registration path), :received (the offending middle metadata arg), :rf.error/id |
:rf.error/app-schemas-bad-batch |
:error |
diagnostic | reg-app-schemas was called with a first argument that is not a {path -> schema} map — nil, a vector, a string, a seq of pairs, a set, etc. Validated and thrown at registration time, BEFORE any entry registers, 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) would iterate zero entries and return [] — indistinguishable from the documented {} no-op — so a boot/config/schema-loader bug passing nil would get a false green with schema enforcement silently disabled. The empty map {} is accepted (the documented no-op returning []) (per 010 §App-db schemas) |
:no-recovery — the call throws; the whole batch is rejected and nothing is stored |
:received (the offending value), :expected (the contract string), :rf.error/id |
:rf.error/unknown-preset |
:error |
diagnostic | The frame config's :preset value is not in the closed set #{:default :test :story} (per Spec-Schemas §:rf/preset-expansion) |
:no-recovery — the call throws; registration of the offending frame fails |
:preset (the offending value), :valid (the closed set) |
:rf.error/retired-registration-key |
:error |
diagnostic | A reg-* registration-metadata map, on any registrar that runs the shared classifier (reg-event / reg-sub / reg-fx / reg-cofx / reg-interceptor, and reg-resource / reg-mutation after their own spec checks), carries a re-frame v1 BARE key re-frame2 does not accept — the canonical case is :spec, spelled :schema in re-frame2 per MIGRATION §M-54. In v1 :spec silently carried the registration's payload schema, so swallowing it here would DISABLE that registration's validation — a soft-pass hiding bugs. Rejected loud in dev AND prod (mirrors re-frame.image's retired-image-key redirect), naming the canonical replacement, rather than silently swallowed. Per Conventions §No silent swallow. Emitted by re-frame.reg-meta/validate-registration-metadata! (reg_meta.cljc) |
:fix-registration — the call throws; the registration is rejected. Rename the key to its canonical replacement (:spec → :schema) |
:kind, :id, :retired-key, :replacement |
:rf.warning/unknown-registration-key |
:warning |
diagnostic | A reg-* registration-metadata map carries a BARE (unqualified) key outside the kind's recognised vocabulary (Spec-Schemas §:rf/registration-metadata + the per-kind refinement) — a likely typo of a real key. NAMESPACED extension keys (:myapp/*, :rf.cofx/requires, …) are the open-map carve-out and pass silently. The unknown bare key is retained on the stored meta but never read; the cascade continues safely, so this is a WARNING (not an error), per Conventions §No silent swallow. Dev-gated (DCE'd under :advanced + goog.DEBUG=false). Emitted by re-frame.reg-meta/validate-registration-metadata! (reg_meta.cljc) |
:warned — the registration succeeds; fix the typo or namespace the extension key |
:kind, :id, :unknown-keys, :known (the recognised bare vocabulary), :reason |
:rf.warning/unknown-frame-config-key |
:warning |
diagnostic | A make-frame config carries a BARE (unqualified) key outside the recognised frame-config vocabulary — the keys core reads (Spec-Schemas §:rf/frame-meta) plus the bare keys each LOADED optional artefact names in its lifecycle contribution's :config-keys (routing :url-bound? / :url-strategy, flows :flows, HTTP :http-interceptors, resources :revalidate-on, SSR :ssr). It is a likely typo (:fx-overides for :fx-overrides), or a key whose artefact is not loaded. NAMESPACED keys are the open extension surface and pass silently. The key is stored on the frame config but never read and construction continues, so this is a WARNING, emitted once per make-frame call and naming every such key, per Conventions §No silent swallow. It runs after the config's refusals, so a :sensitive / :large key or a key whose artefact must be present keeps its own error; :on-create and :initial-db are not frame keys and get this warning. Dev-gated (DCE'd under :advanced + goog.DEBUG=false, vocabulary included). Emitted by re-frame.frame/upsert-frame! (frame.cljc) |
:warned — the frame is created or re-registered; fix the typo, namespace the extension key, or load the artefact that owns it |
:frame, :unknown-keys, :known (the recognised bare vocabulary), :reason |
:rf.error/adapter-already-installed |
:error |
diagnostic | A second install-adapter! call — or an (rf/init! …) with a DIFFERENT adapter — was made without an intervening destroy-adapter! (per 006 §Single adapter per process). init! is idempotent for the seated adapter (same canonical :rf.adapter/* :kind, or the identical custom map), so a hot-reload re-call is a no-op and does not reach here |
:no-recovery — the call throws; the existing adapter remains installed. To swap substrates, destroy-adapter! first |
: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. Surfaced as a thrown ex-info, not a trace |
:no-recovery — the call throws |
:where ('init!), :received (when nil/keyword/non-map), :expected, :reason |
:rf.error/render-on-headless-adapter |
:error |
diagnostic | render was called on the plain-atom (JVM/SSR) adapter, which only supports render-to-string (per 006) |
:no-recovery — the call throws; user should use render-to-string on this adapter |
:reason |
:rf.error/hiccup-on-element-render-slot |
:error |
diagnostic | render on a React-hook (element-shaped) adapter received CLJS data — a hiccup vector / seq / map — where a React ELEMENT is required (per 006; hiccup mounts only on the ratom-family substrates). Thrown by re-frame.substrate.spine/make-render BEFORE any root is created, replacing React's cryptic per-child "Objects are not valid as a React child" spray with one structured diagnostic; enforcing on every build (a programmer error, not user input). The ex-data carries an EP-0015-safe shape summary of the tree, never the raw tree |
:no-recovery — the call throws; nothing is mounted; build the tree with the substrate's element macro (e.g. UIx $) |
:reason, :render-tree/summary |
:rf.error/derived-container-replaced |
:error |
diagnostic | replace-container! was called on a derived container (a make-derived-value result). Derived containers are read-only — there is no slot to write into — so the core's replace-container! choke point both emits this trace AND throws the canonical ex-info (per §The thrown-error shape). Per 006 §make-derived-value |
:no-recovery — the call throws; the adapter replace-container! is not invoked. Write to the source container(s) instead |
:reason; thrown ex-data also carries :rf.error/id, :where ('rf/replace-container!) |
:rf.error/no-hiccup-emitter-bound |
:error |
diagnostic | render-to-string was called before the SSR namespace bound the hiccup emitter via set-hiccup-emitter! (per 011) |
:no-recovery — the call throws; SSR namespace must be required so set-hiccup-emitter! runs |
:reason, :render-tree |
:rf.error/frame-context-corrupted |
:error |
diagnostic | A function-component frame-id read (_currentValue on the shared React context) observed a value coerce-context-value cannot resolve to a frame keyword AND is not the no-provider sentinel — false, a number, an empty string, or a JS object. Real-world triggers: a subtree rendered through an unwrapped portal, a Provider authored with a non-keyword :value, or a library mutating _currentValue externally. The runtime emits the diagnostic and returns nil (NOT a synthesised :rf/default — per the carried invariant, 002 §Frame target resolution); a subsequent public frame-scoped operation reading that nil reports the always-on :rf.error/no-frame-context. Emitted by re-frame.adapter.context/function-component-current-frame (adapter/context.cljs). Per 006 §Frame-provider via React context and 002 §React context reader |
:no-frame-context — the corrupted context is reported as its own distinct category and the resolution chain returns nil (it is NOT folded into ordinary 'no scope', and NOT replaced with a synthesised :rf/default); the public frame-scoped op then fails loudly with :rf.error/no-frame-context |
:received (the offending value), :type (a short keyword tag — :nil / :boolean / :number / :string / :empty-string / :keyword / :symbol / :map / :vector / :sequential / :collection / :fn / :js-object), :reason |
:rf.error/flow-cycle |
:error |
diagnostic | The flows a frame runs close a cycle in the flow-dependency graph (per 013 §Topological sort and cycle detection); a flow whose :inputs overlap its own :output-path is a one-flow cycle. Thrown by re-frame.flows.topo at activation (:where 'rf/make-frame): the frame is not constructed and no frame state is written. A re-registration that completes a cycle in a running frame's active set throws it from that frame's next flow pass instead (:where 're-frame.flows/run-flows-on-db), where it aborts the event as :rf.error/flow-eval-exception |
:fix-registration — break the cycle, or stop naming one of its flows in the frame's :flows |
:cycle (the offending flow ids, closing-repeat: [:a :b :a]) |
:rf.error/flow-missing-id |
:error |
diagnostic | A reg-flow call's flow map omitted :id (per 013) |
:fix-registration — 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) |
:fix-registration — 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) |
:fix-registration — 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 :derive fn — the third (value) slot of (reg-flow flow-id metadata derive-fn) — was not a fn (per 013) |
:fix-registration — flow registration is rejected |
:flow, :reason |
:rf.error/flow-bad-path |
:error |
diagnostic | A reg-flow call's :output-path was not a vector, was empty (an empty :output-path would make the flow a prerequisite of every other flow), or carried an element that is not a path segment (per 013) |
:fix-registration — flow registration is rejected |
:flow, :reason, :bad-elements? (vector of the offending path elements when the failure mode was a non-segment element — a value outside the shared EP-0012 segment domain of keyword / string / symbol / boolean / integer / UUID / instant / nil; omitted when :output-path itself was not a vector or was empty) |
:rf.error/flow-reserved-output-path |
:error |
diagnostic | A reg-flow call's flow :output-path was well-formed (a non-empty vector of valid segments) but rooted at the reserved runtime-db partition key :rf.db/runtime. That leading key is reserved for the INPUT side (a runtime-input? flow input opts into the runtime-db partition); a flow OUTPUT is always an app-db write (evaluate-flow! assoc-ins the derived value into the app-db partition), so a :rf.db/runtime-rooted output would write the reserved partition key INSIDE app-db — a namespace-squat / spurious-topo-edge / false-cycle footgun. Fires AFTER the :rf.error/flow-bad-path shape rules confirm a non-empty vector of valid segments, so it is a DISTINCT discriminator from that shape family (the path is well-formed; it just names a reserved partition). The reservation is positional — :rf.db/runtime is legal DEEPER in an :output-path, where it names an ordinary app-db key. Per 013 |
:fix-registration — flow registration is rejected; re-root the :output-path at an ordinary (non-:rf.db/runtime) app-db key |
:flow, :reason, :bad-elements (the offending leading segment, [:rf.db/runtime]) |
:rf.error/flow-bad-marks |
:error |
diagnostic | A reg-flow call's flow carried a malformed output data-classification key — a non-vector :sensitive / :large, a subpath entry within one that is not a vector of path segments, a non-boolean :large?, the boolean :sensitive? spelling (classify the whole output with :sensitive [[]] instead), or an :rf.egress/output-sensitivity key (per 013 / 015 §Registration-owned transient classification). A flow's :sensitive / :large classify the flow's OWN output subpaths only; a flow does not inherit its inputs' classification, and there is no :rf.egress/output-sensitivity declassification key or enum (a sensitive flow output is just a classified db path). Flow output classification is a fail-closed safety surface — a malformed declaration is rejected at registration before any flow state mutates, never silently dropped. Distinct from :rf.error/bad-classification (reg-* classification metadata and reg-classification) so consumers route the faults apart |
:fix-registration — 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); :use (:sensitive) on the :sensitive? spelling |
:rf.error/flows-artefact-missing |
:error |
diagnostic | A flow API (reg-flow, (rf/clear :flow id)) was called, or a frame was made whose :flows names any flow, 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; a frame naming flows is refused before any frame state is written, because nothing would evaluate them |
:no-recovery — the call throws an ex-info; user adds day8/re-frame2-flows to deps |
:where (the calling fn), :reason, :flow-id (clear), :flows (frame construction) |
:rf.error/ssr-artefact-missing |
:error |
diagnostic | An SSR API (render-to-string, reg-error-projector, project-error) was called but the optional day8/re-frame2-ssr artefact is not on the classpath. Surfaced as a thrown ex-info, not a trace |
:no-recovery — the call throws an ex-info; user adds day8/re-frame2-ssr to deps |
:where (the calling fn), :reason |
:rf.error/routing-artefact-missing |
:error |
diagnostic | A routing façade export (reg-route, route-link) or another re-frame.core-routing late-bind wrapper was called — OR a frame config declared :url-strategy or :url-bound? true at construction (frame construction fails loud when the routing artefact is not loaded, rather than storing a declaration nobody can check) — but the optional day8/re-frame2-routing artefact is not on the classpath / not yet required. Surfaced as a thrown ex-info, not a trace |
:no-recovery — the call throws an ex-info; user adds day8/re-frame2-routing to deps and requires re-frame.routing at app boot (before frame construction, for the frame-config case) |
:where (the calling fn), :reason, :frame (the declaring frame id; present on the frame-config preflight case) |
:rf.error/schemas-artefact-missing |
:error |
diagnostic | A schemas registration API (reg-app-schema / reg-app-schemas) was called but the optional day8/re-frame2-schemas artefact is not on the classpath. Surfaced as a thrown ex-info, not a trace. :rf.http/managed raises it too, at dispatch and before anything is sent, for a :decode schema it cannot run: any schema while Malli is not loaded, or one declaring a :sensitive? / :large? slot mark while the schema walker is unbound (per 014 §JSON decoder hardening). The two schema checks a release build keeps raise it at dispatch, in every build, so neither passes a value it could not check: the step-1 check of a :boundary? true handler, before the handler runs, and a recordable coeffect's reg-cofx :schema check, as the coeffect is satisfied, so the injection fails and the handler does not run (per 010 §Production builds). A nil :validate installed through set-schema-fns! is not this case: the artefact is loaded and both checks pass. The read-side schema queries do not raise it: with the artefact absent they answer nil or {} |
:no-recovery — the call throws an ex-info; user adds day8/re-frame2-schemas to deps |
:where (the calling fn, or at dispatch the registration that declared the check: rf/reg-event or rf/reg-cofx), :path (reg-app-schema), :failing-id and :frame (at dispatch), :rf.cofx/id (the coeffect check), :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 / resource-state / reg-resource-scope / resolve-resource-scope, the mutation surface reg-mutation / mutation-state, or rf/clear of a :resource, :mutation or :resource-scope) was called, or a frame config declaring :revalidate-on was registered, 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; rf/clear for a clear), :resource-id / :mutation-id / :scope-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-elided |
:error |
diagnostic | rf/project-egress was handed a record stamped :kind :rf/epoch-record in a build that elides epoch (interop/debug-enabled? false), so no per-kind projector is installed. The door raises naming the kind rather than falling back to the kindless whole-value walk, which starts at :path [] and so would ship the record's app-db slots RAW (per Spec-Schemas §:rf/epoch-record). It raises rather than returning a sentinel like the read and write epoch surfaces (epoch-history / restore-epoch! / replace-frame-state!, which return [] / false / false in an elided build), because a projection that returned a sentinel would be a lie rather than a degrade. Surfaced as a thrown ex-info, not a trace |
:no-recovery — the call throws an ex-info; an elided build records no epochs, so the record came from elsewhere and is projected where epoch is live |
:where (rf/project-egress), :reason, :kind |
: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. The Fresco test kit's re-frame.fresco.test.mounted/hydrate! raises the same id when its adoption wait exhausts budget-ms without the root's adoption window shutting, rejecting its promise. Per 008 §poll-until |
:no-recovery — the deadline elapsed; the throw propagates (JVM) / rejects the promise (CLJS) |
:where ('rf/poll-until, or 're-frame.fresco.test.mounted for hydrate!), :elapsed-ms, :label, :reason; hydrate! adds :frame and :budget-ms |
:rf.warning/route-shadowed-by-equal-score |
:warning |
diagnostic | A reg-route registered a pattern whose structural :rf/route-rank tuple (rules 1-5) equals an already-registered pattern's and the two patterns are co-matchable — some URL matches both (the rule-6 "same URL family", decided by language intersection over the patterns' segment automata; equal rank alone MUST NOT warn — rank tuples ignore literal text, so /x/:id and /y/:slug tie structurally yet never co-match). The EARLIER registration wins the rule-6 tiebreak at match time (stable sort), so the NEW route is the shadowed one (per Spec-Schemas §:rf/route-rank and 012 §Route ranking algorithm) |
:warned-and-replaced — the new route registers but the existing equal-score winner beats it wherever both match |
:route-id (the new, shadowed id), :shadowed-by (the existing winner it can never beat), :rank (the tied rules-1-5 structural tuple) |
:rf.warning/no-not-found-route |
:warning |
diagnostic | An unmatched URL arrived but no :rf.route/not-found route was registered. Emitted by the navigation, URL-driven or {:url …}, on every such URL. There is no built-in placeholder view: the root view's dispatch on :rf.route/id decides what renders. Per 012 §Route-not-found |
:warned-and-replaced — the slice still commits :rf.route/not-found with the requested URL in :params; the warning surfaces the missing registration |
:url, :frame |
:rf.warning/route-classification-query-key-unpromoted |
:warning |
diagnostic | A reg-route declared a :sensitive / :large [:query k] classification path for a query key k the route does NOT promote to a keyword via :query / :query-defaults. An unpromoted query key stays a STRING in the route slice, so the keyword classification path never matches it and the value SILENTLY ships raw at egress (fail-open — the hygiene bargain, not a security boundary). An authoring footgun, not a contract break, so it WARNS, never throws. A no-op when the route declares no classification, names no [:query k] path, or promotes every classified query key. Emitted by re-frame.routing.classification/advise-query-promotion! (routing/classification.cljc). Per 012 §Route data classification |
:warned-and-recovered — the route still registers; the advisory names the unpromoted key(s) and the fix (add the key to the route's :query schema so the slice carries the keyword key the path targets) |
:route-id, :query-keys (the classified-but-unpromoted query keys), :promoted-keys (the route's declared query vocabulary), :advice (the fix sentence) |
:rf.http/cljs-only-key-ignored-on-jvm |
:warning |
diagnostic | A managed-HTTP request supplied a CLJS-only key — one of the six the JVM transport cannot honour (:mode, :cache, :referrer, :integrity, :credentials on the :request map, plus the top-level :abort-signal) — that is silently no-op on the JVM. One trace fires per occurrence. Per 014 §JVM transport — degraded behaviour for CLJS-only options |
:ignored — the unsupported key is dropped; the request proceeds with the remaining keys |
:key, :url, :sensitive? (the URL is redacted on the trace surface when the request is sensitive — per 014 §Privacy) |
:rf.http/binary-decode-degraded-on-jvm |
:warning |
diagnostic | An explicit binary :decode — :blob, :array-buffer, or :form-data — is HONOURED on the JVM (jvm-fetch reads ofByteArray and rides the raw bytes), but the returned value is a byte[], NOT the native browser Blob / ArrayBuffer / FormData object the CLJS Fetch path yields. That host-shape difference is a degradation worth surfacing: a caller asking for a Blob gets bytes and would otherwise never learn. One trace fires per occurrence. Distinct from :rf.http/cljs-only-key-ignored-on-jvm — the decode is honoured, not ignored. Only an EXPLICIT binary :decode is flagged (:auto resolves to :blob from the response Content-Type, unknown at dispatch time). Per 014 §JVM transport — degraded behaviour for CLJS-only options |
:ignored — informational; the decode proceeds and yields a byte[], the request completes normally |
:decode (the requested binary decoder), :url, :sensitive? (the URL is redacted on the trace surface when the request is sensitive — per 014 §Privacy) |
:rf.http/issued |
:info |
diagnostic | A managed-HTTP request was issued — one row per issuance, emitted inside the issuing fx handler as soon as the issuance number is allocated, so it carries the issuing run's :rf.trace/dispatch-id and precedes every row the issuance causes (the stale suppression of a superseded predecessor, a synchronous body-preparation failure, the fx's own :rf.fx/handled). Its :rf.reply/work-id is the attempt-1 work id; each completion row carries the work id of the attempt that completed, so after a retry the two differ in the attempt component, and issuance joins to completion on the two-element issuance prefix [:rf.work/http issuance] within the frame — never on full work-id equality. A resource read's :request-id is [:rf.req frame-id [:rf.work/resource resource-id generation]], so the row names the attempt and never its scope or params. Per 014 §Successful-response metadata |
n/a — informational lifecycle trace | :rf.reply/work-id, :rf.reply/work-kind (:http), :request-id (nil when anonymous), :url (query values redacted under :sensitive?), :method, :frame, :reply-to (the reply target's event-id per branch) |
:rf.http/retry-attempt |
:info |
diagnostic | A managed-HTTP retry-lifecycle marker, emitted in two arms discriminated by :next-backoff-ms. Intermediate (:next-backoff-ms non-nil) — an attempt failed with a retryable category and the runtime is ACTUALLY retrying: the trace fires when the next attempt starts, not merely when a retry is scheduled, so a request cancelled during its backoff window never produces one. Terminal retry-sequence stop marker (:next-backoff-ms nil) — the retry sequence has ended and nothing further is scheduled. It is not always "exhaustion": it fires in either of two cases — the retry budget was spent (the last permitted attempt also failed with a retryable category), or a later attempt (2+) failed with a category outside :retry :on, so the sequence stopped before the budget was spent (it is NOT emitted for a non-retried, non-eligible terminal failure). Per 014 §Retry and backoff |
:retried (intermediate — the runtime actually retried; emitted when the next attempt starts, :next-backoff-ms non-nil, so it is never stamped on a retry cancelled in its backoff window; the consumer sees the trace and the eventual final outcome via :on-failure / :on-success) / :no-recovery (terminal stop marker — the retry sequence ended (budget spent, or a later failure fell outside :retry :on), :next-backoff-ms nil, no further attempt occurs; the honest disposition, since stamping :retried on the marker would claim a retry that never ran) |
:request-id, :url, :attempt, :max-attempts, :failure (a :rf.http/* failure-category map), :next-backoff-ms (non-nil on the intermediate arm, nil on the terminal stop marker) |
:rf.http/aborted-on-actor-destroy |
:info |
diagnostic | A managed-HTTP request was aborted because the spawned state-machine actor that issued it was destroyed (parent state exit, parent's :after firing, :spawn-all cancel-on-decision, frame destroy, or imperative [:rf.machine/destroy]). The reply lands as a standard :rf.http/aborted failure with :reason :actor-destroyed. Per 014 §Abort on actor destroy and 005 §Cancellation cascade — in-flight :rf.http/managed aborts |
n/a — informational lifecycle trace | :request-id (when set), :actor-id (the destroyed spawned-actor address), :url |
:rf.http/aborted |
:info |
diagnostic | A managed-HTTP request was aborted (:user, :actor-destroyed, :request-id-superseded, :epoch-restored, :frame-destroyed, or :resource-superseded — a Resources refetch superseding its own in-flight fetch). :info for EVERY reason: the failure KIND picks the severity, never the :reason. Each reason names a deliberate act of the program or the operator, and the reply protocol delivers an abort as :status :cancelled, never :status :error, so an abort is activity rather than an issue; a real failure travels as one of the other :rf.http/* kinds, which stay :error. A timeout is never an abort reason — it is the :rf.http/timeout kind. The abort is surfaced on the dev trace bus (URL redacted when the request is sensitive); the :request-id-superseded reason additionally suppresses the failure reply (supersede semantics), the :epoch-restored reason suppresses it (epoch restore unwound the timeline), the :frame-destroyed reason suppresses it (the request's owning frame was destroyed, so a live cancellation reply into the dead frame is invalid), and an :actor-destroyed abort whose reply target is OBSOLETE (it addresses the destroyed actor itself) suppresses the failure reply as a :rf.http/stale-suppressed outcome (see that row), while the other reasons dispatch the failure reply normally. :frame-destroyed is the frame-teardown sibling of :epoch-restored — both are members of re-frame.http.transport's reply-suppressing-abort-reasons set and are emitted by re-frame.http.registry (abort-in-flight-on-frame-destroyed! / abort-in-flight-for-frame!); on frame destroy it catches every managed request the frame still has in flight, a Resources-owned one included (actor-owned work already took the more specific :actor-destroyed path). Distinct from the :rf.http/aborted FAILURE-CATEGORY map that rides :on-failure (this catalogue row is the trace emit). Emitted by re-frame.http.transport (http/transport.cljc) from dispatch-aborted!, which only abort! reaches, once the abort has committed. Whichever of the request's completion and its abort commits first decides its one outcome, so an abort that arrives after a completion committed finds the request done and emits nothing. Per 014 §Abort |
:no-recovery — the request is cancelled; an abort with a still-meaningful target dispatches the failure reply, supersede + epoch-restore + frame-destroy + obsolete-actor-target suppress it |
:kind (:rf.http/aborted), :request-id, :reason, :actor-id, :url |
:rf.http/stale-suppressed |
:info |
diagnostic | A managed-HTTP request's app reply was SUPPRESSED and recorded the uniform reply-envelope way — a canonical :status :stale / :rf.reply/work-status :suppressed row carrying the suppressed attempt's :rf.reply/work-id (and, for supersession, the current/superseding work-id inside the :rf.reply/current correlation gate). Four triggers: (1) SUPERSESSION — a fresh request with the same :request-id replaced this one; carries :rf.reply/stale-reason :rf.http/request-id-superseded, :recovery :superseded-by-fresh-request, and both the carried (superseded) + current (superseding) work-id inside :rf.reply/carried / :rf.reply/current (the two =-distinct because each request takes its own number from the frame's issuance counter); complements the :rf.http/aborted :reason :request-id-superseded trace; emitted by re-frame.http.transport. (2) ACTOR-DESTROY OBSOLETE TARGET — an :actor-destroyed abort whose reply target addresses the destroyed actor itself (the machine-shape wrapper's [self-id [:rf.http/failed]] default) is obsolete; carries :rf.reply/stale-reason :rf.http/actor-destroyed-target-obsolete, :recovery :actor-destroyed-target-obsolete, and a carried work-id with no current successor (:rf.reply/current nil); the :rf.http/aborted trace still fires alongside. (3) EPOCH RESTORE — epoch restore unwound the request's timeline; carries the generic :rf.reply/stale-reason :rf.http/request-id-superseded, :recovery :suppressed-on-epoch-restore and a carried work-id with no current successor (restore replaces the attempt with nothing); emitted by re-frame.http.registry's abort-in-flight-for-frame!. (4) FRAME DESTROY — the request's owning frame was destroyed and the PLAIN managed request it issued (ordinary event-handler issuance, no actor id) was aborted at the frame-teardown boundary with :reason :frame-destroyed; carries the same generic :rf.reply/stale-reason :rf.http/request-id-superseded, :recovery :suppressed-on-frame-destroy and a carried work-id with no current successor (frame destroy replaces the attempt with nothing); emitted by re-frame.http.registry's abort-in-flight-on-frame-destroyed!. The frame-destroy sibling of the epoch-restore boundary — the two share the identical carried-id-against-nil-current suppression gate (one emit-frame-boundary-stale-trace! walk) but stay discriminable on the trace stream via :recovery (:suppressed-on-frame-destroy vs :suppressed-on-epoch-restore) so tooling never mislabels a destroy as a restore. All four keep the late completion from delivering to the original :rf/reply-to target. Per Conventions §The naming rules the row's work identity rides ONLY under :rf.reply/work-id (no bare :work/id beside it); the :rf.reply/carried / :rf.reply/current correlation payloads nest a :work/id as data. Dev-only (interop/debug-enabled?-gated). Per 014 §:request-id (internal), 014 §Abort on actor destroy, Managed-Effects §Stale suppression / §Cancellation, and Managed-Effects §SSR, preload, hydration, and restore |
:superseded-by-fresh-request (supersession) / :actor-destroyed-target-obsolete (obsolete actor target) / :suppressed-on-epoch-restore (restore) / :suppressed-on-frame-destroy (frame destroy) — no app target runs for the suppressed attempt |
:rf.reply/work-id (carried), :rf.reply/work-kind (:http), :rf.reply/status (:stale), :rf.reply/work-status (:suppressed), :rf.reply/stale-reason, :rf.reply/carried, :rf.reply/current (nil for actor-destroy / restore / frame-destroy) |
:rf.warning/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/transport_jvm.cljc) and re-frame.http.transport-cljs (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.http.interceptor/registered |
:info |
diagnostic | A reg-http-interceptor stored (or replaced) the frame-agnostic HTTP interceptor definition :id under the registrar kind :http-interceptor; a frame runs it only by naming the id in its :http-interceptors. Per 014 §Middleware |
n/a — informational lifecycle trace | :id |
:rf.http.interceptor/cleared |
:info |
diagnostic | (rf/clear :http-interceptor id) removed an existing definition (no trace fires for clear-of-unknown-id). Per 014 §Middleware |
n/a — informational lifecycle trace | :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/registrar-clear-bad-request |
:error |
diagnostic | rf/clear — the ONE kind-keyed registrar inverse — was called with an argument it refuses. Two reasons, carried on :reason: :unknown-kind (a kind outside clear's closed set: the registrar kind set MINUS :frame, which is a live runtime object torn down by destroy-frame!); and :opts-on-a-non-frame-scoped-kind (any trailing opts map — every registration is a program member read through each frame's own view, so clear removes it for every frame at once and has no frame to target; a frame stops running a flow or an HTTP interceptor when it is re-made without naming it). Validation runs before any registry is touched. Thrown by re-frame.core. :rf.error/http-bad-interceptor is separate — it covers reg-http-interceptor's own arg validation. Per 001 §Registry model and API.md §Clearing registrations |
:fix-the-clear-call — name a clearable kind, and pass no opts |
:kind, :reason (:unknown-kind / :opts-on-a-non-frame-scoped-kind), :clear-kinds (the closed set) |
:rf.error/http-bad-interceptor |
:error |
diagnostic | An HTTP interceptor definition or a frame's HTTP interceptor chain is malformed. reg-http-interceptor (shape iii — (reg-http-interceptor id interceptor-map)) throws it for a non-keyword positional id, a non-map interceptor-map, a non-fn :before / :after, a map missing both :before and :after (a no-op interceptor), or ANY :frame key in the interceptor map — a definition is frame-agnostic, and the message names the :http-interceptors frame-config key that activates it. make-frame throws it when the frame's :http-interceptors is neither nil nor a vector of keyword ids. Surfaced as a thrown ex-info from the call, not a trace. Per 014 §Middleware |
:no-recovery — the call throws an ex-info; the registration or the frame construction fails |
:where ('rf/reg-http-interceptor or 'rf/make-frame), :received (a map of {:id :interceptor-map}, from reg-http-interceptor), :http-interceptors (the rejected value, from make-frame), :reason |
:rf.error/http-bad-retry-on |
:error |
diagnostic | A :rf.http/managed fx was invoked with a :retry policy the transport cannot honour, on one of five arms. Policy shape — a non-nil :retry that is not a map (:retry 3) names no policy, so the request would never retry. Shape — :on is present and non-nil but not a set (a keyword, vector, list, string, …); the transport's (contains? on-set kind) gate would test index membership over a sequential value and silently disable retry. Membership — a set :on contains a member that is neither a retryable category nor an HTTP status integer from 400 to 599. The closed retryable set is #{:rf.http/transport :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 so are any keyword outside that set, an integer outside 400-599 and any other value. Ceiling — the :retry map carries no :max-attempts. The transport retries only under that ceiling, so the map would validate and never retry, and none is defaulted because an extra attempt re-sends a request that may not be idempotent. Numbers — a :max-attempts that is not a positive integer, a :backoff that is not a map, or a :backoff :base-ms / :factor / :max-ms that is not a non-negative number; each would throw when a failure reaches the retry decision, ending the request with no reply. Surfaced as a thrown ex-info from the fx-call site, not a trace. Emitted by re-frame.http.handlers/validate-retry!. Per 014 §Closed-set :retry :on validation and 014 §Retry and backoff |
:no-recovery — the call throws an ex-info; the request is not dispatched. Pass :retry as a map, make :on a set of retryable categories and statuses from 400 to 599, add a positive-integer :max-attempts, give :backoff as a map of non-negative numbers, or omit :retry for no retry |
This row is thrown, so the column names FLAT ex-data slots, not :tags keys (per §Reading the two right-hand columns). :where (:rf.http/managed) and :reason on every arm; :bad-shape (the non-set :on), :bad-type and :retryable-set on the shape arm; :bad-members (the refused members of :on) and :retryable-set ({:categories #{…} :http-status-range [400 599]}, both admitted forms) on the membership arm; :received (the non-map :retry value) on the policy-shape arm; :received (the ceiling-less :retry map) on the ceiling arm; :received (the policy) and :bad-key (the key or key path, e.g. [:backoff :base-ms]) on the numbers arm |
: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 |
:rf.frame |
diagnostic | At an event boundary (before dequeue), an ordinary drain observed that its exact frame incarnation was claimed for destruction (or already dead/absent). An event in flight when the destroy was requested has already settled as an ordinary event; the drain drops every envelope still queued in both lanes — that event's own descendants included — and the frame's teardown runs. An immediate claim cuts pre-claim waiting work, and the observing check removes claim-to-dead arrivals before invocation. One exact router generation emits at most one report, whose :dropped-count combines claim-time and check-time removals; the private exact-token :on-destroy cascade is not counted. Per 002 §Edge cases worth pinning. Lifecycle event, not error-shaped |
n/a — lifecycle event, not error-shaped. Dropped queued work is silent apart from this lifecycle trace | :frame, :dropped-count |
:rf.machine.event/unhandled-no-op |
:rf.machine |
diagnostic | An unknown user event arrived at a machine and no transition matched at any state-node along the active path (nor the root :on / its :* wildcard). The snapshot is unchanged. Benign, not an error — xstate-v5 parity: v5 has no 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). There is no :rf.error/machine-unhandled-event (nor a :rf.warning/ spelling): an unhandled user event deliberately sits outside the error catalogue |
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 :reset}) records how it started; :reset marks the birth that follows a snapshot reset. Op-type :rf.machine (activity family, never an issue). Emitted ONLY when initial-entry actually runs — a throwing initial-:entry short-circuits to :rf.error/machine-action-exception instead, and restoration paths (SSR / restore-epoch! / replace-frame-state!) install a present, non-pending snapshot and emit none. Per 005 §The :rf.machine/started trace. Consumer: Xray's epoch panel renders it as a [START] badge |
n/a — lifecycle/activity trace; the snapshot was installed | :machine-id, :frame, :state, :data, :cause |
:rf.error/invalid-cofx |
:error |
diagnostic | A caller-supplied :rf.cofx is structurally malformed at the PUBLIC dispatch boundary — a non-nil, non-map value, or a non-integer :rf/time-ms. The :rf.cofx map is the durable causal token (replay / restore / SSR-hydration fold its :rf/time-ms), so a malformed token would corrupt durable state. The guard is production-reachable — checked in re-frame.router/build-envelope before the clock stamp, NOT gated on interop/debug-enabled? (it fires in :advanced + goog.DEBUG=false production too — a corrupt durable causal token is a production data-integrity bug) — but it is a pure throw-error! that does NOT fan out on the always-on error-emit listener, so its catalogue Channel is diagnostic (the thrown-ex-info-is-diagnostic-channel rule). Per 002 §Recordable coeffects |
:no-recovery — the dispatch is rejected at the boundary; supply a nil or map :rf.cofx with an integer :rf/time-ms |
:event-id, :event, :supplied |
:rf.error/invalid-event |
:error |
diagnostic | A dispatch was handed a value that is not an event vector — a vector headed by its event-id keyword (Spec-Schemas §:rf/event): a bare keyword ((rf/dispatch :foo)), a map, an empty vector, any other non-vector, or a vector whose head is not a keyword. Checked FIRST in re-frame.router/build-envelope, before anything reads the event, so every dispatch path is covered: dispatch, dispatch-sync, a :dispatch fx child (the forgotten inner vector of {:fx [[:dispatch :foo]]}) and a reply completion. A :dispatch-later event is checked when its timer is armed, not when it fires. Production-reachable — NOT gated on interop/debug-enabled? — and a pure throw-error! the caller observes at its call site, so its catalogue Channel is diagnostic (the thrown-ex-info-is-diagnostic-channel rule). Thrown from a :dispatch / :dispatch-later fx, it lands in the fx walk, which reports it like any typed fx throw, with :failing-id naming the fx, and runs the sibling fx. Per 002 §Routing: the dispatch envelope |
:no-recovery — nothing is dispatched; pass an event vector, e.g. [:foo] or [:foo {:k v}] |
:event |
: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/frame-no-generation |
:error |
diagnostic | A frame-targeted registrar query (registrations / handler-meta, the {:frame f …} query form) named a :frame that resolves to neither a live frame carrying a sealed image generation nor a frame made without :images, or rf/frame-generation named a frame carrying no 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 the id or value of a live frame: one made with :images for rf/frame-generation, one made with or without for the query |
:frame, :live-frame-ids (the frames that read resolves) |
:rf.error/registrar-query-invalid |
:error |
diagnostic | A registrar query (registrations / handler-meta / ambiguous-registrations) is not a map of its grammar's keys. Fires on a non-map argument — most often a positional call such as (handler-meta :event) or (registrations :event), which no query accepts — and on a map carrying a key other than :kind, :frame and, for handler-meta, :id: a misspelled :frame would otherwise read the registration store without a word, and a :source key is refused like any other. :rf.error/registrar-query-needs-source is reserved and never recycled. Thrown by re-frame.core. Per API.md §Public registrar query API and 002 §Frame target resolution |
:pass-a-query-map — pass {:kind k …} to read the registration store, or {:kind k :frame f …} to read live frame f's resolved program |
:reason (:not-a-map / :unknown-key), :received (the non-map value, on :not-a-map), :unknown-keys (a vector of the keys outside the grammar, on :unknown-key), :allowed-keys (a vector of the grammar's keys, on :unknown-key) |
:rf.error/registrar-kind-not-queryable |
:error |
diagnostic | A registrar query named :kind :frame — the RESERVED-BUT-EMPTY registrar slot (re-frame.registrar/kinds). Nothing is ever written to it, so the query could only ever answer {} / nil, which reads as an authoritative empty catalogue while the real store sits elsewhere. The message NAMES THE REAL DOOR: rf/frame-ids / rf/frame-meta. Thrown by re-frame.core. Per 001 §Registry model and API.md §Public registrar query API |
:use-the-owning-read — read the owning store named in the message |
:kind, :read (the owning read to use instead) |
:rf.error/non-edn-identity |
:error |
diagnostic | An identity value (a path segment, a recordable key) is outside the portable CEDN-1 EDN domain — a host object or otherwise non-portable value — and is rejected rather than silently host-hashed. Thrown by re-frame.identity. Per Conventions §Canonical EDN identity |
:encode-as-portable-edn — re-express the value as portable EDN data |
:bad-value, :bad-type |
:rf.error/bad-frame-classification |
:error |
diagnostic | A frame-config data-classification declaration is structurally malformed (a bad key, a non-EDN path segment, a mismatched carrier, a malformed entry). Thrown by re-frame.frame's config preflight (a :sensitive / :large key) and by re-frame.observability's sink-policy grammar (a malformed :observability, from make-frame or configure!); 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. A malformed reg-classification declaration (the durable app-db surface) raises it too — this is the registration-time classification 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). One declaration raises this id later: a malformed :carriers block on the :rf.http/managed reg-fx registration (014 §HTTP carriers) is validated where it is read, so it is refused at the next managed request, not at registration (thrown by re-frame.http.privacy/validate-carriers!). 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, :flow-bad-marks |
:rf.error/bad-path |
:error |
diagnostic | A :rf/path value is structurally malformed — a nil path (the root is the explicit [], never nil), a non-sequential container, or a non-EDN-identity segment. Thrown by re-frame.path (normalize / normalize-concrete); the fail-closed :rf/path-algebra boundary. A pure throw-error! (does not fan out on the error-emit listener), so its catalogue Channel is diagnostic. Per Conventions §The :rf/path algebra |
:fix-path — supply a sequential vector of EDN-identity segments (the root is []) |
:bad-path, :bad-segment |
:rf.error/unknown-egress-profile |
:error |
diagnostic | A :rf.egress/profile value passed to project-egress (at the door, or at the epoch arm's own pre-flight guard) 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/bad-egress-opts |
:error |
diagnostic | An egress opts map carried a key the door it was passed to does not read. The ONE façade door and the internal walker beneath it both take closed opts maps: the framework-internal re-frame.elision/elide-wire-value (:frame / :path / :query-v + the four :rf.egress/* overrides — seven keys), and rf/project-egress (that set, plus the :rf.egress/profile this layer owns, plus the three epoch-only axes :rf.egress/include-fx-args? / :rf.egress/include-runtime-db? / :rf.egress/include-event-args? — eleven keys). Were they open, a RECOGNISED policy key the reading door did not read would vanish without a signal — a :rf.egress/profile handed to the walker, or an unqualified include-sensitive? handed to the door — so the call would read as though it had named a policy while the walk ran under the default one. Closing the maps cannot widen egress, since a key no door reads changes nothing. Thrown by re-frame.elision (the shared bad-egress-opts-ex builder every door routes through). A pure throw-error!, so its catalogue Channel is diagnostic. Per 015 §elide-wire-value and Spec-Schemas §:rf/project-egress-opts |
:pass-the-profile-to-project-egress when the offending key is :rf.egress/profile; otherwise :use-a-recognised-egress-opts-key |
:unknown-keys, :accepted |
:rf.error/adapter-disposed |
:error |
diagnostic | A runtime substrate op ran after terminal teardown of the exact installed adapter generation was claimed (destroy-adapter!) — including from a cleanup callback while that generation is still draining. Distinct from never-installed (:rf.error/no-adapter-installed). Thrown by re-frame.substrate.adapter's require-adapter!. Per 006 §The adapter API contract |
:install-a-fresh-adapter — after teardown settles, install a fresh adapter with (rf/init! adapter) before using the runtime or creating a public compiled Root |
(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/defwrapper-bad-args |
:error |
diagnostic | A defwrapper macro call's second argument is neither a docstring nor an attr-map. Thrown at macroexpansion from re-frame.core-artefact; the optional-artefact wrapper-authoring path. Per Conventions §Optional-artefact wrapper convention |
:fix-registration — pass a docstring or attr-map (or omit the second arg) |
:got |
:rf.error/defreg-macro-bad-delegate |
:error |
diagnostic | A defreg-macro call cannot resolve its delegate symbol in re-frame.core (a typo'd or non-existent reg-fn name). Thrown at macroexpansion from re-frame.core-reg-macros. Per Conventions §Optional-artefact wrapper convention |
:fix-registration — name an existing re-frame.core reg-fn as the delegate |
:sym |
:rf.error/reg-view-bad-args |
:error |
diagnostic | reg-view's second argument was not an args vector — the defn-shape (reg-view sym [args] body) was violated. Thrown at macroexpansion from re-frame.core-reg-view-macro; names reg-view* for runtime registration. Per 002 §with-frame and with-new-frame |
:fix-registration — pass an args vector, or use (reg-view* :id render-fn) for runtime registration |
:sym, :got, :args-after-sym |
:rf.error/with-frame-vector-form |
:error |
diagnostic | with-frame (the pin-to-existing-frame form) was given a VECTOR argument — the caller meant with-new-frame (which evals, binds, runs, and destroys). Thrown at macroexpansion from re-frame.core-reg-view-macro. Per 002 §with-frame and with-new-frame |
:use-with-new-frame — use with-new-frame [sym expr] for the eval/bind/destroy form |
:got |
:rf.error/with-new-frame-keyword-form |
:error |
diagnostic | with-new-frame (the eval/bind/destroy form) was given a KEYWORD argument — the caller meant with-frame (which pins to an existing frame-id). Thrown at macroexpansion from re-frame.core-reg-view-macro. Per 002 §with-frame and with-new-frame |
:use-with-frame — use with-frame :keyword to pin to an existing frame |
:got |
:rf.error/with-new-frame-bad-binding |
:error |
diagnostic | with-new-frame's binding was neither a 2-element [sym expr] vector nor a keyword — a wrong-arity vector or a non-vector value (almost certainly a typo of the binding form). Thrown at macroexpansion from re-frame.core-reg-view-macro. Per 002 §with-frame and with-new-frame |
:fix-registration — use a 2-element [sym expr] binding vector |
:got |
:rf.error/invoke-handler-bad-node |
:error |
diagnostic | The rf/invoke-handler test helper received a non-vector node — it expects a hiccup vector. Thrown by re-frame.test-helpers; a public test-support surface (dev/test-only). Per 008 §Pattern 5 — single-frame e2e fixture |
:no-recovery — pass a hiccup vector node |
:node, :event-key |
:rf.error/invoke-handler-missing |
:error |
diagnostic | The rf/invoke-handler test helper found no handler fn at the given event key on the supplied node. Thrown by re-frame.test-helpers; a public test-support surface (dev/test-only). Per 008 §Pattern 5 — single-frame e2e fixture |
:no-recovery — supply a node carrying a handler at the named event key |
:node, :event-key |
:rf.error/flow-path-overlap |
:error |
diagnostic | Two flows one frame runs 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. The check is over each frame's active set: two flows writing one path are allowed when no frame runs both. Thrown by re-frame.flows.topo at activation (:where 'rf/make-frame), refusing the frame before any frame state is written; a re-registration that creates the overlap in a running frame's active set throws it from that frame's next flow pass (:where 're-frame.flows/run-flows-on-db), aborting the event as :rf.error/flow-eval-exception. Per 013 §Topological sort and cycle detection |
:fix-registration — give each flow a disjoint :path, or stop naming one of them in the frame's :flows |
:overlap (the offending {:flow-ids [a b] :paths [pa pb]}) |
:rf.error/unregistered-flow |
:error |
diagnostic | A frame's :flows names an id its registration view does not resolve — no reg-flow defines it in the live store, or in the frame's image for a frame made with :images — or :flows is not a vector of keyword ids. Activation refuses the frame before any frame state is written, the way an unregistered interceptor reference does (per 013 §Activation and 002 §Validation and resolution timing). Thrown by re-frame.flows.registry (an unresolved id) and re-frame.frame (a malformed :flows); construction-time |
:fix-registration — register the flow before making the frame, or remove the id from its :flows |
:where ('rf/make-frame), :frame, :flow-id (the unresolved id), :flows (the frame's :flows as given) |
:rf.warning/unregistered-flow |
:warning |
diagnostic | A frame still names a flow in its :flows, but the registration it last evaluated is gone — (rf/clear :flow id) or the hot-reload sweep removed it. The frame's next flow pass vacates the output path its memo recorded and drops the flow's output classification marks; re-registering the id resumes it (per 013 §Re-registration). Emitted by re-frame.flows; dev-only |
:warned-and-continued — the event proceeds without the flow, its output vacated; register the id again, or re-make the frame without it |
:frame, :flow-id, :path (the vacated output path), :reason |
:rf.error/invalid-flow-metadata |
:error |
diagnostic | A reg-flow (3-slot grammar (reg-flow flow-id metadata derive-fn)) was given a MIDDLE metadata slot that is not a map, or a :derive was left INSIDE the metadata map (the pure derivation is the THIRD slot — its one home). Both guards run BEFORE the flow-map reconstruction so a malformed call fails loudly at the authoring boundary naming the flow, rather than leaking a raw host exception. Mirrors reg-machine's :rf.error/invalid-machine-opts and reg-route's :rf.error/route-bad-metadata non-map / mislocated-key guards. Thrown by re-frame.flows.registry; registration-time / dev+prod (a caller bug). Per 013 §The registration shape |
:fix-registration — the call throws; pass a metadata map with :inputs / :output-path, and put the pure :derive fn in the third slot |
:id, :value (the rejected non-map metadata or the mislocated :derive) |
:rf.error/malformed-json |
:error |
diagnostic | A decoded JSON payload exceeded the per-call unique-key cap (default-max-decoded-keys, overridable via :rf.http/max-decoded-keys) — a keyword-interning DoS guard. Thrown during json-parse; the transport classifies it as :rf.http/decode-failure (:reason :too-many-keys). Per 014 §Keyword-interning cap |
:no-recovery — raise :rf.http/max-decoded-keys for this request or review the upstream service |
:cause, :limit |
:rf.error/http-timeout |
:error |
diagnostic | The per-attempt wall-clock timeout elapsed before the request (headers + body read) completed and was aborted. Emitted by the CLJS Fetch transport's timeout handler; classified as :rf.http/timeout and routed through maybe-retry! (distinct from the wire-level :rf.http/timeout op-type). Per 014 §:timeout-ms security defaults |
:no-recovery — the request was aborted after timeout; retry per the configured retry policy |
:elapsed-ms, :limit-ms |
:rf.error/http-schema-validation-failed |
:error |
diagnostic | A decoded 2xx response body failed its Malli :decode schema validation. Thrown during the schema-decode phase in re-frame.http.decode; the transport classifies it as :rf.http/decode-failure. Per 014 §Decoding |
:no-recovery — the server returned a structurally invalid response; investigate the upstream service |
:schema, :value |
:rf.error/http-interceptor-bad-return |
:error |
diagnostic | An HTTP :before / :after interceptor returned a non-map value; the threaded ctx must always be a map. Thrown by re-frame.http.middleware; chain-execution validation. Per 014 §Middleware |
:no-recovery — fix the interceptor to return the transformed ctx map |
:id, :returned |
:rf.error/http-bad-reply-target |
:error |
diagnostic | A :rf.http/managed request's reply addressing was rejected, for one of four reasons — the first three at dispatch time. Bad SHAPE — a :reply-to / :on-success / :on-failure target was neither an event vector nor nil, so it cannot be dispatched. MIXED addressing (:reason :mixed-addressing) — the map carried :reply-to beside :on-success or :on-failure; the unified and split styles are exclusive because :reply-to already addresses both branches, and the test is key PRESENCE, so an explicit nil branch is a mixture too (silence a reply with :reply-to nil). UNPAIRED split (:reason :unpaired-split) — the map carried :on-success without :on-failure, or :on-failure without :on-success; the split keys address one reply as a pair, and the branch left out would drop its outcome with no handler. Also tested on key PRESENCE, so an explicit nil partner completes the pair. All three are thrown by re-frame.http.handlers's args validation (validate-reply-target!), BEFORE run-attempt! issues the request — so a typo'd bare-keyword target fails fast at the fx-call site rather than being issued and then throwing async in the reply tail. re-frame.http.encoding's reply-event builder keeps the shape check as belt-and-braces. MACHINE-OWNED reply (:reason :machine-owns-reply) — a :spawn of the :rf.http/managed machine carried :reply-to, :on-success or :on-failure in its :data; the machine addresses its request's reply to itself and reports to the parent as [:succeeded value] / [:failed failure], so a caller-supplied target has nothing to mean. Tested on key PRESENCE and thrown from the child's :entry (re-frame.http.machine-wrapper), before any request goes out, so the machine runtime reports it as an action exception and routes it to the parent's :on-error. Per 014 §Request envelope / §Reply addressing |
:no-recovery — make the reply target an event vector (or nil); for a mixture, drop either :reply-to or the branch key(s); for an unpaired split key, supply its partner (an explicit nil silences that branch) or use :reply-to; for a machine spawn, drop the key from its :data, or issue the request with the :rf.http/managed fx when a named event should receive the reply |
:key (the offending :reply-to / :on-success / :on-failure slot), :value; on a mixture, an unpaired split or a machine spawn instead :reason (:mixed-addressing / :unpaired-split / :machine-owns-reply) and :keys (the colliding or supplied keys) |
:rf.error/http-no-reply-target |
:error |
diagnostic | A :rf.http/managed fx was invoked with NO reply target — none of :reply-to, :on-success, :on-failure was supplied. There is no co-located default (merging the reply under :rf/reply back to the originating event id), so an unaddressed request fails loud at the dispatch site rather than silently routing the reply back to the dispatching event. Thrown by re-frame.http.handlers's args validation (validate-reply-target!); fx-call-time, before run-attempt!. Per 014 §Reply addressing |
:no-recovery — supply :reply-to (one target for both success and failure) or :on-success / :on-failure (an explicit nil silences a branch) |
:where (':rf.http/managed), :args-keys (the args-map keys supplied), :reason |
:rf.error/http-reply-tail-failed |
:error |
always-on | A managed-HTTP REPLY TAIL threw AFTER the transport already completed — a throwing :after interceptor (also emitted as :rf.error/http-interceptor-failed at the interceptor site), or a malformed reply target the dispatch-time guard did not catch (:rf.error/http-bad-reply-target, belt-and-braces path). Per 014 §Middleware §Failure mode a response-side throw MUST NOT be reclassified as a transport rejection: this boundary catches it and surfaces it once here rather than (a) routing it into the transport-rejection classifier — on CLJS the Fetch .catch would read it as a :rf.http/transport rejection of a request whose outcome had already committed, and the done request drops a late outcome without a word, or (b) letting it vanish into the unobserved JVM whenComplete future (a silent swallow, the caller hangs). The response-side analogue of the request-side :rf.error/fx-handler-exception boundary. The request is already done (its outcome committed and its record retired before the reply tail ran), so NO retry / re-send fires and no teardown is repeated; the reply itself is not delivered (delivery is what threw). The fence spans the WHOLE platform completion callback, not only the :after / reply-target tail — a throw in the response cascade above it (status classification, response-body schema classification, the retry decision, the commit) lands here too, and :reply-error-id carries the caught throw's own id so the cases stay distinguishable. ALWAYS-ON, because a dev-only surface would not keep 014 §Failure mode's promise that the throw is surfaced "observably" — in a production CLJS bundle the reply would vanish with nothing left behind. It fans out through both error substrates via the :error-emit/emit-error-both hook, exactly as the request-side :rf.error/fx-handler-exception boundary does; the dev-trace half stays interop/debug-enabled?-gated from inside trace/emit-error!. Emitted by re-frame.http.transport (http/transport.cljc) |
:no-recovery — the reply could not be delivered; fix the :after interceptor or reply target so it does not throw |
:url, :kind (:success / :failure — the reply branch whose delivery threw), :reply-error-id (the caught throw's :rf.error/id, e.g. :rf.error/http-interceptor-failed), :cause, :sensitive? (when the request is sensitive the trace surface redacts the URL's query values and the string :cause, which is the caught throw's message; an interceptor-chain error is thrown already projected on a sensitive request, so the always-on record's :exception echoes no more — 014 §Privacy), :reason |
:rf.error/machine-reserved-meta-in-opts |
:error |
diagnostic | A reg-machine* opts map carried the framework-owned :rf/machine? / :rf/machine keys — the registration home stamps these automatically. Thrown by re-frame.machines.lifecycle-fx.registration; registration-time. Per 005 §reg-machine — public registration surface |
:drop-reserved-keys — remove the reserved keys; the registration home stamps them |
:machine-id, :opts |
:rf.error/invalid-machine-opts |
:error |
diagnostic | A reg-machine / reg-machine* 3-arity was given a MIDDLE opts slot that is not a registration-metadata map (a vector, string, number, … — a non-map in the middle opts slot: (reg-machine* machine-id {…} machine)). The non-map guard runs BEFORE the reserved-key contains? / assoc so a malformed opts fails loudly at the authoring boundary naming the machine, rather than leaking a raw host IllegalArgumentException ("Key must be integer"). The 2-arity (reg-machine* machine-id machine) has no opts (it normalises to {}). Mirrors reg-route's :rf.error/route-bad-metadata non-map guard and the reg-resource / reg-mutation metadata-slot map gate. Thrown by re-frame.machines.lifecycle-fx.registration; registration-time / dev+prod (a caller bug). Per 005 §reg-machine — public registration surface |
:fix-registration — the call throws; pass an opts map (or use the 2-arity with no opts) |
:machine-id, :value (the rejected non-map opts) |
:rf.error/invalid-machine-classification |
:error |
diagnostic | A reg-machine spec declared a malformed projection-relative :sensitive / :large data-classification — a non-vector axis, a non-path entry, an invalid :rf/path segment, or a path that does not start with :data (egress applies only :data-rooted paths, so any other root would redact nothing) — or the opts map's :sensitive / :large holds a path that does not start with an index into the routed event vector: opts paths classify the event the machine is dispatched, and a durable :data slot is declared on the spec. A machine declares :sensitive / :large as a vector of snapshot-relative :data-rooted :rf/path vectors (e.g. {:sensitive [[:data :token]]}), applied at egress to each actor snapshot present. 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, :declared-in (:spec or :opts, the map that declared the path), :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 concrete resource scope is a reserved :rf.scope/* typo, a wrapped reserved scope (e.g. the singleton [:rf.scope/global]), or a {:from-db …} named-resolver REFERENCE reaching a boundary that needs a concrete scope — a reference is a policy spelling, and accepted as a literal map scope it would key nothing and silently no-op. A host or non-portable value in a scope is :rf.error/resource-non-edn-params (:kind :scope), not this id. The same id refuses a [:rf.resource/change-principal {:from …}] whose :from is not a vector, because a bare scope tuple would otherwise read as its own members (016 §Changing principal); every :from member is validated before any scope is cleared. Thrown by re-frame.resources.state's canonicalize-scope and re-frame.resources.events' change-principal-handler. Per 016 §Scope resolution |
:fix-scope — use a valid concrete scope value; resolve a {:from-db …} reference first with rf/resolve-resource-scope and pass the scope it returns; give change-principal a vector of concrete scopes |
:resource-id, :scope; :from on the change-principal shape check |
:rf.error/resource-cross-scope-cause-required |
:error |
diagnostic | A :cross-scope? true :rf.resource/invalidate-tags dispatch carried no :cause evidence — cross-scope is the audited escape that can stale/refetch data across every user/tenant/frame, so it requires a recorded cause. Thrown by re-frame.resources.events. Per 016 §The cross-scope lattice — three precise rungs |
:fix-cause — supply a :cause for the cross-scope invalidation |
:tags |
:rf.error/resource-cross-scope-scope-conflict |
:error |
diagnostic | A :cross-scope? true :rf.resource/invalidate-tags dispatch ALSO carried a :scope — cross-scope is scope-AGNOSTIC (it matches the tags in every scope), so a :scope alongside it is a contradiction, rejected loudly rather than resolve-then-ignore (a closed union). Thrown by re-frame.resources.events. Per 016 §The cross-scope lattice — three precise rungs |
:fix-scope — drop the :scope for a cross-scope sweep, or drop :cross-scope? true to invalidate one resolved scope |
:tags, :scope |
:rf.error/resource-invalidate-scope-required |
:error |
diagnostic | A scoped (default) :rf.resource/invalidate-tags dispatch lacked an explicit :scope — a missing scope would silently match nothing or the wrong nil-scope set; cross-scope is the only scope-agnostic path. Thrown by re-frame.resources.events. Per 016 §Invalidation |
:fix-scope — supply an explicit :scope (or pass :cross-scope? true with a :cause) |
:tags |
:rf.error/invalid-route-pattern |
:error |
diagnostic | A route :path pattern violated the Spec 012 grammar — a missing leading /, an empty segment, an invalid param/splat name, a reserved char not percent-encoded, a malformed optional group, or multiple splats. Thrown by re-frame.routing.match at reg-route on the first violation. Per 012 §Path-pattern grammar (canonical) |
:no-recovery — fix the route pattern to satisfy the grammar |
:route-id, :pattern, :index |
:rf.error/cookie-invalid-attribute |
:error |
diagnostic | An SSR cookie attribute value contains a forbidden char. For the attributes concatenated VERBATIM into the Set-Cookie line (:path/:domain/:max-age/:same-site/:expires) the rejected set is CR/LF/NUL (RFC 7230 §3.2.4 header-splitting) PLUS the raw ; cookie-attribute delimiter (RFC 6265 §4.1.1) — a ; inside such a value escapes its assigned attribute and fabricates extra ones (SameSite=None, Secure, …). Cookie :value is delimiter-tolerant: it is percent-encoded downstream (; → %3B), so it is gated on CR/LF/NUL ONLY. The offending attribute rides the :attribute payload slot (the injection class is one fact; which attribute carried the char is data). Thrown at BOTH boundaries from one shared grammar (re-frame.ssr.http-validation): the re-frame.ssr.response fx boundary (gating every serialised cookie attribute — a CR/LF/NUL-bearing :value, and a CR/LF/NUL- or ;-bearing :path/:domain/:max-age/:same-site/:expires) and the re-frame.ssr.ring.cookie serialiser at wire-write time. Per 011 §CRLF fail-fast on header values |
:remove-injection-chars-from-cookie-attr — strip CR/LF/NUL (and, for a verbatim-serialised attribute, ;) from the attribute value |
:attribute, :value |
:rf.error/cookie-invalid-expires |
:error |
diagnostic | An SSR cookie :expires is not an epoch-millis long (a string-shaped epoch or a java.util.Date was supplied). Thrown by re-frame.ssr.ring.cookie's serialiser. Per 011 §Payload scope (canonical boundary) |
:supply-epoch-millis-long — pass :expires as a long count of epoch milliseconds |
:expires, :cookie |
:rf.error/cookie-invalid-name |
:error |
diagnostic | An SSR cookie :name violates the RFC 6265 §4.1.1 token grammar (no CTLs, whitespace, or separators). Thrown by re-frame.ssr.ring.cookie and the re-frame.ssr.response fx boundary (one shared predicate). Per 011 §Payload scope (canonical boundary) |
:use-a-token-grammar-cookie-name — use a token-grammar cookie name |
:name |
:rf.error/cookie-missing-name |
:error |
diagnostic | An SSR cookie map carries no :name key (required). Thrown by re-frame.ssr.ring.cookie's serialiser. Per 011 §Payload scope (canonical boundary) |
:supply-a-cookie-name — add a :name key to the cookie map |
:cookie |
:rf.error/header-invalid-name |
:error |
diagnostic | An SSR response header :name violates the RFC 7230 §3.2.6 token grammar (no CTLs, whitespace, or separators). Thrown by re-frame.ssr.response's fx boundary. Per 011 §Payload scope (canonical boundary) |
:use-a-token-grammar-header-name — use a token-grammar header name |
:header |
:rf.error/header-invalid-value |
:error |
diagnostic | An SSR response header value contains CR/LF/NUL — forbidden by RFC 7230 §3.2.4 (header-splitting injection). Thrown by re-frame.ssr.response's fx boundary. Per 011 §Payload scope (canonical boundary) |
:remove-injection-chars-from-header-value — strip CR/LF/NUL from the header value |
:header, :value |
:rf.error/server-fx-args-invalid |
:error |
diagnostic | An argument to one of the six reserved :rf.server/* response fx violates its published TYPE contract (011 §Standard fx) — a :status that is not an integer in 100–599, a non-string header :name / :value, a cookie attribute of the wrong type or a missing required :value, a non-string redirect :location, a present :relative-only? (the redirect is relative-only by default, so the key is refused), an :allow that is neither :any nor a sequence of strings, or an args value that is not a map at all. This gate is UNCONDITIONAL — it runs in a -Dre-frame.debug=false build too. The general Spec 010 §Validation order step-5 fx-args boundary is dev-posture (validate-fx! is (if interop/debug-enabled? … true), read once at load time), which is correct for USER fx under trust-the-programmer but would leave the framework's OWN wire-adjacent contract unguarded: a malformed reserved fx would run and its args land on the per-request response accumulator that ssr/get-response publishes to every host adapter. So the closed reserved family guards its own args in every build, and ssr/get-response never yields a malformed :rf/response shape whatever the posture. The SHAPE half of the same ownership pattern the sibling wire-grammar guards in this file already carry (:rf.error/header-invalid-name / -value, :rf.error/cookie-invalid-name / -attribute, :rf.error/redirect-invalid-location). ONE category for the whole shape surface, with the offending :key carried as data — the same decision :rf.error/cookie-invalid-attribute records for the injection surface. Thrown ex-info from re-frame.ssr.response's fx boundary before the first swap-response!; the registered-fx containment in re-frame.fx then skips the fx, runs its siblings, and fans an always-on :rf.error/fx-handler-exception that SSR projects to a sanitised 500. On CLJS the fx are no-op'd by :rf.fx/skipped-on-platform (server-only), so this is a JVM-only gate. Per 011 §Standard fx |
:supply-a-well-formed-fx-argument — fix the args map at the dispatch site; the message names the key, the expected type, and the shape received |
:rf.error/id, :where (rf.ssr/response), :reason, :fx-id (which reserved fx), :key (which argument), :expected (the published type, as prose), :value (a re-frame.error/diag-value-summary — the EP-0015-safe SHAPE of what arrived, never the value: a cookie :value is a session token. That summary is content-free BY CONSTRUCTION — a closed-vocabulary :type plus an integer :count, nothing else — which is what makes it safe to interpolate into the thrown MESSAGE as well as the ex-data. WHICH argument failed rides the separate trusted :key slot, never a guess made from the value) |
:rf.error/invalid-json-ld-key |
:error |
diagnostic | A JSON-LD object key is nil — JSON object keys must be strings and nil has no key representation. Thrown by re-frame.ssr.head.emit. Per 011 §XSS at output boundaries |
:supply-a-non-nil-key — give the JSON-LD object a non-nil key |
(none) |
:rf.error/invalid-json-ld-number |
:error |
diagnostic | A JSON-LD number is non-finite (##Inf / ##-Inf / ##NaN) — JSON has no representation for these. Thrown by re-frame.ssr.head.emit. Per 011 §XSS at output boundaries |
:supply-a-finite-number — use a finite number |
:value |
:rf.error/invalid-initial-events |
:error |
diagnostic | The :initial-events of an SSR-ring handler's :frame is neither a vector of events nor a (fn [request] …) returning one. Two arms, told apart by :recovery and payload key: the fn arm, where the fn form returned a non-vector, and the value arm, where the opt itself is neither a vector nor a fn. Both thrown by re-frame.ssr.ring.lifecycle during per-request setup. Per 011 §Server flow (per request) |
:return-an-initial-events-vector-from-the-fn (fn arm) — make the fn return a vector of events; :supply-a-vector-or-a-fn-of-the-request (value arm) — pass a vector of events or a (fn [request] …) returning one |
:returned (fn arm — the fn's non-vector result), :received (value arm — the offending opt value) |
:rf.error/invalid-root-view |
:error |
diagnostic | An SSR-ring :root-view is neither a hiccup vector nor a 0-arity fn returning hiccup. Thrown by re-frame.ssr.ring.lifecycle. Per 011 §Server flow (per request) |
:supply-a-hiccup-vector-or-0-arity-fn — pass a hiccup vector or 0-arity fn |
:received |
:rf.error/invalid-hiccup-head |
:error |
diagnostic | A hiccup vector's head has no HTML interpretation. TWO fail-loud ARMS, distinguished by message and :recovery. (a) MALFORMED HEAD — the head is neither a keyword (DOM tag / :<> / :>) nor a callable component (a fn or Var): a string / nil / number / boolean / collection head, whose raw EDN form emitted would bypass output escaping (an XSS-class leak: a (str el) fallthrough would ship a malformed-head vector's attacker-controlled child strings raw and unescaped). Fail loud rather than stringify the malformed form to the wire, mirroring :rf.error/invalid-tag-name. (b) UNRECOGNISED RESERVED HEAD — the head is a keyword in the framework-reserved :rf/* scheme (bare rf namespace or a dotted rf.<area> segment) that is not a head the emitter implements; the recognised reserved heads are :<> and :>. This arm exists because every other keyword head is a DOM/custom element: without it a misspelt :rf/suspense-boundry passes the [A-Za-z][A-Za-z0-9-]* tag grammar and paints a phantom <suspense-boundry> silently — a silent mis-render one keystroke away. The :rf/* root is framework-owned (Conventions §Reserved namespaces), so no legitimate author element is rejected. Both arms thrown by re-frame.ssr.emit during server render. Neither arm is raised client-side: stock Reagent is an external dependency whose element dispatch is not ours to extend, and that diagnostic asymmetry is accepted. Per 011 §XSS at output boundaries and 011 §The head grammar is not Spec 011's to extend |
arm (a) :use-a-keyword-or-callable-hiccup-head — produce a keyword or callable hiccup head; arm (b) :use-a-recognised-reserved-head-or-an-unreserved-keyword — fix the spelling, or use an unreserved keyword for a custom element |
:head, :element (both arms). :element is the element's shape summary, {:type :vector :count n}, never the element: its children carry whatever the view rendered. :head differs by arm. Arm (a)'s head is the offending value, so :head is its shape summary too, such as {:type :string :count 23}, and the message gives that shape and the element's size, never the head. Arm (b)'s head is a keyword in the framework-owned :rf/* scheme, so :head is the keyword itself and the message names it and the element's size (015 §Author guidance for the exception-path residual) |
: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-hydration-payload-invalid |
:error |
diagnostic | On a JVM host, the hydration payload (either partition, :rf/app-db or :rf/runtime-db) carries a number the browser's EDN reader reads back as a DIFFERENT value — a Long or BigInt past ±(2^53−1), a BigDecimal, a Ratio, a BigInteger or a Float — as a value, a map key or a set member. The read-back succeeds silently (9007199254740993 arrives as 9007199254740992, 1/3 as a double), nothing on the client compares the value with the server's, and React adoption sees nothing when the view prints the value identically, so the check fails closed and RUNS IN EVERY BUILD — it is not gated on re-frame.debug, because the failure is data-dependent. The same id refuses a map whose distinct KEYS, or a set whose distinct MEMBERS, the browser reads as ONE value, at any depth — 1 beside 1.0, 0 beside -0.0, a composite key [1] beside [1.0] — because every number crosses on its own but the browser's reader rejects the duplicate and hydration fails; that refusal names the colliding values and the one value they become. The rule is the type and range half and the collection half of 011 §The numeric crossing rule (re-frame.ssr.wire/portable-number? and re-frame.ssr.wire/numeric-collision), WITHOUT its NaN clause: ##NaN, ##Inf, #inst and #uuid read back as what they were and ride. Thrown by re-frame.ssr.payload-policy/build-payload, and the refusal is projected like any render throw. Per 011 §Payload scope (canonical boundary) |
:narrow-the-value-or-drop-the-key — narrow the value (an id to a string, money to a string or integer cents) or leave the key off the :payload allowlist |
:partition, :path, :class (per-number arm), :collapses and :browser-value (collision arm), :half (a map key, or colliding map keys) |
: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 allowlist policy is malformed: the :payload opt is a sequential (a vector is canonical; a list or seq is admitted) carrying an entry that is neither a keyword top-level key nor a path (a non-empty vector) — a string typo such as "public/articles", a stray nil, a list, an empty vector; a :render-state map names an unknown slot, gives a slot an allowlist that is not a sequential of KEYWORD top-level keys (a set included); or the handler carries a :payload-include-sensitive opt, refused naming the :payload path form that carries the permit. An empty allowlist is valid and ships nothing. An absent, nil, set or other non-sequential :payload is :rf.error/ssr-missing-payload-policy, and an unrecognised policy keyword is :rf.error/ssr-unknown-payload-policy. Thrown by re-frame.ssr.payload-policy and re-frame.ssr.render-state, at handler construction and again when the policy is applied. :opt names the opt at fault (:payload-include-sensitive or :render-state; absent for :payload), and the :payload-include-sensitive refusal carries :replacement :payload. Per 011 §Payload scope (canonical boundary) |
:declare-payload-policy — supply a vector of keyword top-level keys and path vectors, the paths naming any classified leaf the browser may hold |
:got, :bad-entries, :opt, :replacement |
: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-node-build-skew |
:error |
diagnostic | A Node render sidecar serves a build other than the :build-id the JVM renderer was built against — two builds answering one request — caught on two occasions. At the render call site: a 200 whose x-rf-ssr-build is not :build-id, an absent header included, since an answer that names no build is not a verifiable match. The sidecar already refuses a mismatched REQUEST; this is the JVM adapter's defensive check on the ANSWER, thrown by re-frame.ssr.ring.node and so projected through project-render-exception! as a 5xx. At construction, under :module: the ready line of the sidecar the renderer launched names a buildId other than :build-id. The child is stopped and the throw reaches renderer's caller, so a skewed deployment fails at boot. Per 011 §Client-side hydration boot helper |
:redeploy-the-matching-bundle — redeploy the server bundle that matches the JVM host, or correct :build-id. An answer with no header means something stripped it, or something other than the sidecar is answering |
:expected, :serving (nil when the answer or the ready line named no build) |
:rf.error/ssr-node-deadline |
:error |
diagnostic | The Node render did not finish within its deadline — observed by the sidecar (its 504, the transport's status for a deadline; :observed-by :sidecar) or by the JVM (its own derived HTTP timeout expired first; :observed-by :jvm). Thrown by re-frame.ssr.ring.node at the render call site, so it is projected through project-render-exception! as a 5xx. Per 011 §Client-side hydration boot helper |
:raise-the-deadline-or-speed-the-render — raise :timeout-ms, or make the entry render faster |
:observed-by, :timeout-ms, :http-timeout-ms |
:rf.error/ssr-node-refused |
:error |
diagnostic | The Node render sidecar answered a render with a non-200 that is not a deadline — a caller-fault 4xx, 503 saturation or shutdown, 500 on the sidecar's side. :refusal carries the sidecar's x-rf-ssr-refusal code as an opaque keyword (nil when the answer carried none); the adapter classifies by the transport's status contract and never spells that vocabulary. Thrown by re-frame.ssr.ring.node at the render call site, so it is projected through project-render-exception! as a 5xx — the sidecar's status never reaches the browser. Per 011 §Client-side hydration boot helper |
:read-the-refusal-code — the :refusal and :detail name the fault |
:status, :refusal, :message, :detail |
:rf.error/ssr-node-renderer-opt-invalid |
:error |
diagnostic | A re-frame.ssr.ring.node/renderer construction opt is absent, malformed or misplaced. Under :module: :module not a non-empty string naming a file, :endpoint named beside it, :node not a non-empty string, or :isolates not a positive integer. Without :module: :endpoint not an absolute http(s) URL, or :node or :isolates named at all, since both configure the sidecar :module launches and would otherwise be silently inert. In either mode: :entry or :build-id not a non-empty string, :timeout-ms not a positive integer, :admission-ms not a non-negative integer, or :args not EDN the sidecar's safe reader reads back equal; opts that are not a map name :opt :opts. Thrown at construction, before any sidecar is launched, so a misconfigured deployment fails at boot rather than at first request. (A missing or malformed :render-state throws the payload family's ids with :opt :render-state instead.) Per 011 §Client-side hydration boot helper |
:correct-the-renderer-opt — supply the named opt in its documented shape |
:opt, :got |
:rf.error/ssr-node-unreachable |
:error |
diagnostic | No usable Node render sidecar, caught on two occasions. At the render call site: the sidecar gave no HTTP answer at all — connection refused, connect timeout, or an I/O fault mid-exchange — or, under :module, the renderer has no sidecar to dial, because the one it owns is down and being relaunched or the renderer was closed; those two carry :endpoint nil. Thrown by re-frame.ssr.ring.node, so it is projected through project-render-exception! as a 5xx rather than reaching :on-error. At construction, under :module: the sidecar the renderer launches cannot start — its runtime is missing from the classpath, the :node executable cannot be run, or the child exits before its ready line, prints none within 30 s, or names a protocol other than 1. A child that started is stopped and its last stderr lines end :ex-message; :endpoint is nil, and the throw reaches renderer's caller, so the deployment fails at boot. A relaunch that fails, this way or on a build skew, is logged on the JVM's stderr and retried with a capped backoff, and requests meanwhile take the :endpoint-nil arm. Per 011 §Client-side hydration boot helper |
:start-the-sidecar-or-correct-the-endpoint — under :endpoint, start re-frame2-ssr-node --module <bundle> or correct :endpoint. Under :module, read the child's stderr, in :ex-message and on the JVM's stderr: install Node or correct :node, or fix what stops the bundle loading. A request refused while the owned sidecar relaunches succeeds once it is back; a closed renderer stays refused |
:endpoint (nil at construction and while no owned sidecar is up), :ex-class, :ex-message |
:rf.error/ssr-nonrenderable-component |
:error |
diagnostic | A callable hiccup component (a fn / Var head) resolved to a fn even after the single Form-2 unwrap (outer fn → inner render fn → still a fn). SSR cannot render a bare fn; stringifying its .toString would leak the fn text (user$…fn__…@…) as visible page content plus a guaranteed hydration mismatch. A Form-2 component's inner render fn must return hiccup. Thrown by re-frame.ssr.emit during server render. Per 011 §JVM-runnable view rendering |
:return-hiccup-from-the-component-render-fn — return hiccup from the component's render fn |
:component |
:rf.error/ssr-reagent-native-head |
:error |
diagnostic | A Reagent-native interop head (:>) cannot be rendered server-side — there is no React on the JVM. Thrown by re-frame.ssr.emit. Per 011 §Source-coord annotation under SSR |
:wrap-in-reg-view-or-render-client-only — wrap the component in a reg-view or render it client-only |
:element |
:rf.error/ssr-render-state-invalid |
:error |
diagnostic | The render-state partition map a NON-LOCAL renderer is handed (the ssr-node crossing) is invalid, in one of two arms discriminated by :invalid. :envelope — not the two-partition envelope {:rf/app-db <map> :rf/runtime-db <map>}: a non-map, a present-but-non-map partition, or a third key. :unserialisable — a top-level entry of either partition the EDN wire cannot carry under re-frame.ssr.wire/edn-carryable? (a fn, a host object, a record, a JVM-only number, #inst / #uuid, a map or set holding two numbers the browser reads as one value — 1 beside 1.0 — at any depth, or a non-keyword top-level key), naming :partition, :key and the offending :half. Thrown by re-frame.ssr.render-state at project (the projector's output — never a silent nil, never #object[…] text discovered at the far end of the wire) and at restore! (the deserialised wire; nothing is installed). Per 011 §Payload scope (canonical boundary) |
:return-the-two-partition-envelope (envelope arm) / :narrow-the-value-or-drop-the-key (unserialisable arm) |
:invalid, :partition, :key, :half, :got |
:rf.error/ssr-ring-frame-opt-invalid |
:error |
diagnostic | An ssr-handler was created with its frame config somewhere other than one :frame map. Three arms, told apart by :recovery: a frame-config key (:initial-events, :fx-overrides, :ssr, :url-strategy) at the handler's top level, a :frame that is not a map, or an :id / :platform inside :frame — the handler owns both, giving each request frame a fresh id and :platform :server. Thrown by re-frame.ssr.ring.lifecycle at construction. Per 011 §Server flow (per request) |
:move-the-frame-config-into-frame — move the named keys inside :frame; :supply-a-frame-config-map — pass the frame config as a map; :remove-the-handler-owned-key — drop :id / :platform from :frame |
:keys (the misplaced or handler-owned keys; first and third arms), :received (a re-frame.error/diag-value-summary of the non-map :frame; second arm) |
:rf.error/ssr-ring-fn-opt-invalid |
:error |
diagnostic | An ssr-handler was created with a fn-valued opt the pipeline cannot call: :renderer, :html-shell or :on-error present but not a callable (a keyword, symbol, map, set or vector is an IFn and still refused — a keyword :renderer would answer every request with an empty body), or :error-view present but neither a keyword nor a fn. Shape only: a view keyword may be registered after construction. Thrown by re-frame.ssr.ring.lifecycle at construction. Per 011 §Server flow (per request) |
:supply-a-fn — pass a fn of the documented shape; :supply-a-view-keyword-or-fn — pass a registered-view keyword or a (fn [public-error] hiccup) as :error-view |
:opt, :got (a re-frame.error/diag-value-summary of the value) |
:rf.error/ssr-ring-invalid-error-view |
:error |
diagnostic | An :error-view the pipeline cannot render: a keyword naming no registered view, or — from a direct resolve-error-body call, since ssr-handler refuses the shape at construction with :rf.error/ssr-ring-fn-opt-invalid — a value that is neither a keyword nor a fn. Thrown and caught by re-frame.ssr.ring.pipeline/resolve-error-body: the error page falls back to the host default template and the failure is reported as :rf.error/ssr-ring-error-view-failed. Per 011 §Detailed design |
:register-the-view-or-supply-a-1-arity-fn (unregistered keyword) / :supply-a-view-keyword-or-1-arity-fn (any other value) |
:received |
:rf.error/ssr-ring-missing-initial-events |
:error |
diagnostic | An ssr-handler was created with no :initial-events inside its :frame (a vector of events, or a fn of the request returning one, is required). Thrown by re-frame.ssr.ring.lifecycle. Per 011 §Server flow (per request) |
:supply-the-initial-events-opt — supply :initial-events inside the handler's :frame |
(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-ring-renderer-result-invalid |
:error |
diagnostic | An ssr-handler's :renderer returned something other than {:body-html <string>} — the markup bare, a map without a string :body-html, nil. Unchecked, the page would ship as a 200 with an empty app root. Thrown by re-frame.ssr.ring.pipeline inside the render, so it is projected like any render throw: it rides the :rf.error/ssr-render-failed record's :exception, and the browser gets the projected 5xx. Per 011 §HTTP response contract |
:return-the-body-html-string-in-a-map — return {:body-html markup} |
:got (a re-frame.error/diag-value-summary of the value in place of the string) |
: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/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. There is no public path-value constructor: the var exists only to fail loud, with 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/machine-bad-target |
:error |
diagnostic | A transition :target on a state-node is malformed — not a sibling keyword, a non-empty absolute vector path, or the :same-state sentinel (e.g. {:target 42} or an empty vector). Rejected by the machine-definition target validator before the generic keyword/vector resolve branch, so tools/conformance consumers classify it correctly. Thrown by re-frame.machines.lifecycle-fx.validation. Per 005 §Transitions + Spec-Schemas §TransitionTarget |
:fix-registration — use a sibling keyword, a non-empty absolute path, or :same-state |
:state, :slot, :target |
:rf.error/machine-unresolved-target |
:error |
diagnostic | A transition :target is well-shaped but resolves to no declared state — a keyword naming no sibling, or an absolute vector path naming no node — or an :initial (root, compound or region body) names no state declared in its node's :states (:slot :initial). Thrown by re-frame.machines.lifecycle-fx.validation. Per 005 §Transitions + Spec-Schemas §TransitionTarget |
:fix-registration — target a declared state (or a :type :history pseudo-state) |
:state, :slot, :target |
:rf.error/machine-bad-after-delay |
:error |
diagnostic | A state-node's :after DELAY is invalid — an :after delay must be a positive integer (literal ms), an ISO-8601 duration string ("PT30S", the :timeout duration grammar; the "5s" shorthand is refused), a keyword naming an entry of the machine's root :delays map, or a fn. Distinct from :rf.error/machine-bad-after-spec (the transition VALUE). Two surfacings, one category. (1) Registration — a static DELAY KEY outside those forms (-1, 0, "soon", "5s", nil, a vector) is thrown by re-frame.machines.lifecycle-fx.validation; a vector key's message says to keep the configured value in the machine's :data and read it with a fn delay. A keyword that names no :delays entry is refused the same way, its message naming the key and the names :delays declares; so is a malformed :delays map (not a map, a non-keyword name, or a value outside the three inline forms), with :slot :delays. (2) Fx time — a fn delay (inline, or a :delays entry) that resolves to anything but a positive number is reported by re-frame.machines.timer on the same diagnostic channel; no timer is armed, so the state waits for an event. A delay fn that throws also reports :rf.error/machine-after-fn-threw first, then this row for the skipped timer. Per Spec-Schemas §:rf/state-node and 005 §Delayed :after transitions |
:fix-registration — supply a valid :after delay key or :delays entry (surfacing 1); :skipped — no timer is armed for that entry (surfacing 2) |
:state, :slot (:after, or :delays for a malformed :delays map), :delay-key (both surfacings); :actor-id, :delay-source (:fn, or :named for a :delays entry), :resolved-delay (the value the delay resolved to), :frame (surfacing 2) |
:rf.error/machine-bad-after-spec |
:error |
diagnostic | An :after clause, or a transition value in one, is not a form the grammar admits (per 005 §Delayed :after transitions). A clause is a map from delay to transition, or nil; a transition value is a target keyword, a vector path target, a transition map, a vector of transition maps, or nil. At registration re-frame.machines.lifecycle-fx.validation refuses an :after clause of any other shape on the machine root, a parallel region body or any state node, and any other value in an :after entry, an :on-timeout or a :spawn :on-timeout (both lower onto :after). At run time the shared :on / :after / :always value normaliser in re-frame.machines.transition throws it when a timer resolves such an entry in a definition that never passed registration, e.g. through the pure machine-transition |
:fix-registration — fix the offending :after clause |
registration: :state, :slot (:after, :on-timeout or :spawn/on-timeout), :value (the offending clause or value), :delay-key for an :after entry, and :region on a region body; run time: :value, :slot (the category id) |
:rf.error/machine-bad-on-done-clause |
:error |
diagnostic | An :on-done value is not a form its slot admits. At registration, re-frame.machines.lifecycle-fx.validation refuses a compound state's, a parallel region body's or a parallel root's :on-done that is not a transition form (a target keyword, a vector path target, a transition map, a vector of transition maps, or nil); a single :spawn's :on-done that is neither a fn (the :data fold) nor an :on-shaped transition (keyword target, vector-path target, single transition map, or a non-empty guarded candidate vector); and a :spawn-all child's :on-done that is not a fn (a join child's :on-done is a fold only). At run time re-frame.machines.transition throws it for a compound, region-body or parallel-root :on-done that is not a transition form in a definition that never passed registration, e.g. through the pure machine-transition (the same shared value grammar :on / :after use). Per 005 §Final states |
:fix-registration — fix the offending :on-done clause |
registration: for a node's own :on-done, :state, :slot (:on-done), :value (the offending value) and :region on a region body; for a spawn's, :state, :on-done (the offending value), plus :child for a :spawn-all child; run time: :value, :slot (the category id) |
:rf.error/machine-bad-structure |
:error |
diagnostic | A machine definition's state tree is not built of maps: a :states or :regions that is neither nil nor a map, on the machine root, a parallel region body or any state node, or a state under :states that is neither nil nor a map (per 005 §State nodes). Refused at registration by re-frame.machines.lifecycle-fx.validation, before any other check walks the tree. A :type :parallel root's :regions that is not a map, and a region body that is not a map, are :rf.error/machine-parallel-bad-shape instead |
:fix-registration — make the slot a map of state nodes, or remove it |
:state (the node carrying the slot, or the state that is not a map), :slot (:states / :regions), :value (the offending value), :region on a region body |
:rf.error/machine-parallel-bad-shape |
:error |
diagnostic | A :type :parallel machine violates the parallel-root shape — a :regions that is missing, empty or not a map, a non-keyword region name, a region body that is empty or not a map, or :initial/:states at the parallel root (mutually exclusive with :regions). Thrown by re-frame.machines.lifecycle-fx.validation; a :regions or a region body that is not a map is refused before any other check walks the tree. Per 005 §Parallel regions |
:fix-registration — give the parallel root a non-empty :regions map of keyword → state-node |
:region (the offending region, when applicable) |
:rf.error/machine-parallel-on-done-target |
:error |
diagnostic | A parallel root's :on-done declares an in-machine :target — a root-only parallel machine has no flat sibling to land on (an accepted target would silently STALL in the all-final configuration). Thrown by re-frame.machines.lifecycle-fx.validation. Per 005 §Final states |
:fix-registration — drop the :target; express "then continue" as an :action / :fx |
:on-done |
:rf.error/machine-parallel-root-on-bad-target |
:error |
diagnostic | A root parallel :on / :after transition :target is not region-qualified — each target's head must be a declared region ([<region> & <path>], or a vector of such). A parallel root has no flat sibling to land a bare-keyword / non-region target on. Thrown by re-frame.machines.lifecycle-fx.validation. Per 005 §Transitions |
:fix-registration — region-qualify the target |
:target, :regions |
:rf.error/invalid-resource-scope-spec |
:error |
diagnostic | A reg-resource-scope input source descriptor is malformed — not a 2-vector [:db <rf-path>], an unknown source head, or a non-concrete :db path. Thrown by re-frame.resources.scope-registry. Per 016 §SSR and hydration |
:fix-registration — supply [:db <rf-path>] with a concrete :rf/path |
:scope-id, :input, :descriptor / :source |
:rf.error/resource-scope-source-reserved |
:error |
diagnostic | A reg-resource-scope input declares the RESERVED :runtime source — named in the input vocabulary but not implemented. Thrown by re-frame.resources.scope-registry. Per 016 §Restore and replay |
:fix-registration — use a [:db <rf-path>] source (db-derived identity) |
:scope-id, :input, :source |
:rf.error/resource-scope-not-registered |
:error |
diagnostic | A {:from-db <scope-id>} reference names a resource-scope resolver that is not registered — fail-closed. Thrown by re-frame.resources.scope-registry. Per 016 §Restore and replay |
:fix-registration — call reg-resource-scope before referencing the scope |
:scope-id |
:rf.error/resource-scope-unresolved-reference |
:error |
diagnostic | A scope-requiring operation's {:from-db <scope-id>} named-scope reference resolved NIL against the current db — fail-closed (a scope-requiring site — event ensure / refetch, the direct :rf.resource/invalidate-tags, or a supplied :rf.mutation/execute :scope — never silently falls through to global). Thrown by re-frame.resources.registry / re-frame.resources.events / re-frame.resources.mutation-registry. Per 016 §Restore and replay |
:fix-scope — ensure the resolver yields a non-nil scope (fix the referenced db state) |
:from-db, :where, :resource-id / :mutation-id (optional) |
:rf.error/infinite-missing-next-page-param |
:error |
diagnostic | An :infinite true resource declares no valid :next-page-param — :infinite makes :next-page-param a REQUIRED pure fn (last-page → next-param | nil). The R8 registration gate. Thrown by re-frame.resources.registry. Per 016 §Registration |
:fix-registration — supply a :next-page-param fn |
:resource-id, :infinite |
:rf.error/route-bad-metadata |
:error |
diagnostic | A reg-route's metadata map (the MIDDLE slot) is malformed, on one of five arms: it is not a map; it carries :path, which is the THIRD slot, not a metadata key; it carries a BARE key outside the accepted set — the routing-owned reserved keys plus the cross-feature keys (:head, and whatever an artefact publishes under :routing/extra-route-keys, such as :resources) — a likely typo like :on-matched for :on-match; its :on-match is present but not a vector of event vectors — typically a single event, [:app/load], which would otherwise register and fail at the first navigation; or its :scroll is outside the closed scroll vocabulary (:top / :restore, false or nil), which would otherwise register and fail later inside the :rf.nav/scroll fx. Namespaced keys are the extension surface and always pass. Thrown by re-frame.routing.registry at reg-route (:where 'rf/reg-route); registration-time, dev and prod (a caller bug, not user input). Per 012 §Authoring-boundary key validation |
:no-recovery — the registration throws; move :path to the third slot, correct or namespace the bare key, write a single :on-match event as [[:app/load]], or set :scroll to :top / :restore or false |
This row is thrown, so the column names FLAT ex-data slots, not :tags keys (per §Reading the two right-hand columns). :route-id on every arm; :keys on the four key arms ([:path], the offending bare keys, [:on-match], [:scroll]); :reserved (the accepted key set) on the bare-key arm; :value on the not-a-map, :path, :on-match and :scroll arms |
:rf.error/route-decimal-unsupported |
:error |
diagnostic | A reg-route :path declares a :double / decimal-typed param/query key — a float has no canonical EDN identity, so it cannot round-trip through match-url / route-url and diverges across the JVM/CLJS hosts. Thrown by re-frame.routing.registry at reg-route. Per 012 §Path-pattern grammar (canonical) |
:no-recovery — encode the value as a string (parse in a handler) or use an :int-typed key |
:route-id, :slot, :param, :type-form |
:rf.error/route-keyword-unbounded-unsupported |
:error |
diagnostic | A reg-route :params / :query [:map …] schema declares a BARE / optioned (unbounded) :keyword-typed key — an unbounded :keyword slot cannot round-trip through match-url / route-url: route-url host-stringifies the keyword value (:asc → %3Aasc) but match-url keeps the URL segment a string (an unbounded :keyword slot must not intern arbitrary URL input — the keyword-interning guard), so the URL route-url built fails the SAME route's re-match. A bounded [:enum :a :b …] keyword slot is admitted (its declared choices intern + round-trip via the enum prism). The :double un-round-trippable sibling is :rf.error/route-decimal-unsupported (row above). Thrown by re-frame.routing.registry at reg-route; registration-time / dev+prod (a caller bug, not user input). Per 012 §Query strings and fragments (the bare-:keyword rejection rule) and 012 §Keyword-interning cap on query keys + values |
:no-recovery — the registration throws; use [:enum …] for a bounded keyword slot or :string for a free-form value (parse it in a handler) |
:route-id, :slot (:params / :query), :param (the offending key), :type-form |
:rf.error/route-url-validation |
:error |
diagnostic | The address handed to route-url cannot produce a round-trippable URL (a caller bug; not user input). Four of the arms carry a :reason discriminator. Address SHAPE — the address is not a map (:not-a-map), names no :to (:missing-to), or carries a key outside the address-only set :to / :params / :query / :fragment (:bad-address-keys, naming the rejected :keys in total canonical order). Address CONTENTS — a :params key the route's PATTERN has no :name / *name segment for (:uncaptured-params, naming the offending :keys): such a key cannot reach the URL and match-url cannot read it back, so route-url rejects it rather than dropping it in silence — the same closure the address-only rule applies, one level down inside :params. The reserved :rf.route/not-found route is the ONE exemption, because its slice :params are the framework's record of the miss rather than path captures. The two remaining arms carry no :reason and are told apart by their payload: a :params / :query schema failure (:slot + :error; a schema that makes the validator throw adds :reason :malformed-schema) and the sequential-optional-group prefix chain — a later optional group supplied while an earlier one elided, which match-url cannot recover (:group). Thrown by re-frame.routing.registry and surfaced through :rf.route/navigate. Per 012 §Bidirectional URL ↔ params and 012 §Path-pattern grammar (canonical) |
:no-recovery — supply an address the route accepts: fix the shape, satisfy the :params / :query schema, or — for an uncaptured key — drop it, move it to :query, or add the segment to the route's pattern |
This row is thrown, so the column names FLAT ex-data slots, not :tags keys (per §Reading the two right-hand columns). :route-id and :slot (:params / :query) on every arm that has resolved a route; :reason on the four discriminated arms; :keys on :bad-address-keys and :uncaptured-params; :value on :not-a-map, on both schema arms, and on the prefix chain — on a schema arm redacted to :rf/redacted with :sensitive? true when the route's schema marks a slot sensitive or cannot be walked, and withheld the same way when the schema throws; :error (the schema explanation, redacted with the value) and, in development builds, :explain-humanized on the schema arms; :group on the prefix chain. The uncaptured-param arm reports STRUCTURE only — keys and slot, never a value — so a secret in an uncaptured key cannot ride the error surface |
:rf.warning/malformed-url |
:warning |
diagnostic | A URL handed to the routing egress (handle-url-change / navigate) could not be matched — a match-url throw or an otherwise malformed URL. Advisory: the navigation plan emits it and continues to the not-found path. Emitted by re-frame.routing.plan. Per 012 §Route-not-found |
:logged-and-skipped — the URL is treated as unmatched; the not-found route (if any) handles it |
:url, :reason (when a match-url throw), :frame |
:rf.error/flow-cycle-extract-invariant |
:error |
diagnostic | The flow topological-sort cycle-path extractor reached a dead end — a Kahn-stuck node found no stuck dependency to follow. An IMPOSSIBLE-by-construction internal-invariant violation (a framework bug), distinct from the caller-fixable :rf.error/flow-cycle registration rejection. Thrown by re-frame.flows.topo. Per 013 §Failure semantics |
:no-recovery — a framework bug (not caller-fixable); report with the :node / :stack / :seen / :remaining payload |
:node, :stack, :seen, :remaining |
:rf.error/ui-tree-malformed |
:error |
diagnostic | A consumer of the structural render tree met a value the closed node set cannot carry — a node that is not [head attrs & children], a hiccup vector head outside the closed set, a child outside the value grammar, a non-data attribute value, a boundary prop named :view-id, or a projection applied to something that is not a node. The id is contract-named by 004B §The tree — ONE id shared by every consumer of the tree. Thrown ex-info (canonical builder), not a trace. Emitted by the Fresco test kit (re-frame.fresco.test, rostered here rather than left to the kit's own subsection below) — a non-data attribute value, a hiccup vector head outside the closed set, a child outside the value grammar, a node that is not [head attrs & children], a boundary prop named :view-id, and the attrs / text / role projections applied to something that is not a node; the kit raises this corpus-owned id rather than mint a kit-private twin. |
:no-recovery — fix the template or the runtime value; the escapes are named in the message. The re-frame.fresco.test kit carries :no-recovery on every arm and tells its arms apart in :reason: a non-data attribute value that is a FUNCTION is told to give it a prop of its own (the one kind 004B's opaque marker gives a site of its own, so moving it there reaches the marker and is a real exit); every OTHER non-data attribute value is told the two exits that work — an EDN value, or the mounted tier that carries the real one — because a prop of its own would refuse it identically, the marker being for a function and nothing else; a hiccup head outside the closed set, a child outside the value grammar, a node that is not [head attrs & children], a boundary call carrying a :view-id prop, a projection handed something that is not a node, and an accessibility projection handed a node from a different tree each name their own fault |
This row is thrown, so the column names FLAT ex-data slots, not :tags keys (per §Reading the two right-hand columns). Every arm carries the four required slots of §The thrown-error shape plus a per-arm payload: :value alone on every arm, except the non-data attribute arm, which adds :key (the attribute) and :path (the position of the offending value within it). |
:rf.error/flush-in-open-epoch |
:error |
diagnostic | A synchronous registry-flush was forced while a frame's run-to-completion event drain was still open. Rendering there could expose partially settled state — queued events whose update and commit phases have not yet run. The guard throws BEFORE its caller touches its registry, so no partial flush publishes a render phase. Implemented ONCE and shared: the open-drain guard is re-frame.frame/guard-open-drain! in CORE, and the event it rejects is a synchronous REGISTRY-FLUSH, exactly as its own docstring words it. Every substrate that forces a registry-flush from inside an open drain MUST call it. Spec 006's flush-render! is EXPRESSLY OUTSIDE this law. A flush-render! is a RENDER-COMMIT — it synchronously commits the substrate's pending renders to the surface — which is not the registry-flush this guard rejects, and Spec 006 already draws that line itself: §Render-batch finalization, the very section this guard's docstring cites, attaches the throw to the explicit TEST flush and closes by holding the adapter's production/tooling flush-render! contract unchanged. So the three shipped adapter kinds that install a flush-render! slot (Reagent, UIx, Fresco) do not owe this guard, and core's re-frame.substrate.adapter/flush-render! delegation is right not to call it. The docstring's phrase "every substrate whose synchronous flush can publish a render phase" is a REACHABILITY clause — any substrate CAN reach the guard through core alone — never a statement of who must call it. The guard has NO in-repo call sites: no shipped substrate forces a synchronous registry-flush, so the guard stands as the requirement on a substrate that grows one. It is normative surface rather than residue, because the law is core's rather than any substrate's. One guard serves every substrate, where a convergence bound cannot be shared, for a structural reason: this guard closes over NO cell registry at all — it reads the router-bound re-frame.frame/*run-frame-state-before* and the frame accessors alone — so a substrate reaches it through core alone. Nothing dev-gates the guard itself: its body carries no goog.DEBUG arm, so it throws wherever it is called from, and whether the category is reachable in production or only under test is a property of the CALL SITES; the guard's own body is the sole emitter of the category and throws whenever it is reached inside an open drain. It is a pure throw-error! that does NOT fan out on the always-on error-emit listener, so it rides the diagnostic channel for catalogue purposes (the thrown-ex-info-is-diagnostic rule). The :where slot names which site threw |
:no-recovery — let the event drain reach quiescence, then flush once |
:frame, :frame-epoch, :where (the substrate's flush entry point) |
:rf.error/root-boot-failed |
:error |
always-on | One root of a multi-root page failed to boot and was ISOLATED — the page's other roots hydrated and are running without it (011 §Failed-root isolation). Emitted once per contained root by re-frame.ssr.boot/report-root-boot-failed!, from the hydrate-page! per-root boundary, for a throw at ANY point in that root's boot: preflight (:rf.error/hydration-frame-id-mismatch, :rf.error/frame-payload-conflict), the hydrate seed, or the host's own :mount-fn. This is a DISTINCT fact from the cause, not a re-report of it: the cause says what broke, this says this root is not running and the page is live without it — the degradation an operator acts on, and the only signal that a page is quietly serving N-1 roots. always-on for exactly that reason: a root can fail in PRODUCTION, so its containment must reach an off-box shipper under goog.DEBUG=false (the :rf.error/malformed-hydration-payload precedent — an absorbed hydration-boot failure is a boundary event, not a dev teaching diagnostic). :phase discriminates :hydrate (died before its seed committed; the frame holds no server slice, and any claim it made on the payload id was released) from :mount (the seed COMMITTED and the payload stays installed, so a sibling root sharing that frame keeps running against live state). Isolation, not recovery — no retry, no supervision, no fallback render. The always-on record is STRUCTURAL ONLY: a boot exception's message can carry application text and the record reaches production error sinks, so the throwable and the message-bearing :reason ride the debug-gated trace and that root's outcome map (:error), never the record |
:warned-and-continued — the failed root stays failed; the page continues with its surviving roots |
Always-on record — structural only: :root-id, :frame, :phase, :where. Dev trace — adds: :reason (names the root and the phase, and carries the exception's message), :exception (the throwable) |
:rf.error/frame-payload-conflict |
:error |
diagnostic | At a hydrating root's preflight, a referenced payload id is already installed with a DIFFERENT CONTENT DIGEST (004C §7, 011 §Hydration preflight and idempotent payload install). A page is N roots referencing M frames, so several roots routinely hydrate one frame off one page-wide __rf_payload; the first installs it, a later root carrying the SAME payload is the idempotent no-op (no re-seed — :replace-frame-state makes a re-seed a silent RESET, not additive corruption), and a later root carrying a DIFFERENT payload for that id fails EXACTLY that root. Payload ids ARE frame ids (004C §6). Typically a page composed from fragments rendered by two different server responses. The claim-or-observe decision is one atomic ledger update, so two roots racing one unclaimed id cannot both install. Thrown BEFORE any install: the live payload, the frame it seeded, and the ledger record are untouched, so a bad frame payload affects exactly the roots referencing it. Thrown ex-info (canonical builder). Emitted by re-frame.ssr.install/payload-install-decision! (hydrate preflight) |
:render-the-page-from-one-response — render every root on the page from one server response, so roots sharing a frame carry the same payload |
This row is thrown, so the column names FLAT ex-data slots, not :tags keys (per §Reading the two right-hand columns): :payload-id, :installed (the ledger record: :digest, :installed-by), :arriving ({:digest … :root-id …}) |
Schema-validation-failure per-:where recovery¶
:rf.error/schema-validation-failure is a single category spanning several validation boundaries, and its recovery is NOT uniform — the catalogue row's Default :recovery cell points here rather than carrying one blanket value, because the boundaries do different things: :fx-args skips the offending fx (:skipped), :sub-return reports and passes the value through, and :app-db / :machine-data reject the candidate frame transition. One blanket :no-recovery would describe none of that. The category vocabulary is stable — an existing category cannot be renamed or split (per Spec-ulation and §Error event catalogue) — so the per-boundary variance rides the :where tag and this table, not a forked category. This is the normative surface-facing mirror of Spec 010 §Per-step recovery (the emit-site of record); it is the description, not a second source of truth — if the two ever disagree, 010 wins.
Every boundary below is dev-only save one: the dev-time arms are production-elided per Spec 000 §Contract C-000.35, and a handler's :boundary? true check is the one production-reachable exception, riding the :where :event recovery unchanged, per 010 §Production builds. Both its rejection and its record survive into production — the record structurally, on the always-on axis; only the payload-bearing dev trace elides (see the §Error event catalogue paragraph on the category).
:where |
Boundary — what is validated | Recovery |
|---|---|---|
:event |
The dispatched event vector, before the handler's interceptor chain runs (per 010 §Per-step recovery row 1). | Skip the handler — the handler is not invoked; no :db install, no :fx. :recovery :no-recovery for this dispatch, but the run-to-completion drain continues: downstream queued events still drain. |
:app-db |
The flow-augmented CANDIDATE :db value at a registered schema-bound path, validated BEFORE the single deferred install (010 §Per-step recovery row 4). |
Reject the candidate transition — :rollback? true (the stable public transaction-REJECTED vocabulary), :recovery :no-recovery. The candidate is NEVER installed (app-db keeps its pre-event value; no observer — trace listener, container watch, substrate subscriber — can see the candidate), NO :rf.event/db-changed / db-noop / frame-state-changed fires, and :fx does not walk for this dispatch. Downstream queued events still drain. |
:fx-args |
A registered fx's args map, before that fx handler runs (010 §Per-step recovery row 5). | Skip only the offending fx — :recovery :skipped (mirroring :rf.fx/skipped-on-platform). Sibling fx in the same :fx vector still run; the run does not halt; downstream events still drain. |
:sub-return |
A schema'd subscription's computed value, on recompute or compute-sub resolution (010 §Per-step recovery row 6). |
Report and pass through — :recovery :no-recovery: the sub returns its computed value to its consumer, so the view renders what the sub produced rather than a nil that would fail somewhere far from the report. (A strict mode re-raises.) |
:sub-override |
A dev-tool :sub-overrides pin's value against the overridden sub's own output :schema, at the subscribe deref seam (010 §Sub override). |
Mirrors :sub-return — :recovery :no-recovery: the pinned value renders as pinned, and the failure is reported. |
:machine-data |
A machine snapshot's :data slot against its [:schemas :data] schema — in the CANDIDATE runtime-db at macrostep / bootstrap, or at spawn (010 §Per-step recovery row 7). |
Reject the candidate frame transition (macrostep / bootstrap: :rollback? true — the whole event transaction is rejected before install), or skip the single local write (spawn: :rollback? false — nothing committed, the actor never enters the runtime). :recovery :no-recovery. |
:machine-output |
A finishing machine's completion :output ([:schemas :output]) payload at finalize time, :phase :completion (EP-0029 A8; 005 §Completion-output validation). |
Best-effort — complete and proceed — the machine has ALREADY reached its final state, so there is nothing to roll back: :rollback? false, :recovery :no-recovery. The completion still flows (:on-done payload and :rf.machine/done trace unchanged); the failure is surfaced loudly so a schema typo cannot deadlock a machine. |
A validator that THROWS on a boundary's schema reports :rf.error/malformed-schema at the same :where instead, and the boundary applies the same recovery: the throw decided nothing about the value, so no boundary reads it as a pass.
There is no
:cofxboundary. A recordable-coeffect value that violates itsreg-cofx:schemais NOT a:where :cofxcase of this category (per EP-0017 and 010 §Per-step recovery row 2). It is the separate production hard error:rf.error/cofx-value-invalid— emitted and thrown, halting the run before the handler — because folding an out-of-contract value into the durable causal ledger is corrupt state that must fail in production too.:cofxis therefore absent from this category's:whereenum. A validator that throws on that:schemareports:rf.error/malformed-schemaat:where :cofxfirst, a development trace beside the same hard error.
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 registration, before the machine-id is bound (it 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 added by the registrar wrapper (the reg-machine / reg-machine* surface), the layer that knows the registration-site id; the pure validator cannot supply it. The wrapper catches each framework registration error its validators raise, the history-grammar errors among them, and rethrows it with :machine-id in its ex-data and leading its message, the original as the cause; an error that already names a :machine-id passes through unchanged. A consumer correlating a history-grammar rejection to a machine therefore reads :machine-id off the thrown ex-data — not from :rf.machine.lifecycle/created, which fires only once a registration succeeds.
Fresco — the interpreted hiccup substrate (EXPERIMENTAL)¶
Fresco is the interpreted hiccup substrate packaged at implementation/fresco/ — a public door re-frame.fresco (the defview / defhost authoring macros) over internals in re-frame.fresco.impl.*, alongside re-frame.fresco.native — exactly two React hooks, use-sub and use-frame, for a UIx or raw React island mounted through defhost that needs Fresco state; nothing in the interpreted path requires it, an application that never asks for it does not carry it, and it mints no refusals of its own. It walks arbitrary hiccup into React elements at runtime, and it is EXPERIMENTAL: shipped for measurement alongside the first-class adapters/, not offered as a supported surface. Its design record lives under docs/design/fresco/; it has no normative feature Spec yet, so the rows below name the emitting namespace and fn as the emit-site of record in place of the "Per [N]" cross-link every other tier carries. When a Fresco Spec lands, these rows gain their link and lose nothing else.
The rows here are the meaning, payload and recovery of each id and are the normative statement of all three. A row means the runtime raises this — CHECK C of scripts/check_keyword_catalogue_drift.py enforces exactly that — so a spelling reserved for a refusal whose surface is not built yet has no row. The Fresco package's own complaint register (implementation/fresco/spec/complaints.md) is an index of the live ids, each with the guide chapter that teaches how not to hit it; it restates no meaning, and this section stays the normative statement of every live id.
Every row below is a thrown ex-info, never a trace event, so all of them are diagnostic by the §Channel reading ("a thrown ex-info rejection the caller observes at its own call site is diagnostic-channel for catalogue purposes"), and the sixth column names flat ex-data slots rather than :tags keys, per §Reading the two right-hand columns. The four slots §The thrown-error shape requires — :rf.error/id, :where, :recovery, :reason — ride every one of them and are not restated per row; (none) in that column means the throw carries nothing beyond those four. Every Fresco throw carries :recovery :no-recovery — a refusal is a throw the runtime does not recover from, and the fix lives in :reason — so the Default :recovery column reads :no-recovery on every live row here.
The throws are built by one constructor, re-frame.fresco.impl.error/fail! — (fail! id where reason extra) — through re-frame.error/ex-info-from-data, so the shape it produces is the canonical one: message "<reason> [<id>]", ex-data {:rf.error/id … :where … :reason … :recovery :no-recovery} over the per-arm payload — the four canonical slots merge OVER extra, never under it, and the two ambient keys are REMOVED from extra before that merge, so a per-arm payload names its own class's slots and cannot erase or replace one this section says rides every row.
Every refusal in the package routes through that one constructor. Two consequences are catalogue facts rather than implementation detail:
- Two ambient slots ride every row, in dev builds only.
:view(the"<ns>/<sym>"of the boundary rendering when the refusal was minted) and:source({:ns :file :line :column}, captured bydefview/defhostat macro-expansion time). Both are supplied by the constructor rather than by the call site, so neither appears in the sixth column below; both are absent — not nil — under:advanced+goog.DEBUG=false, and absent for any refusal raised outside a boundary body and outside a declaration (:rf.error/fresco-sub-outside-renderis the ordinary case: no boundary is rendering, which is the refusal's whole subject). Erasure is proved byre-frame.fresco.error-source-coord-elision-prod-testand byimplementation/fresco/scripts/check_source_coord_elision.cjs. - The sixth column is the situational half of the shape, and it is per-CLASS by construction. §3.6 of the Fresco specification (
docs/design/fresco/product/specification.md) names eight things a refusal carries; four of them (id, source coordinate, view, recovery) are the constructor's own and are stated above, and the remaining four — frame where relevant, tree path or host-prop position, offending value, expected shape — are not universal and are therefore not enforceable at the constructor.:rf.error/fresco-empty-vectorhas no offending value because the offence IS the absence of a head;:rf.error/no-frame-contexthas no frame because the offence is that there is none. Those rows read(none)and that is a recorded absence rather than a gap. Where a class does carry one it carries it under its own spelling —:child,:head,:query-v,:option,:declared,:instance-key— and this column is where that spelling is stated.
Read the group as one posture. Each refusal exists to delete a silent failure, and the trigger cells say which one: an intent that would cross a foreign ABI as inert data and never dispatch, a declared policy that would be set and never applied, an option quietly ignored, an unkeyed child that cannot be animated out, a reset trigger fired at a field that cannot receive it. Where the substrate can repair a value it repairs it silently and there is no row; the rows below are the cases where repairing would mean overruling what the author wrote.
Fresco also names two corpus ids already rowed above rather than minting private spellings for them: :rf.error/no-frame-context (its boundary shell's refusal when React context carries no frame, re-frame.fresco.impl.collector/shell) and :rf.error/routing-artefact-missing (the route-link door's missing-artefact descriptor, re-frame.fresco.impl.route-link). Its cold-read path additionally reaches core's own :rf.error/no-such-sub and :rf.error/frame-destroyed recoveries unchanged, through subs/compute-sub-with-memo and subscribe-once — reached, not re-emitted, so neither is a Fresco emit site.
:operation |
:op-type |
Channel | Trigger / meaning | Default :recovery |
:tags |
|---|---|---|---|---|---|
:rf.error/fresco-empty-vector |
:error |
diagnostic | An empty hiccup vector reached the interpreter. Refused at the top of vec->element, ahead of any head classification, because every branch below it reads position 0. Thrown ex-info; :where 're-frame.fresco.impl.codec/vec->element. Emitted by re-frame.fresco.impl.codec |
:no-recovery |
(none — the four required slots only) |
:rf.error/fresco-bad-head |
:error |
diagnostic | A hiccup vector's head is outside the closed set of element classes: a native tag (keyword, symbol or string), the fragment head :<>, the raw-escape head :>, a defview product, or a defhost product. TWO arms, discriminated by :reason and by the recovery rather than by a second id — a PLAIN FUNCTION in head position gets its own reading, because a function head is never a silent embedding (it is called, or made a view), and every other value takes the generic one. A raw JS component is likewise never a silent embedding: declare it with defhost, or write the [:> Component …] escape. Thrown ex-info; :where 're-frame.fresco.impl.codec/vec->element. Emitted by re-frame.fresco.impl.codec |
:no-recovery |
:head (the value that was written in head position — the offending value, on both arms) |
:rf.error/fresco-true-child |
:error |
diagnostic | true reached child position. nil and false render nothing, so a guard that evaluates to either is inert by design; true is refused rather than silently dropped, which is what makes a (when …) mistyped down to a bare boolean loud instead of invisible. Thrown ex-info; :where 're-frame.fresco.impl.codec/as-element. Emitted by re-frame.fresco.impl.codec, and by re-frame.fresco.test's L2 walk (:where re-frame.fresco.test, plus :value) so the test kit refuses a true child under the id the runtime raises for it rather than dropping it |
:no-recovery |
(none — the four required slots only, plus :value from the test kit) |
:rf.error/fresco-deferred-read-at-boundary |
:error |
diagnostic | An UNFORCED delay was reachable from a boundary's props. Raised by the crossing walk that forces lazy sequences, so the stack lands inside the render of the body that WROTE the delay rather than on the child that would otherwise be blamed. Forced inside the child's render instead, any subscription the delay reads becomes the child's edge, is cached by the delay, and is dropped the next time the child renders — a value correct on screen, frozen thereafter, and attributable to nothing. The substrate refuses rather than forcing, because forcing would change what the author's delay means. A delay the author already deref'd carries a computed value, derefs to it without calling anything, and is harmless wherever it goes. Thrown ex-info; :where 're-frame.fresco.impl.codec/boundary-element. Emitted by re-frame.fresco.impl.codec |
:no-recovery |
:value (the unforced delay) |
:rf.error/fresco-raw-not-a-component |
:error |
diagnostic | The [:> Component …] escape was handed one of the three values React would accept or misreport into silence, and :reason names which: nil (or nothing at all) — the broken-import symptom, :default against a library with no default export, which React names only at fiber creation, post-adoption and client-only, so a server render would never reach it; a defview product, which is fn?-true, so React would mount the shell raw and the body would receive nil props; or a defhost product, which is already a declaration and a head in its own right. Any other bad type — a keyword, a map, a React element — crosses, and React's own Element type is invalid reports it. Thrown ex-info; :where 're-frame.fresco.impl.codec/raw-element. Emitted by re-frame.fresco.impl.codec |
:no-recovery |
:component (the value refused), :argv-count (the vector's length: 1 when the vector ended at the head) |
:rf.error/fresco-bad-host-declaration |
:error |
diagnostic | A defhost declaration is outside its shape, and :reason names which: options that are not a map (usually a docstring written AFTER the component, which the macro's probe then binds to opts); an option key outside the closed roster #{:slots :server :fallback} — an :ssr key (the policy option is :server) lands here, with no alias, and so does :callbacks, because a carrier's kind is its meaning and no declaration names a callback; a :slots value that is not a set of ordinary prop names — a non-set, an entry that cannot name a prop, key/ref, or two spellings landing on one canonical slot; or a FORM after the options map, which the macro's variadic destructure would otherwise drop in silence (a second options map is not merged). One id because every arm is the same fault — this declaration does not say what it appears to say — with one recovery, fixing the declaration; and every arm is raised at namespace load inside the declaration extent, so it carries the declaration's :view / :source. The nil-component case keeps its own id (:rf.error/fresco-host-no-component), as do the server-policy and fallback-contents refusals. Thrown ex-info; :where 're-frame.fresco.impl.codec/mint-host! on every arm but the extra-form one, which is 're-frame.fresco/defhost. Emitted by re-frame.fresco.impl.codec |
:no-recovery |
:host on every arm; then per arm :options (the non-map value), :option + :options (the offending key and the roster), :slots plus :position / :slot as the slots arm has them, or :extra (the discarded forms, as data) |
:rf.error/fresco-host-no-component |
:error |
diagnostic | A defhost declaration was given nil as its component. Refused AT THE DECLARATION rather than at the first render, so the author's stack is the declaration site; the render-time alternative is React's own error, which names nothing the author wrote. The usual cause is again a JS import that resolved nothing. Thrown ex-info; :where 're-frame.fresco.impl.codec/mint-host!. Emitted by re-frame.fresco.impl.codec |
:no-recovery |
:host (the declaration's qualified name) |
:rf.error/fresco-host-bad-ssr-policy |
:error |
diagnostic | A defhost declaration does not name a server policy the door can honour. TWO faults, one id, because they are one question. Either the :server value is outside the two the matrix admits — :client-only (the default, and what an author who writes nothing gets — the host region renders nothing until the client adopts it) and :render (the component itself is safe to run on the server, and does) — or the declaration carries the sibling :fallback where it cannot belong. :render is an ASSERTION rather than a description, which is why the spellings authors reach for instead — :children, :transparent, :passthrough — stay refused: they assert a structural property nobody can check and deliver the subtree under the WRONG context value. :fallback is Client-only's placeholder markup and belongs to that arm ALONE: under :render the component renders on the server, so there is nothing for a placeholder to stand in for, and a declaration carrying both says two incompatible things. An explicit :fallback nil is refused with them — a value rather than an absence, exactly as :server nil is. The id is spelled ssr although the option is :server, because an id names a refusal, not an option (complaints.md, Rulings this catalogue owns). Thrown ex-info; :where 're-frame.fresco.impl.codec/mint-host!. Emitted by re-frame.fresco.impl.codec |
:no-recovery |
:host, :server (the rejected policy value, or the policy the fallback could not belong to), :fallback (present only when the fallback is the fault) |
:rf.error/fresco-host-fallback-boundary-head |
:error |
diagnostic | A defhost declaration's :fallback carries a defview or defhost head at some position inside it. A declared fallback is INERT MARKUP: it is walked into ONE element at the declaration, outside any frame, and that element is reused at every site of the host — so a head whose body runs LATER makes one declared placeholder render a different document per frame and per write, which is not a placeholder. The walk is STRUCTURAL rather than evaluating, and that is the whole of the enforcement: a fallback's other refusals are what the element walk happens to evaluate on its way, while a boundary head is precisely the thing that walk never looks inside, so every refusal it carries would otherwise be deferred past the declaration. Thrown ex-info; :where 're-frame.fresco.impl.codec/mint-host!. Emitted by re-frame.fresco.impl.codec |
:no-recovery |
:host, :head (the offending head's displayName, or <unnamed>), :position (the index route into the declared form: [] the fallback itself, [0] its head position, [2 0] the head of its third element), :kind (defview / defhost) |
:rf.error/fresco-host-unclaimed-callback |
:error |
diagnostic | A carrier — h/event or h/render-fn — arrived at a defhost prop the declaration named a ReactNode position in :slots. The slot is claimed, but for MARKUP — it lowers hiccup under the frame of the boundary that wrote the crossing — so it has no meaning to give a carrier, and a function crossing as a ReactNode renders nothing, silently. A PLAIN function at the same slot is untouched: it never asked for anything, and this refuses the unanswered REQUEST, never the marked form itself, which is legal at every other position. A carrier at a prop nothing claimed is not this id: a carrier's kind is its meaning at every other prop. Thrown ex-info; :where 're-frame.fresco.impl.codec/host-element. Emitted by re-frame.fresco.impl.codec |
:no-recovery |
:host, :position (the prop key), :slots (the set of declared ReactNode slots) |
:rf.error/fresco-overlay-anchor-missing |
:error |
diagnostic | An overlay's :anchor names a DOM id no element in the document carries. :anchor is how a popover asks to be positioned against a trigger: the module gives that element a generated CSS anchor name and points the panel's position-anchor at it, so an id resolving to nothing leaves a dangling reference and the engine falls back to the UA's default top-layer position. The panel still opens — visibly unanchored rather than silently mispositioned. What makes it worth refusing is that the failure is indistinguishable from a choice: a typo in the id, a trigger whose row key moved, and a trigger that renders one commit later all look exactly like an author who wanted the default position. Omitting :anchor is legal and silent, and stays so — a modal takes none, and a popover without one is asking for the default rather than failing to find a trigger; the refusal is about a name that resolves to nothing, never about an absent one. Raised from the REF CALLBACK, the first moment the id can honestly be resolved: React has already applied this commit's DOM mutations there, so the same check during render would be reading the previous document. A commit-phase throw reaches an enclosing h/error-boundary, and reportError when there is none, so a witness reads that channel rather than a cljs.test stack. Thrown ex-info; :where 're-frame.fresco.impl.overlay/claim-anchor!. Emitted by re-frame.fresco.impl.overlay |
:no-recovery |
:anchor (the unresolved DOM id) |
:rf.error/fresco-boundary-unknown-prop |
:error |
diagnostic | An h/error-boundary props map carried a key outside the closed roster #{:on-error :reset-key :fallback :children}. The class consumes exactly four keys, so without the refusal {:on-errors …}, {:resetKey …} and {:fall-back …} would mint, cross the boundary hand-off intact, and be consulted by nothing — the policy set and never applied class :rf.error/fresco-bad-host-declaration deletes at a defhost declaration, reached on the one surface where the silent version is worst: an error boundary that reports nothing while wearing a declaration that says it does. Refused in the class's own render, which React always runs before it can run componentDidCatch, so the refusal is raised on the first paint and never on the error path it would otherwise destroy. :children is in the roster because impl.codec/boundary-element writes it from the trailing forms; :key is stripped before the hand-off and cannot reach the map. Thrown ex-info; :where 're-frame.fresco.impl.boundary/boundary. Emitted by re-frame.fresco.impl.boundary |
:no-recovery |
:prop (the offending key), :props (the closed roster, for the message) |
:rf.error/fresco-boundary-bad-on-error |
:error |
diagnostic | An h/error-boundary's :on-error is neither an intent vector nor a function, so nothing can fire it. report!'s arms are vector? and fn?, so without the refusal {:on-error :app/boundary-failed} — a bare intent keyword, which is what somebody writes who has not yet noticed intents are vectors here — would swallow every caught error and report none. Raised in render rather than in report! deliberately: a throw from componentDidCatch replaces the application's real error with a complaint about the declaration and escapes to the next boundary up, so validating the shape where it is CONSUMED would cost the error path the check exists to protect. nil is legal and means no reporting was asked for. Thrown ex-info; :where 're-frame.fresco.impl.boundary/boundary. Emitted by re-frame.fresco.impl.boundary |
:no-recovery |
:value (the rejected :on-error) |
:rf.error/fresco-frame-config-misplaced |
:error |
diagnostic | The root door (h/render!) was handed :frame or :initial-events in its opts. Frame configuration is the TREE's — [h/frame-root {:id …}] ENSUREs and takes the whole rf/make-frame option map, [h/frame-provider {:frame …}] SCOPEs — so the root door carries React-root options only. Its own id rather than the unknown-option one because the caller is not confused about the roster: they wrote a frame key at the wrong door, and the reason names the head that takes it. Thrown ex-info; :where 're-frame.fresco/render!. Emitted by re-frame.fresco.impl.mount |
:no-recovery |
:option (the misplaced key), :config (the config as given) |
:rf.error/fresco-unknown-root-option |
:error |
diagnostic | The root door's opts carried a key outside its roster (#{:hydrate? :identifier-prefix :on-recoverable-error}), or were not a map at all. The No-silent-swallow rule: a door that ignored every other key without complaint would drop an :fx-overrides handed to a mount on the floor. Thrown ex-info; :where 're-frame.fresco/render!. Emitted by re-frame.fresco.impl.mount |
:no-recovery |
:option + :roster (the offending key and the roster), or :config alone when the config is not a map |
:rf.error/fresco-frame-root-adopting |
:error |
diagnostic | A frame-root was reached by a hydrating root's tree. Its first render is empty (it makes the frame at commit and its children arrive on a second pass), and React does not report an unclaimed server node directly under a root, so the server's markup would stay in place and the client tree would mount beside it: the page twice, with no :rf.ssr/hydration-mismatch and no recoverable error. Two kinds of root refuse it. Fresco: the hydrating door (h/render! with {:hydrate? true}) refuses an h/frame-root in the tree it lowers, before hydrateRoot runs, so the container keeps the server's markup untouched and no root is made; an h/frame-root reached only from inside a view body lowers during React's render, which the door never sees, so it refuses in the hydration pass itself: React renders that root again on the client, the page appears once, and the refusal reaches onRecoverableError as the cause of React's recovery error. :where 're-frame.fresco/frame-root; emitted by re-frame.fresco.impl.frame-boundary. Reagent and UIx: a hydrating root provides its adoption flag through re-frame.adapter.context/adoption-context, and a frame-root rendered while that flag is up refuses in its own first render, before React commits and before any frame is made; React reports it as an uncaught render error instead of mounting the page twice. :where 're-frame.views.frame-boundary/frame-root-fc; emitted by re-frame.views.frame-boundary. Thrown ex-info on both |
:no-recovery — make the frame with rf/make-frame, install the payload with ssr/hydrate!, then adopt under frame-provider ([h/frame-provider {:frame …}] in Fresco), which scopes a frame that already exists |
:frame (the frame-root's :id) |
:rf.error/fresco-intent-outside-boundary |
:error |
diagnostic | An intent was lowered, or fired, with no frame-locked dispatch bound. FOUR arms, one id, because the condition is one: (a) LOWERING with no ambient dispatch — the message deliberately offers two readings, since a nil binding cannot distinguish them and they want opposite repairs: either the form really is outside any boundary's render (a declaration, a defhost fallback, a module-level def), or it sits inside a function a foreign component invokes LATER, after the render window unwound, which is exactly what a function prop on a [:>] crossing is (wrapping the function in h/render-fn, which carries the supplying frame, is the whole of that repair); (b) an event CALLBACK returned an intent vector with no dispatch in scope; (c) a handler lowered INSIDE a render callback dispatched after the render returned, but the callback itself was lowered with no owner dispatch — a render callback's handlers fire into the frame of the boundary that SUPPLIED it, and there was none; (d) an OPEN overlay carrying :on-dismiss with no frame dispatch in scope — :on-dismiss is an intent the overlay module routes itself, and the element would otherwise tell the platform it may dismiss (popover="auto", closedby="closerequest") while nothing could route the dismissal, leaving app-db's open flag with a second owner; refused at render, before any element exists. A frameless overlay WITHOUT :on-dismiss stays legal — popover="manual" / closedby="none", one owner. Thrown ex-info; :where 're-frame.fresco.impl.intent/lower-prop on (a)–(c) and 're-frame.fresco.impl.overlay/dismissal-handler on (d). Emitted by re-frame.fresco.impl.intent and re-frame.fresco.impl.overlay |
:no-recovery |
arm (a) :intent; arm (b) :position + :intent; arm (c) :position + :event; arm (d) :intent |
:rf.error/fresco-intent-needs-the-event |
:error |
diagnostic | An intent vector that reads the DOM event was invoked with a first argument that has no such property. The vector spelling is EVENT-FIRST — ::h/prevent, ::h/value, ::h/checked, :on-submit's auto-prevent and the key-map's key lookup all take the event from argument ONE, which is what every native position and every event-first foreign contract hands it — and a VALUE-FIRST foreign invoker such as a date picker's (on-change date) has no event there. Nothing can guess which of a library's arguments is the event, and a runtime that went looking would be reintroducing inference at the seam where it is forbidden. The check IS the read the closure was about to perform, so the diagnostic names the missing capability rather than asserting a type, and it is paid only by closures that actually read the event: an intent carrying no marker and no prevent never touches its argument and is correct under any invoker contract. Without it the author gets the engine's own value.preventDefault is not a function, naming nothing they wrote. Thrown ex-info; :where 're-frame.fresco.impl.intent/lower-prop. Emitted by re-frame.fresco.impl.intent |
:no-recovery |
:position (the prop key), :form (the intent as written), :argument (what actually arrived at argument one), :needed (the property the closure was about to read — preventDefault, target, key) |
:rf.error/fresco-malformed-prevent |
:error |
diagnostic | The ::h/prevent decorator is outside its closed grammar. It wraps EXACTLY ONE non-empty intent vector that is not itself a decorator, and the message names which of the four ways it failed: more or fewer forms after the head, a non-vector inner, the empty vector (which names no event), or a nested decorator (they do not nest). There is no options map, no second decorator and no modifier language — the reserved-head roster is a LIST of two, which is what keeps the grammar closed rather than conventional. The classification is taken once per render at the one position lowering already has in hand; nothing descends into the intent, which stays ordinary data all the way to dispatch. Thrown ex-info; :where 're-frame.fresco.impl.intent/lower-prop. Emitted by re-frame.fresco.impl.intent |
:no-recovery |
:position (the prop key), :form (the decorator as written) |
:rf.error/fresco-route-link-outside-boundary |
:error |
diagnostic | route-link was rendered with no ambient frame. A link is legal only inside a boundary's render, because the navigation must be pinned to the frame that rendered it: a browser click fires long after the render's dynamic extent has unwound, so the frame is captured AT RENDER and travels as data in the navigate vector. Thrown ex-info; :where 're-frame.fresco.impl.route-link/route-link. Emitted by re-frame.fresco.impl.route-link |
:no-recovery |
:to (the link's address) |
:rf.error/fresco-route-link-bad-on-click |
:error |
diagnostic | route-link's :on-click is outside the route-click roster: nil, [::h/prevent [:my-event …]] (cancel the navigation and dispatch this instead), an h/event, or a plain function. A bare intent vector is the taught mistake and gets the teaching sentence — the click already produces the one routing intent, so an application reaction belongs behind the routing event, or inside the prevent decorator if it replaces the navigation. The same roster is enforced at LOWERING by the navigate decorator's veto arm; it is stated twice because the two failures land differently, and this one fails AT RENDER, at the site that wrote the link, with the render stack, before any anchor exists. This refusal and the frameless-render one both run BEFORE the routing link model is consulted, so a mistake in the props the author wrote is what the stack names rather than routing's reading of them. :rf.error/fresco-route-link-claimed-intent-position is the deliberate exception, and necessarily so — the positions it polices are routing's to name, so it can only run once the model has named them. Thrown ex-info; :where 're-frame.fresco.impl.route-link/route-link. Emitted by re-frame.fresco.impl.route-link |
:no-recovery |
:on-click (the rejected value) |
:rf.error/fresco-route-link-claimed-intent-position |
:error |
diagnostic | route-link was given :prefetch :intent — which claims routing's credible-intent positions, :on-mouse-enter / :on-focus / :on-touch-start — and ALSO a caller value at one or more of them. One intent per position is Fresco's in-band grammar, and the hover already produces the one prefetch intent, so there is nothing to compose with; the repair is to drop :prefetch and spell the warm-up by hand at the positions not otherwise wanted (:on-mouse-enter [:rf.route/prefetch {…}], the general form the sugar abbreviates), or to move the caller value elsewhere. All-or-nothing: a silent skip would leave the link warming at two positions out of three, and a partially-passive link looks identical to a working one until you measure — the same failure :prefetch's own value check exists to refuse. The claimed positions come off the :routing/link-model seam's :prefetch-keys, never a copy of the list in Fresco, so a position routing adds is policed here without an edit. Distinct from :rf.error/route-link-bad-prefetch, which routing raises for a bad :prefetch VALUE before this check is reached: this id means the value is good and the position is taken. Thrown ex-info; :where 're-frame.fresco.impl.route-link/route-link. Emitted by re-frame.fresco.impl.route-link |
:no-recovery |
:positions (the claimed positions the caller also filled), :prefetch (:intent) |
:rf.error/fresco-presence-child-unkeyed |
:error |
diagnostic | A presence child carries no :key — a child that is not a hiccup vector included, since it has no props map to read one off. nil and false children render nothing and are not entries at all. The key IS the identity the retention state machine runs on, so a child without one has no way to be the same child next render: it cannot be recognised across a render, and therefore cannot be animated out. Thrown ex-info; :where 're-frame.fresco.impl.presence/child-key. Emitted by re-frame.fresco.impl.presence |
:no-recovery |
:child (the offending child) |
:rf.error/fresco-presence-timeout-required |
:error |
diagnostic | A presence boundary's :timeout-ms is absent, non-numeric, or not positive. It is MANDATORY because it is the retention length AND the hard terminal bound at once: presence does not wait on transitionend, so a boundary without one is a boundary whose children can be stuck on screen forever if CSS fails or is disabled. Inherited unchanged by nested boundaries. Thrown ex-info; :where 're-frame.fresco.impl.presence/check-timeout!. Emitted by re-frame.fresco.impl.presence |
:no-recovery |
:timeout-ms (the rejected value) |
:rf.error/fresco-revision-not-controlled |
:error |
diagnostic | The :re-frame.fresco/revision reset trigger reached an element that is not a controlled text field. The marker is read and DELETED on every native element before anything else can see it — the delete is the whole of "never a DOM attribute" — and the element is then refused unless it is an input or textarea carrying a non-nil :value to re-baseline TO. A nil :value on a text field — a textarea, or an input whose type is not a token, label or filename type — is the empty field "" by then, so it is ACCEPTED and re-baselines to empty; a nil :value on a checkbox, radio, hidden, submit, reset, button, image or file input stays no value and is refused like a value-less one. The acceptance predicate is the same one that already selects the shadow component, reused rather than duplicated, and it is deliberately type-blind, so the coverage is worth stating exactly: a :div, a select, a value-less <input> and a checkbox written idiomatically with :checked and no :value are all refused, while a checkbox carrying a form-submission value is ACCEPTED with the revision simply inert there. Thrown ex-info; :where 're-frame.fresco.impl.controlled/install!. Emitted by re-frame.fresco.impl.controlled |
:no-recovery |
:tag (the element's tag) |
:rf.error/fresco-file-input-value-marker |
:error |
diagnostic | ::h/value was materialized at an <input type="file">. HTMLInputElement.value is in FILENAME MODE on that control: it answers the literal C:\fakepath\ followed by the first selected file's name. So without the refusal [:app/upload ::h/value] on a file input would be a correct-looking expression that quietly means something else — one file named out of however many were chosen, over a path the HTML specification mandates as a deliberate privacy fiction, which nothing downstream can open or turn back into a File. Nothing would throw or warn, and the shape is a plausible non-empty string indistinguishable from an honest answer. Refused rather than corrected, and the asymmetry with the <select multiple> sibling defect is deliberate: the multi-select is a CORRECTION because ::h/value already means the control's current value and a multi-select's current value is its selection, so reading .value there would answer the marker's own question badly. A file input is not that case — the Fresco guide rules it OUT of the marker's surface on purpose (supported controls, File input: no controlled value, with :on-change and an h/event reading .files as the whole of its event form, because the platform owns their value) — so answering with a FileList would WIDEN the marker onto a control the design excludes. The discriminator is .files, which the platform already provides: a FileList on this control, null on every other <input>, absent entirely off an input. It is asked first on the marker path because this is the one control that must not reach .value at all; every other control pays one property read and no allocation. An EMPTY file input is refused too — the control is the wrong one for this marker whatever it currently holds, and a refusal that waited for a selection would fire on the user's action rather than the author's mistake. The same control's other half — a non-empty :value on the prop — is left to the platform's own InvalidStateError. Thrown ex-info; :where 're-frame.fresco.impl.intent/target-value. Emitted by re-frame.fresco.impl.intent |
:no-recovery |
:value (the filename-mode string the marker would have handed over) |
:rf.error/state-bad-argument |
:error |
diagnostic | An argument to reg-state, or to the sub, setter event or shared clear event it registers, is outside the contract, and :reason names which. Three arms: a concern that is not a namespace-qualified keyword (it is a sub id, an event id and an app-db key at once, so an unqualified one collides with every other feature on the page in three registries); options that are not a map or carry a key outside #{:default} (an option quietly ignored is a setting its author believes is in force); and an instance key outside the accepted set — a keyword, string, number, UUID, or vector of those — checked at READ, at WRITE and at CLEAR alike, nil excluded deliberately because nil is what a missing prop, a mistyped destructure and a forgotten argument all evaluate to, and nil as an instance key is the every-instance-shares-one-value pitfall. Thrown ex-info; :where 'rf/reg-state on the registration arms and on a read or write, 'rf.state/clear on a clear. Emitted by re-frame.core-state |
:no-recovery |
:concern on every arm; then :options (the non-map value) or :unknown (the rejected keys, sorted) on the options arm, :instance-key + :op (:read / :write / :clear) on the key arm |
:rf.error/fresco-sub-outside-render |
:error |
diagnostic | A subscription read happened outside a boundary render. sub is legal only inside a defview body, because a read outside one has no render pass to be fenced by and no boundary to record the edge against — the read-set entry the substrate builds during a body run is what the boundary's subscription hook closes over. subscribe-once is the sanctioned snapshot for handler and utility code. A React island's re-frame.fresco.native/use-sub hook reads through useSyncExternalStore, not this read, so it never raises this id; outside every frame it refuses with :rf.error/no-frame-context. Thrown ex-info; :where 're-frame.fresco.impl.collector/read-key!. Emitted by re-frame.fresco.impl.collector |
:no-recovery |
:query-v (the query vector that was read) |
:rf.error/fresco-generation-fence-exhausted |
:error |
diagnostic | A boundary body observed a NEW commit on each of four consecutive runs. The generation fence re-runs a body once per commit that lands while it is running, so that all of a pass's reads observe ONE commit; three re-runs is a ceiling rather than a budget, and a fourth commit arriving inside three consecutive runs is a write loop, where failing loudly beats spinning. The fence compares the commit BASIS rather than the generation alone, because the generation cannot see a mid-body move of a key nothing holds — no cell, so no watch, so no bump — while the frame's install epoch moves for that write regardless. Thrown ex-info; :where 're-frame.fresco.impl.collector/render-body. Emitted by re-frame.fresco.impl.collector |
:no-recovery |
:frame (the frame keyword), :generation |
Adapter-internal and test-harness categories¶
The catalogue's terminal tier records :rf.error/* ids emitted by a substrate adapter or the test harness rather than the re-frame2 runtime proper. They are catalogued for TOTALITY — every emitted id carries a row — but kept terse (id, one-line meaning, recovery, payload keys), and all ride the diagnostic channel (dev/test-only; never the always-on production error-emit axis).
Test-harness¶
The conformance-corpus DSL evaluator runs the cross-port conformance fixtures. The siblings :rf.error/conformance-unknown-dsl-op / -unknown-fn-builtin are catalogued in the main table above.
:operation |
:op-type |
Channel | Trigger / meaning | Default :recovery |
:tags |
|---|---|---|---|---|---|
:rf.error/conformance-cannot-count-nil |
:error |
diagnostic | A conformance-corpus :count builtin was applied to nil. |
:no-recovery — count a countable value |
:where |
:rf.error/conformance-cannot-count-string |
:error |
diagnostic | A conformance-corpus :count builtin was applied to a string / char. |
:no-recovery — count a countable value |
:where |
:rf.error/conformance-throw-step |
:error |
diagnostic | A conformance-corpus :throw DSL step raised its fixture-supplied message (the corpus's deliberate-throw op). |
:no-recovery — expected for a :throw fixture |
:where, :from-fixture? |
Fresco test kit (re-frame.fresco.test, L0–L2)¶
re-frame.fresco.test is the Fresco substrate's supported testing surface. Its ids fall into two families, and the split is the surface's whole design: opacity refusals, where the harness declines an input it cannot model honestly and names the tier that can (a harness answering a plausible value for a host crossing or a raw React element is worse than one that declines — the plausible answer is believed), and misuse refusals, ordinary shape rejections at a public door. All are thrown ex-infos carrying :rf.error/id, all ride the diagnostic channel, and none is production-reachable: no shipping namespace requires the kit, so only a test reaches it.
The table below is not the whole of what the kit throws. Three further ids reach it, each rowed elsewhere rather than re-minted here, and the omission is worth stating because a reader takes a per-surface table as a roster: :rf.error/fresco-true-child and :rf.error/fresco-deferred-read-at-boundary (both in the Fresco runtime table above — the kit raises the runtime's own id for the condition rather than a kit-private twin), and the corpus-owned :rf.error/ui-tree-malformed (rowed in the main catalogue above), which the kit raises for values outside the structural tree's grammar — a non-data attribute value, a bad hiccup head, a child outside the value grammar, a node that is not [head attrs & children], a boundary prop named :view-id, and the attrs / text projections applied to something that is not a node. That row's own Trigger cell rosters its emitters by namespace and names re-frame.fresco.test among them; it is said again here because this is where the surface is the subject.
:operation |
:op-type |
Channel | Trigger / meaning | Default :recovery |
:tags |
|---|---|---|---|---|---|
:rf.error/fresco-test-boundary-body-not-retained |
:error |
diagnostic | An L2 tree was given a minted h/defview head carrying no body. mint-view! attaches the body to the head under a dev-only own property, so an ordinary dev build renders a minted head; under :advanced with goog.DEBUG false the property is never written and there is nothing to run without React. |
:no-recovery |
:where, :reason, :view |
:rf.error/fresco-test-host-is-opaque |
:error |
diagnostic | A h/defhost crossing reached the L2 semantic tree. What a foreign React component renders is React's answer, not Fresco's. |
:no-recovery |
:where, :reason, :host, :value |
:rf.error/fresco-test-react-is-opaque |
:error |
diagnostic | A raw React element reached the L2 semantic tree. A value only React can interpret has no semantic form there. | :no-recovery |
:where, :reason, :value |
:rf.error/fresco-test-missing-read-fixture |
:error |
diagnostic | An L2 body read one or more subscriptions no injected fixture answers. A missing fixture refuses rather than resolving to nil, so a body reading the wrong key cannot look like one reading the right key and finding nothing. | :no-recovery |
:where, :reason, :missing, :supplied, :phase |
:rf.error/fresco-test-plain-fn-head |
:error |
diagnostic | A plain function appeared in a hiccup head position inside an L2 tree. Fresco itself refuses that (HD-016), so the kit does too rather than teaching a spelling the runtime rejects. | :no-recovery |
:where, :reason, :value |
:rf.error/fresco-test-not-a-body |
:error |
diagnostic | An L2 tree form's head is not the body function a h/defview is minted from. |
:no-recovery |
:where, :reason, :value |
:rf.error/fresco-test-not-a-render-form |
:error |
diagnostic | An L2 tree was given something other than a hiccup form. The id's render-form spelling stands for the reason -bad-reads below gives: an id names the refusal, not the vocabulary around it. |
:no-recovery |
:where, :reason, :value |
:rf.error/fresco-test-bad-reads |
:error |
diagnostic | An L2 tree's :subs option is not a map from query vector to value. The id is spelled -bad-reads although the option is :subs — an id names the refusal, not the option (see the register's Rulings this catalogue owns). |
:no-recovery |
:where, :reason, :value |
:rf.error/fresco-test-bad-option |
:error |
diagnostic | A test-kit door was given options outside their closed contract. Every door that takes an options map has TWO arms: a non-map opts value, and an option key outside that door's closed roster — L2's tree #{:subs}; re-frame.fresco.test.mounted/mount! #{:initial-events :images :container :clock}; re-frame.fresco.test.mounted/hydrate! that roster plus :html, delivered as the rejection of the promise it returns, like that door's every failure; re-frame.fresco.test.mounted/shadow! #{:reference :candidate :images :initial-events}. The second arm is the one worth the id — each door reads its options by destructuring, which validates no key set, so without the check an unknown option would be accepted in silence, a setting its author believes is in force: in tree a typo or a :reads key would render a body reading nothing as if the fixtures were in force. The mounted doors check before they allocate anything, the virtual clock included. re-frame.fresco.test.mounted/advance-clock! refuses a handle whose mount was not given {:clock true}, or whose clock came down with it — an advance with no clock under it would move nothing and assert nothing. |
:no-recovery on every arm; the :reason says which arm refused |
:where, :reason, plus: an options door's non-map arm :value, its unknown-key arm :unknown (the rejected keys, sorted); advance-clock! :ms, :frame |
:rf.error/fresco-test-not-a-native-form |
:error |
diagnostic | An L1 codec / controlled / fire projection was given a form whose head is not a tag keyword. | :no-recovery |
:where, :reason, :value |
:rf.error/fresco-test-not-an-intent |
:error |
diagnostic | The L1 marker materializer was given something other than an intent vector. | :no-recovery |
:where, :reason, :value |
:rf.error/fresco-test-not-a-host |
:error |
diagnostic | The declared server policy was read off a value that is not a minted h/defhost crossing. It refuses rather than answering nil, because a nil there reads as :client-only's neighbour. |
:no-recovery |
:where, :reason, :value |
:rf.error/fresco-test-not-a-dom-node |
:error |
diagnostic | The canonical-DOM comparator was given a value that is not a DOM node. | :no-recovery |
:where, :reason, :value |
:rf.error/fresco-test-no-handler-at-position |
:error |
diagnostic | fire! named a prop position the form does not write. A silently absent handler is the fault that door exists to make loud. |
:no-recovery |
:where, :reason, :position, :written |
:rf.error/fresco-test-position-is-not-a-handler |
:error |
diagnostic | fire! named a position that lowers to something other than a function, so there is nothing to fire. |
:no-recovery |
:where, :reason, :position |
:rf.error/fresco-test-l1-dispatch |
:error |
diagnostic | A handler lowered by a pure L1 projection was invoked. L1 reads the codec's emission as data; firing a handler is behaviour, and the projection binds a dispatch that refuses rather than a no-op that would make a handler look wired. | :no-recovery |
:where, :reason, :event |
Schemas¶
Each category's :tags shape is specified as a Malli schema in Spec-Schemas (not registered at runtime), so consumers can validate without ad-hoc parsing. The full set of per-category :tags schemas is canonicalised in Spec-Schemas §Per-category :tags schemas — one schema per category enumerated in the §Error event catalogue above. Two examples (the rest follow the same shape):
;; Conceptual; the actual registration mechanism is implementation-specific.
;; :frame is a :tags key, not a top-level field — per §Frame identity on the raw
;; event above, `re-frame.trace/build-event`'s `stamp-frame` supplies it under
;; :tags for every in-run emit the site did not already tag there. The two
;; schemas below are ABRIDGED illustrations of the shape; the canonical, complete
;; per-category schemas (which do declare :frame) live in Spec-Schemas.
(def HandlerExceptionTags
[:map
[:failing-id :keyword]
[:reason :string]
[:event [:vector :any]]
[:handler-id :keyword]
[:exception-message :string]])
(def SchemaValidationFailureTags
[:map
[:failing-id :keyword]
[:reason :string]
[:where [:enum :event :sub-return :app-db :fx-args :machine-data :machine-output :sub-override]] ;; :machine-data is the `reg-machine` [:schemas :data] boundary; :machine-output is the [:schemas :output] completion-payload boundary (EP-0029 A8). Agrees with the canonical SchemaValidationFailureTags in Spec-Schemas.
[:frame {:optional true} :keyword] ;; the frame the failure belongs to, on every arm that has one
[:path {:optional true} [:vector :any]] ;; (:where :app-db only) the failing leaf
[:value :any]
[:explain :any] ;; Malli explanation shape
[:explain-humanized {:optional true} :any] ;; development builds only — Spec 010 §Humanize-hook
[:sensitive? {:optional true} :boolean] ;; true when the value-bearing slots were redacted
[:source {:optional true} [:enum :boundary]] ;; (:where :event) the `:boundary? true` arm
[:registered-path {:optional true} [:vector :any]]]) ;; (:where :app-db only) registration root; :path is the failing leaf — see Spec/010
;; ... and so on for each category — see Spec-Schemas for the full set.
Pattern-level: every implementation registers an equivalent set of schemas. The category vocabulary is stable and additive — new categories can be added but existing ones cannot be renamed or removed.
Server error projection — public boundary¶
For SSR specifically, the structured trace event is the internal record (rich, full detail, monitor-bound) and a separate public projection is written to the HTTP response (sanitised, client-safe). The internal trace event is never serialised to the client. The projection mechanism is owned by 011 §Server error projection; the trace stream is unchanged by it. Dev tools that want full error detail subscribe via register-listener! as usual; the response carries only the locked :rf/public-error shape. The production-reachable SSR error categories (:rf.error/ssr-render-failed, :rf.error/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 :observability :errors sink consumers (Sentry / Datadog) on the always-on axis (surface #4), so a -Dre-frame.debug=false JVM SSR host still ships them where the dev trace surface is elided — the off-box record, not the wire response, is the telemetry.
The runtime emits :rf.error/sanitised-on-projection (above) when the projector itself fails, so monitor dashboards see when the public boundary fell back to the generic-500 shape. This category is always-on — it rides the production-survivable error-emit axis (surface #4) alongside the dev trace, so an off-box shipper on a -Dre-frame.debug=false JVM SSR host sees the fallback. The always-on emit is one-shot and NON-PROJECTING (the projection listener skips this category), so surfacing it cannot re-enter the projector it reports on.
Recovery contract¶
The :recovery field on the trace event tells consumers (dev panels, error-monitor integrations, tooling) what the runtime did:
:no-recovery— the error propagated; the event was not handled.:replaced-with-default— the runtime used a default value (e.g.,:no-such-handlerfalling through to a no-op).:retried— the runtime retried (with an upper bound) and surfaces the result.:skipped— the runtime declined to act (:rf.fx/skipped-on-platform,:rf.cofx/skipped-on-platform).:warned-and-replaced— the runtime emitted the warning and did its default action anyway (e.g.,:rf.ssr/hydration-mismatchwarn-and-replace mode).:logged-and-skipped— the runtime emitted the trace and dropped the offending input; sibling inputs still apply (e.g.,:rf.error/effect-map-shapecase (c) drops the malformed:fxentry while its sibling entries still run). Note the contrast with that same category's cases (a)/(b): a malformed effect-map ENVELOPE is pre-commit and is REFUSED (:fix-effect), not dropped — dropping a recognised-but-unhonourable key while its:dbsibling commits is the partial-success disguise Conventions §No silent swallow forbids.:no-frame-context— the corrupted-context reader (:rf.error/frame-context-corrupted) emitted the diagnostic and returned nil rather than synthesising a:rf/defaultframe (per the carried invariant). The downstream public frame-scoped op turns that nil into the loud:rf.error/no-frame-context(whose own recovery is:supply-frame).
The list above is illustrative, not exhaustive — several per-category rows carry a recovery value documented inline in their catalogue cell (e.g. :supply-frame, :blocked-navigation, :event-dropped). The runtime routes every error category to the trace stream and proceeds with the documented per-category recovery — the typed, framework-owned defaults (frame-destroyed recovers + emits, sub-exception returns nil, handler-exception fails loud without crashing the app). There is no app-steering recovery policy: recovery is not a framework app-policy concern. For production observation, declare an :observability :errors sink (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. (re-frame v1's process-wide reg-event-error-handler has no re-frame2 equivalent — see MIGRATION.md §M-26. There is no per-frame :on-error recovery policy either: errors are not generically recoverable by an app policy, and observability is provided by the always-on sink route. 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 :observability :errors sink (surface #4): one record per catalogued promoted runtime :rf.error/* event, routed to the sinks a frame's policy names — or, for a cross-frame seat and for records with no resolvable frame, the sinks (rf/configure! {:observability …}) names. It is always-on — NOT gated by re-frame.interop/debug-enabled? — so it survives :advanced + goog.DEBUG=false. Per-frame observation is the frame's own policy; a process-wide view is the default, filtered by the record's :frame tag. Dev-side enrichments (:dispatch-id, :rf.trace/trigger-handler) ride the trace surface and elide with it. In a DEVELOPMENT build the same axis additionally carries the dev-gated candidate-rejection records — a :rollback? true refusal reporting from inside its own debug-enabled? gate, per §The promotion criterion — so a listener written for production also sees, in dev, the transactions the framework discarded; keying on :rollback? true is how one that does not want them drops them.
;; Error-monitoring libraries (Sentry, Honeybadger, …) wire through the
;; always-on sink route. Forward the projected record to the monitor; recovery
;; is the runtime's typed per-category default — the sink observes, it
;; does not steer.
(rf/configure! {:observability {:errors [{:sink :my-app/sentry}]}})
(rf/register-observability-sink! :my-app/sentry
(fn [record] ;; :rf.observe/error, already projected
(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`; the effect-map is closed — `:db`, `:rf.db/runtime` and `:fx` are the only top-level keys. Move `:dispatch` into `:fx` (e.g. `{:fx [[:dispatch …]]}`). The event is refused until corrected — nothing committed."
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 an :observability :errors sink that forwards the projected record to the monitoring service. The sink observes — it does not steer recovery (the runtime applies its typed per-category default):
(rf/configure! {:observability {:errors [{:sink :my-app/sentry}]}})
(rf/register-observability-sink! :my-app/sentry
(fn forward [record]
(sentry/capture-event (sentry-shape record))))
Multiple monitoring concerns compose in user code (one sink fn that fans out to several services, or several entries each naming its own sink). For cross-frame dev observation, (rf/register-listener! :trace …) 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: there are no open questions. "Trace allocation cost in dev when no listeners" classifies as
:resolved— the(rf/configure! {:trace-buffer {:events-retained 0}})escape hatch IS the answer — and lives under## Resolved decisionsbelow.
Resolved decisions¶
Listener ordering¶
Multiple listeners may register concurrently. Listener-invocation order is not contract — tools must not depend on the order in which sibling listeners receive a given event. Each listener receives the same event independently; nothing about the order in which the runtime walks the listener registry is guaranteed across builds, hosts, or registry implementations. The same rule applies to register-listener! (per §Subscription / consumption and §Listener invocation rules) and register-epoch-listener! (per register-epoch-listener! §Invocation rules).
Trace allocation cost in dev when no listeners¶
In dev, interop/debug-enabled? is true, so the emit body runs even when no listeners are registered: the runtime allocates the event map, routes it into the in-flight frame's event slot in the per-frame ring (or skips the ring entirely for frameless emits, per B3 below), and walks the (empty) listener registry. The per-frame ring's per-event append is the floor cost when in-run. Tools that want maximum dev-loop throughput can (rf/configure! {:trace-buffer {:events-retained 0}}) to disable the ring; the synchronous-delivery path still works and the user-listener fan-out remains zero-cost when no listeners are attached.
Trace correlation across the run¶
Two run-wide channels ride on every trace event emitted inside a run — neither is scoped to errors:
-
:rf.trace/dispatch-idunder:tags(per §Dispatch correlation). Grouping raw trace events by run is a single-key filter —(filter #(= run-id (get-in % [:tags :rf.trace/dispatch-id])) events). Tools that need run trees walk:rf.trace/parent-dispatch-idupward across:rf.event/dispatchedevents (the inter-run lineage channel). -
:rf.trace/trigger-handlerat the top level (per §:rf.trace/trigger-handler— naming the in-scope handler). Names the handler whose code produced the event and carries its registration coord — so jump-to-source links work from every trace event in a run, not just errors. Rides on:rf.fx/handled,:rf.machine/transition,:rf.event/db-changed,:rf.fx/do-fx,:rf.sub/run,:rf.view/render, and all:rf.error/*events whenever a handler is in scope at emit time. Omitted outside any handler scope (registration-time emits, outermost-dispatch lookup failures).
Per-run structured projection lives in the assembled :rf/epoch-record (per Spec-Schemas) — the raw :rf.trace/dispatch-id / :rf.trace/trigger-handler channels are the lower-level primitives.
Per-frame trace rings — event-keyed retention¶
The trace surface is partitioned per-frame and sized by event count, not trace-event count. Five resolved sub-questions:
- Cross-frame runs. Each frame retains the traces of runs that executed in it, keyed by their own
:rf.trace/dispatch-id. Cross-frame consumers (pair-mcp, monitoring tools, multi-frame story sessions) merge by:dispatch-idacross rings; the framework does not maintain a process-global cross-frame index. - Frameless trace events — B3. Frameless trace events skip rings entirely; they stream live to listeners only, never retained anywhere. Every hot-reload re-registration emits
:rf.registry/handler-replaced(§Hot-reload), so reload churn reaches the live stream and never a ring. Together: rings hold events exclusively; the live stream carries every registry change; the registry is the source of truth for "what's registered right now". - Off-box streaming wire format. Event bundles (not raw trace events). Matches the storage unit; off-box consumers (re-frame2-pair-mcp
trace-window/watch-epochs, monitoring tools) receive one bundle per stream tick rather than reconstructing runs from individual events. - In-process
trace-bufferAPI. Per-frame, event bundles by default;:flat trueopt-in for callers that want raw trace events. Signature:(rf/trace-buffer frame-id)/(rf/trace-buffer frame-id opts). - Run size bound. No per-run trace cap by design. The operator's tuning lever is the event-count knob (
:rf.trace/events-retained, default 50, per-frame override), not trace-volume per run. A run with 50K traces takes one slot like a run with 5 traces takes one slot.
Single retention knob: :rf.trace/events-retained (frame metadata, default 50). When event #N+1 arrives, the oldest event slot (and every trace event ever emitted under its :dispatch-id) is evicted as a unit. No per-trace-type cap, no per-event trace cap, no other knobs. The full surface lives at §Per-frame trace rings (event-keyed, dev-only).
Rejected alternatives: "don't emit sub-skip" (preserves useful signal by routing instead), "filter at consumer level" (too late — real events already evicted), "bigger flat ring 10×" (procrastinates the architectural mismatch), "frameless :rf/default cluster" (a hot-reload memory leak; B3+B4 instead).
Trace event for app-db changes¶
Partition commits happen at the frame-state commit boundary. The runtime emits :rf.event/db-changed (APP-DB-ONLY) on every dispatch whose handler changed the app-db partition, and :rf.event/frame-state-changed (partition-tagged) whenever either partition changed — so a runtime-only commit (machine snapshot / route slice) emits only the frame-state signal, not :rf.event/db-changed. Tools that want before/after pairs read the :rf/epoch-record's canonical :frame-state-before / :frame-state-after slots (or their :db-before / :db-after app-db projections), which the runtime captures atomically across the event's whole cascade (one epoch per event — per 002 §Drain versus event) rather than per individual change emit.
Privacy / sensitive data in traces¶
Cross-reference: see Security.md §Privacy / secret handling for the framework-wide pattern-level posture this section grounds. Sensitive data marking is path-based per the Spec 015 data-classification mechanism (EP-0025): a
reg-classificationclassifies durable app-db paths, and:sensitiveregistration metadata on areg-*classifies that registration's transient payloads. A schema's{:sensitive? true}prop classifies no durable path; it drives only validation-failure-trace redaction and the schema-owned transient shapes (below). The classification reaches the other way: an:app-dbvalidation-failure trace projects its:valueand:explainthrough the frame's classification policy, so a classified path needs no schema prop (010 §:sensitive?). (There is no handler-meta:sensitive?annotation.)
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) — 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 with a reg-classification beside the feature (015 §Static app-db classification); 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 applied at egress to each instance present. 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 do not classify a durable app-db path (there is no schema→classification bridge, per EP-0025); a schema prop drives only validation-failure-trace redaction and the schema-owned transient (:decode / resource) shapes. (There is no redact-interceptor.)
project-egressreads the frame's classification policy — 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 frame's classification policy, profile-aware — a path declared by areg-classification(:source :classification), a subsystem projection-relative declaration (:source :machine/:resource/:mutation/: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 policy too: there is no value-match (taint-by-equality) dual substituting a re-keyed copy of a sensitive value at a non-app-db position (EP-0025). A value re-keyed off its classified app-db path therefore ships raw — the intended fail-open; 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.egress/*floor. Under:rf.egress/local-raw(or explicit:rf.egress/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 byre-frame.elision/elide-wire-value(per §Size elision in traces below and API.md §Size-elision wire-boundary walker). 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.
re-frame.elision/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). The opt is read by KEY PRESENCE, not truthiness: an explicit:frame nilstates that the value has no governing frame and fails closed; only an ABSENT:framekey falls through to the carried scope. Saying no frame has to be sayable, because the caller that most needs to say it — a tool projecting one frame's value from inside its own chrome frame — has a live ambient frame to fall through to, and that frame's empty classification would ship the value RAW. There is no:rf/defaultfloor: an egress that carries no frame can never borrow a default frame's marks. When no frame is carried, no frame's classification policy is reachable, so the walker fails closed — the whole value is conservatively redacted to:rf/redactedrather than shipped verbatim under no policy (:rf.egress/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 a caller-supplied:tags :sensitive?. But some classification-projection clauses decide sensitivity DURING projection — the projection runs afterbuild-event, sobuild-eventnever saw the signal. Examples: a machine or flow row that carries a thrown value (:rf.error/machine-action-exception, a throwing:rf.machine/guard-evaluated/:rf.machine/action-ran,:rf.flow/failed,:rf.error/flow-eval-exception), whose:exception-data,:exception-message,:exceptionand:reasonslots — and a destroy-time:exitrow's[:info :exception]—project-machine-error-tags/project-flow-exception-tagsredact whole under the rules stated in 005 §Privacy and the:rf.flow/failedrow, and a registration-classified (or frameless-fail-closed):rf.sub/runoutput (project-sub-tags— the sub's own:sensitiveregistration mark; there is no input→output propagation, per EP-0025). 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 caller-stamped ones are.runtime-db is redacted/omitted off-box by default (EP-0001). 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 classification declarations are registrations, not frame-state, so no app-db replace, revert or restore can drop them (015 §Static app-db classification).
The :sensitive? registration metadata key¶
NOTE: There is no handler-meta
:sensitive?registration-metadata annotation. Sensitive data marking is path-based per the Spec 015 data-classification mechanism (EP-0025) — sensitivity is a property of the data value at a path, not of the handler that touched it. The trace-event:sensitive?top-level stamp (see §Trace-event field::sensitive?at the top level) is set by the emitters that decide sensitivity: a caller-supplied:tags :sensitive?, the managed-HTTP per-call or denylisted-query stamp, the schema validation-failure emitters, the machine and flow exception projectors, and a frameless:rf.sub/run. A handler's interceptor chain sets none of them.
A boolean :sensitive? key on the :rf/registration-metadata map
takes no part in the privacy machinery. The two always-on substrate
boundaries (event-emit, error-emit) do not drop / redact based on
handler-meta sensitivity — they apply the event registration's own
:sensitive / :large paths, then the per-path elision wire-walker,
which reads the frame's classification policy (reg-classification
declarations, reg-flow output declarations, and subsystem
projection-relative declarations — never app-schema slot meta, per
EP-0015 §8). Registration-owned
:sensitive payload classification (per EP-0015 §7 and
015 §Registration-owned transient classification)
is the declaration site for an event's payload; a handler's interceptor
chain declares nothing. (There is no public positional
redact-interceptor — EP-0015 §7.)
Trace-event field: :sensitive? at the top level¶
The :rf/trace-event schema (per Spec-Schemas §:rf/trace-event) carries 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 carries one row for it:
| Key | Type | Semantics |
|---|---|---|
:sensitive? |
boolean | (:flat-only) 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 material, by dropping the event or by projecting it:
- 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) projects, then forwards. Every record it forwards to the AI surface has first crossed
project-egressunder:rf.egress/off-box-tool, so declared-sensitive app-db slots, event and effect arguments, and every trace tag nobody classified arrive:rf/redacted.:sensitive?and:rf.epoch/sensitive?ride on those records as annotations — the record contained classified material — and the server drops no whole record by default, so the run stays correlatable (the recorder redact-but-record rule in Security §Behavioural MUSTs across the privacy surface). Its per-call:include-sensitiveopt-in, honoured only when the server was launched with--allow-sensitive-reads, widens only the app-db sensitive axis of that projection.
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.egress/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 profile narrowing¶
An on-box reveal is not a one-way trapdoor. Each consumer's on-box egress profile — a per-(tool, frame) :rf.egress/* value (:rf.xray/egress-profile, :rf.story/egress-profile), never a process-global boolean — 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 reveal has a privacy hole:
1. profile = :rf.egress/local-raw (engineer reveals to debug redaction policy)
2. sensitive run emitted (auth/login event lands in every consumer's buffer)
3. profile = :rf.egress/local-redacted (engineer narrows back, expecting privacy restored)
4. panels keep showing the buffered :sensitive? payloads forever
The normative rule: every on-box egress-profile consumer that holds a trace buffer (Xray's trace-bus, Story's per-variant ui.trace buffer, future devtools buffering downstream of the on-box profile) MUST clear its trace buffer on a narrowing transition — a sensitive-revealing profile (one whose :rf.egress/include-sensitive? resolution is true) back to a redacting one. A widening (redact → reveal) and any same-class transition MUST NOT clear (no buffered sensitive risk exists for those transitions, and clearing would discard legitimate non-sensitive history without cause).
The clear MUST be whole-buffer, not selective. Non-sensitive history buffered alongside the sensitive run is intentionally lost. Selective scrubbing is unsafe because a single sensitive event can have caused later non-sensitive runs — sub recomputes, render args, dispatched-from-fx events — whose payloads structurally reveal the redacted value via the shape of what they consumed. Clearing the whole buffer is the simplest correct semantic; any "smarter" filter risks reintroducing the leak through a derived event.
The clear MUST also reset the per-consumer [● REDACTED N] suppressed-events counter so the indicator drops in lockstep with the buffer (the counter is conceptually "since last clear", not "since process start"). Per Xray's trace-bus/clear-buffer! and Story's ui.trace/clear-buffer! — both 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 reveal → redact narrowing; 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 — it rides the trace surface and elides 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. - The elision-probe verifier (per §Production-elision verification) treats
":rf/redacted"as a framework sentinel that may survive only where a production boundary projects a classified value (the always-on event and error substrates).
No registration-time privacy warning exists. A reg-classification is the canonical durable app-db redaction declaration; registration-owned :sensitive payload classification (per EP-0015 §7) is the declarative site for ad-hoc payload scrubs (there is no public positional redact-interceptor, EP-0015 §7). There is no handler-meta :sensitive? annotation.
Error event catalogue (single source of truth)¶
The error vocabulary has one normative home, §Error event catalogue — one row per category, six columns (:operation · :op-type · Channel · 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); consumers using API.md §Error contract get a pointer to the catalogue rather than a partial duplicate.
:on-error recovery policy — REMOVED¶
There is no per-frame :on-error recovery policy — no frame-config fn that receives a structured error event and returns a map steering the runtime's recovery, and no catalogue row guarding such a contract:
- Errors are not generically recoverable by an app policy. A swallow masks a bug; a replacement fabricates a result a thrown handler could not produce. Genuine recovery is local-at-source (managed-HTTP
:retry, optional-read fallback) or the framework's typed per-category default. - Observability is already provided by the always-on error-emit surface (the
:observability :errorssink, surface #4); a policy would be a redundant observation hook plus an unwanted steering knob.
The catalogue's two axes are therefore always-on-listener? and typed-default-per-category. re-frame v1's process-wide reg-event-error-handler has no re-frame2 equivalent either, 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 by re-calling the existing re-frame2-pair-mcp get-path tool on the marker's :path — no new tool is needed.
Privacy and size are two orthogonal predicates over the same elision walker: re-frame.elision/elide-wire-value (per API.md §Size-elision wire-boundary walker) 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 the 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 :rf.egress/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. The 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 — the classification registry¶
Implementations MUST support declaration-driven nomination. Every source is a declaration the frame's classification policy resolves through the frame's own view; the wire walker consults that policy at every emit, and nothing is written into either partition (015 §Static app-db classification).
The canonical entry for a durable app-db path is a reg-classification declaring :large [[…]] beside the feature, claimed under :source :classification. Declaring beside the feature is what makes the claim AI-discoverable — an agent reading the feature sees the elision claim beside the registrations that write the path. The other sources are reg-flow output declarations (:source :flow) and subsystem projection-relative declarations applied at egress to each instance present (:source :machine / :resource / :mutation / :route); all sources union at lookup time. {:large? true} on a Malli slot of an app-db schema (reg-app-schema) is NOT one of them — there is no schema→classification bridge (EP-0015 §8): no boot / hot-reload schema walk into the policy, and a schema slot prop drives schema-validation-failure-trace redaction and the schema-owned transient shapes only.
The walker does not auto-elide unclassified values. It returns a subtree that no declared path can reach exactly as it found it, rebuilding nothing, so a value keeps its reference and type there. In dev, when a large string sits at any undeclared path in the walked value — under a declared prefix, or inside a subtree the walker returned unchanged — it emits :rf.warning/large-value-unschema'd once per (frame, path) to nudge authors toward classifying that path (the warning keyword's name stays as-is for wire compatibility; its :hint slot names reg-classification).
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 :classification ;; declaration provenance — :classification (reg-classification) / :machine / :resource / :mutation / :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]]}} ;; live-path locator; fetch via get-path on :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 path is copy-pasteable intoget-pathwithout rebasing.:bytes— REQUIRED. Thepr-strbyte count of the elided value, measured with any:sensitivedescendant already redacted, so it never varies with a secret's content or length (015 §No propagation). Lets an agent decide "fetch anyway" (small enough for this turn) vs "skip" (over the per-turn budget).:type— REQUIRED. One of:map,:vector,:set,:scalar,:string. Tells the agent which access pattern to use — a:vector's elements are addressable one index at a time throughget-path(its batchpathsform reads several at once), and at or below a:largedeclaration those reads elide too unless the call passes the size overrideelision false; a:stringof 5MB cannot be subdivided at all.:reason— REQUIRED. The declaration provenance — the:sourceof the declaration that fired the marker (normative at Spec-Schemas §:rf/elision-marker). The durable app-db large declaration is areg-classification, so a slot it classifies emits:reason :classification. A subsystem projection-relative declaration emits its subsystem source (:machine/:resource/:mutation/:route); a flow output declaration emits:reason :flow. A frame:large {:app-db …}annotation, an imperativeadd-marksmark, and areg-app-schema{:large? true}slot prop are not routes into 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>], and only that shape. It is a live-path locator: the path inside it is the marker's:path, and it addresses the CURRENT app-db. 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. To fetch the literal elided value, re-call the existing re-frame2-pair-mcpget-pathtool on the marker's:path(not on the handle vector —get-pathdecodes no handle) with the size overrideelision false, subject to that tool's own cap check (a:rf.mcp/overflowis the failure mode if the literal is over-cap); without the override, a read at or below a:largedeclaration elides too. A marker inside an epoch payload (:db-before/:db-after) describes that epoch's value (:path,:bytes,:type), but its handle still addresses current state, so following it returns the current value. A past elided value is read by evaluating against the retained epoch record (eval-cljswith the pair skill'sre-frame2-pair.runtime/epoch-by-id), choosing:db-beforeor:db-afterto match the marker's side.:digest— OPTIONAL. Asha256:<hex>content digest, taken over the same redacted value:bytesmeasures, computed only when:rf.egress/include-digests?is true on the call AND the host computes one synchronously, which is the JVM. The browser build omits the slot even when it is requested, and never ships:digest nil. Default off, and no profile turns it on::bytesalready serialises the elided value once, and a digest adds a second serialisation plus a SHA-256 per marker. When enabled (debug builds, integrity-check workflows), callers compare digests across turns to detect change-without-fetch. Absent a digest, equal markers (same:path/:bytes/:type) say nothing about whether the content is equal: a same-size change inside a large slot yields identical markers, so a diff over projected records shows no patch for it.
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 other five 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.egress/* namespace (per Conventions §Reserved namespaces) and rides into every tool that emits wire data:
{:rf.egress/elision-policy
{:rf.egress/include-large? false ;; default false — large values elide to markers
:rf.egress/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 / story-mcp servers per Tool-Pair.md) MUST default
:rf.egress/include-large?tofalseand:rf.egress/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.egress/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-largeslot reports the count of:rf.size/large-elidedmarkers ENCOUNTERED in the tool's response payload. Tools do not walk values themselves — they hand the framework's ONE projection boundary (rf/project-egress, for bare-value direct reads, epoch records and every other record kind alike) the resolved profile and count the markers that come back. Some markers therefore ride through from upstream (the event-emit substrate, the error-emit substrate); others are substituted by the boundary call the tool itself makes on a direct read — a pair-shapedsub-cacheorget-pathsurface MUST make that call, per Tool-Pair §Direct-read privacy posture forsub-cacheandget-path. Either way the tool re-implements no redaction of its own. The slot is omitted when the count is zero (per Conventions.md —:elided-largerow).
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: -
×
: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). - ×
: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.egress/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 classification declarations survive production builds — they are registrations, and they ship as data. Production tools that project frame-state (diagnostic dumps, off-box snapshot exports) do so through
project-egress, which applies the frame's policy (subject to the off-box runtime-db redaction default — per §Privacy). - The
re-frame.elision/elide-wire-valuewalker itself ships in production. Consumer-facing surfaces that reach it throughrf/project-egress(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) carries 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 only through the public rf/project-egress door with a named :rf.egress/* profile — the walker itself carries no façade export (per API.md §Size-elision wire-boundary walker); 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 at paths that still need a :large classification.