Conventions¶
Type: Convention Locked runtime conventions that span the spec — reserved namespaces for framework-owned ids, reserved fx-ids and state-node keys, reserved app-db keys, and the feature-modularity id-prefix convention.
Reserved namespaces (framework-owned)¶
re-frame2 reserves one root keyword namespace for framework-owned ids: :rf/*. Every framework runtime id — events, fx, cofx, app-db keys, trace operations, error categories, warnings, registrar mutations, machine lifecycle events, routing events, navigation fx, SSR advisories — lives under :rf/* or one of its sub-namespaces. User code MUST NOT register handlers, fx, subs, or frames under :rf/*. Tooling and migration agents check for collisions.
The previous v1-and-early-v2 scheme used 14 separate top-level prefixes (:registry/*, :machine/*, :route/*, :nav/*, :re-frame/*, ...). That design grew by accretion as new Specs landed and is exactly the place-vs-name accumulation Principles §Name over place names. v2 collapses to one root with hierarchical sub-namespaces.
The v2 prefix migration moved the per-feature trace surfaces (:rf.machine/*, :rf.route/*, :rf.epoch/*, :rf.error/*, …) under the single root but missed the core domino trace ops and their :tags keys — the event / sub / view / fx / registry / frame families still emitted bare op-types (:event, :sub/run, :view/render, :fx, :registry, :frame) and bare :tags keys (:dispatch-id, :sub-id, :render-key, :epoch-id, …). That migration is completed here: every framework-emitted op-type is now :rf.<family>, every :operation is :rf.<family>/<op>, and every top-level framework :tags key is namespaced under its domino family (:rf.sub/*, :rf.view/*, :rf.fx/*, :rf.event/*, :rf.epoch/*, :rf.cofx/*) or — for the cross-cutting correlation spine — under :rf.trace/*. The one deliberate carve-out is :frame (the universal per-event routing tag), which stays bare per 009 §Canonical per-frame routing key. The authoritative op-type / operation / tag-key tables live in 009 §:op-type vocabulary and 009 §:tags is the open-ended bag.
The single-root reserved set¶
| Sub-namespace | Used for | Spec |
|---|---|---|
:rf/* |
Pattern-level events emitted or consumed by the framework (e.g. :rf/hydrate, :rf/server-init); the single reserved app-db root :rf/runtime (per §Reserved app-db keys); pattern-level effect-map keys; reserved hiccup heads (:rf/suspense-boundary per 011 §Streaming SSR — (a)); the universal default frame id (:rf/default) |
002 / 011 / 012 |
:rf.frame/<gensym> |
Anonymous frame-identifier namespace, owned by make-frame (e.g. :rf.frame/123 for a gensym'd frame id). |
002 |
:rf.frame/<operation> |
Frame-lifecycle trace-operation namespace, owned by the router and frame lifecycle (e.g. :rf.frame/drain-interrupted, :rf.frame/destroyed). |
002 / 009 |
:rf.registry/* |
Registrar mutation trace operations (:rf.registry/handler-registered, :rf.registry/handler-cleared, :rf.registry/handler-replaced) |
001 / 009 |
:rf.fx/* |
Effect-resolution advisories (:rf.fx/skipped-on-platform, :rf.fx/override-applied); reserved fx-ids in machine :fx (:rf.fx/spawn-args) |
002 / 009 |
:rf.cofx/* |
Cofx-resolution advisories — cofx-substrate events that ride the error envelope but are not necessarily failures. Reserved members: :rf.cofx/skipped-on-platform (emitted as a :warning with :recovery :skipped when a registered cofx's :platforms set excludes the active platform; mirror of :rf.fx/skipped-on-platform). Per 009 §Error namespace convention and 011 §Effect handling on the server. |
009 / 011 |
:rf.error/* |
Error trace operations (handler exception, sub exception, fx exception, etc.) | 009 |
:rf.warning/* |
Warning trace operations (e.g. :rf.warning/plain-fn-under-non-default-frame-once) |
009 |
:rf.machine/* |
Machine lifecycle and transition trace operations (:rf.machine/transition, :rf.machine/snapshot-updated); machine framework subs ([:rf/machine <id>]) |
005 |
:rf.machine.lifecycle/*, :rf.machine.timer/*, :rf.machine.event/*, :rf.machine.microstep/*, :rf.machine.history/* |
Sub-areas of machine traces (further hierarchy under :rf.machine). :rf.machine.history/* is the history-pseudo-state trace family — :rf.machine.history/restored (a transition resolved a :type :history pseudo-state and re-entry resolved the recorded-or-default configuration) and :rf.machine.history/recorded (a history-bearing compound was exited and its last-active configuration was written into the :rf/history snapshot slot); per 005 §History states and 009 §History trace events. |
005 / 009 |
:rf.route/* |
Framework routing events (:rf.route/navigate, :rf.route/transitioned, :rf.route/handle-url-change, :rf.route/not-found, :rf.route/navigation-blocked, :rf.route/continue, :rf.route/cancel); framework route subs ([:rf.route/id], [:rf.route/params], etc.); route trace operations |
012 |
:rf.nav/* |
Navigation fx ids (:rf.nav/push-url, :rf.nav/replace-url, :rf.nav/scroll, :rf.nav/external) |
012 |
:rf.ssr/* |
SSR-specific advisories (hydration mismatch, head mismatch, etc.) | 011 |
:rf.server/* |
Server-side response-shape fx (:rf.server/set-status, :rf.server/set-cookie, :rf.server/redirect, :rf.server/error-projection) |
011 |
:rf.epoch/* |
Tool-Pair epoch operations | Tool-Pair |
:rf.xray/* |
Canonical-devtools namespace for Xray (per Tool-Pair §Canonical devtools) — events, subs, fxs, app-db keys, trace operations, AND boot-time configure! keys owned by the Xray devtool family. Framework-distance-zero alongside :rf.epoch/*; reserved sub-namespace for canonical devtools under the framework root. The framework reserves the segment; Xray owns the member set — the canonical config-key roster + per-key semantics live in tools/xray/spec/015-Configuration.md (an instance of the :rf.<tool>/* reserve-and-delegate convention in the next row, which Story will follow under :rf.story/* when its configure! surface is reorganised). Third-party libraries MUST NOT register under :rf.* (per §Library-owned prefixes below). |
Tool-Pair |
:rf.<tool>/* (convention) |
The reserved-namespace pattern every canonical re-frame2 tool follows for its configure! boot-time surface — each tool owns its own :rf.<tool>/* sub-namespace under the framework root for events, subs, fxs, app-db keys, and config keys. Today: :rf.xray/* (Xray, this table row); :rf.story/* would be the equivalent for Story when its configure! surface is reorganised. The pattern is prescriptive for canonical devtools (Tool-Pair §Canonical devtools) — third-party libraries MUST NOT reserve under :rf.* (they own their own top-level prefix per §Library-owned prefixes). Tool boot-time configure! keys NEVER use bare names (:editor, :auto-open?) or dotted-but-unscoped names (:launch/auto-open?, :experimental/static-mode?); the :rf.<tool>/* namespace is the collision protection, the greppability anchor, and the IDE auto-completion seed. |
Conventions |
:rf.privacy/* |
Cross-tool privacy gates — config keys that more than one re-frame2 tool (Xray, Story, future tools) reads from the same atom. The canonical member is :rf.privacy/show-sensitive? (the trace-bus privacy gate per Spec 009 §Privacy); set once by either tool's configure!, every tool's trace consumer honours it. The reservation is at the keyword-prefix level — future cross-tool privacy knobs (:rf.privacy/include-large?, etc.) land here without further coordination. Per spec/Privacy.md. |
Privacy / Conventions |
:rf.assert/* |
Assertion-event vocabulary used by the post-v1 stories library's play functions and test runner | 007 |
:rf.test/* |
Test-runner-internal events and fx-stub ids | 008 |
:rf.http/* |
Managed-HTTP fx ids (:rf.http/managed, :rf.http/managed-abort, :rf.http/managed-canned-success, :rf.http/managed-canned-failure); reply-payload :kind values for the closed eight-category failure taxonomy (:rf.http/transport, :rf.http/cors, :rf.http/timeout, :rf.http/http-4xx, :rf.http/http-5xx, :rf.http/decode-failure, :rf.http/accept-failure, :rf.http/aborted); registration metadata key :rf.http/decode-schemas; trace operations (:rf.http/retry-attempt); the security args-map slot :rf.http/max-decoded-keys (per-request keyword-interning cap, — default 10000). Reserved whether or not the implementation ships Spec 014 — ports that omit :rf.http/managed MUST NOT register the namespace for any other purpose. |
014 |
:rf.http.interceptor/* |
Lifecycle trace operations for the per-frame request-side interceptor chain (:rf.http.interceptor/registered, :rf.http.interceptor/cleared) per 014 §Middleware. |
014 |
:rf.size/* |
Size-elision wire markers and policy keys. Reserved members: the wire marker :rf.size/large-elided (per Spec-Schemas §:rf/elision-marker); the per-call policy keys :rf.size/elision-policy, :rf.size/threshold-bytes, :rf.size/include-large?, :rf.size/include-digests?, :rf.size/include-sensitive? (default false; when true, sensitive values flow through the wire-elision walker without drop) consumed by rf/elide-wire-value (per API.md §rf/elide-wire-value); the dev-mode warning category :rf.warning/large-value-unschema'd (catalogued in 009 §Size elision in traces). Schemas (:large? true on a Malli slot) are the only nomination path — there are no framework fxs for runtime declaration. Reserved whether or not the implementation ships the elision walker — ports that omit it MUST NOT register the namespace for any other purpose. Per 009 §Size elision in traces. |
009 |
:rf.elision/* |
Sentinel-handle namespace for the :rf.size/large-elided marker's :handle slot — the EDN form [:rf.elision/at <path>] an agent passes to get-path to re-fetch an elided value. Reserved at the keyword (not segment) level: the only conformant tail is at. Per Spec-Schemas §:rf/elision-marker. |
009 |
:rf.mcp/* |
Cross-MCP wire-vocabulary markers emitted on the wire by the MCP servers (re-frame2-pair-mcp / story-mcp). Reserved members: :rf.mcp/overflow (cap-trip indicator, per tools/mcp-conformance/TOKEN-BUDGETS.md); :rf.mcp/summary (lazy-summary slot); :rf.mcp/diff-from (diff-encode base reference); :rf.mcp/dedup-table (structural dedup table); :rf.mcp/ref (back-reference key paired with :rf.mcp/dedup-table — the integer id at each dedup site that points into the table, per re-frame2-pair-mcp tools/dedup.cljs); :rf.mcp/cache-hit (per-session cache marker); :rf.mcp/cursor-stale (cursor age-out :reason); :rf.mcp/invalid-arg (per-call arg-validation rejection wrapper); :rf.mcp/result (wire-fidelity typed result envelope — :value / :nil / :eval-error / :unserializable discriminator emitted by the runtime-side classifier for eval-cljs / handler-meta, per Tool-Pair §Wire fidelity). Owned by the MCP servers (not part of the framework runtime vocabulary) but reserved cross-server so an agent that learns one marker shape sees it byte-identical across the servers. Canonical naming home: tools/mcp-conformance/NAMING.md §Error-vocabulary alignment; canonical key constants: tools/mcp-base/src/re_frame/mcp_base/vocab.cljc. Reserved whether or not the implementation ships the MCP servers — ports MUST NOT register the namespace for any other purpose. |
Tool-Pair |
:rf.trace/* |
The trace-channel namespace — covers (1) the trace-channel control slots that ride on event-meta or on emitted trace events, (2) the cross-cutting trace-correlation tag keys stamped on every event inside a cascade, AND (3) the per-frame trace-ring retention knob :rf.trace/cascades-retained. Distinct from the :rf.<prefix>/* trace-operation namespaces (:rf.frame/*, :rf.registry/*, :rf.machine/*, …) which name :operation values, and from the domain tag-key namespaces (:rf.sub/*, :rf.view/*, :rf.fx/*, :rf.event/*, :rf.epoch/*) which name a single domino-family's :tags keys. Trace-channel control slots: :rf.trace/no-emit? (event-meta opt-out — when truthy on a dispatched event's meta, the handler's cascade emits no trace events; per 009 §Trace-emission opt-out); :rf.trace/trigger-handler (optional top-level slot on a trace event naming the in-scope handler that produced it and carrying its registration-site :source-coord; per 009 §:rf.trace/trigger-handler and Spec-Schemas §:rf/trace-event); :rf.trace/call-site (optional top-level slot on a trace event carrying the compile-time invocation coord stamped by the dispatching macro; per 009 §:rf.trace/call-site and Spec-Schemas §:rf/trace-event). Trace-correlation :tags keys (the cascade's correlation spine — stamped on sub-runs, renders, errors, every event regardless of domino family, so they have no single domain home): :rf.trace/dispatch-id (the per-cascade correlation id on every trace event in a cascade), :rf.trace/parent-dispatch-id (inter-cascade lineage on :rf.event/dispatched events), :rf.trace/event-id (the cascade run's id — read off :sub/run / :view/render attribution, epoch records, and the cascade aggregator), :rf.trace/trace-id (the dispatch envelope's correlation id), :rf.trace/phase (:run-start / :run-end / :rollback). Trace-ring retention knob: :rf.trace/cascades-retained (per-frame metadata key set on reg-frame, default 50; sets the number of cascade slots retained in the frame's per-frame trace ring per 009 §Per-frame trace rings; also accepted on (rf/configure! :trace-buffer {:cascades-retained N}) as the process-default for frames that did not set per-frame metadata). This row is framework-reserved and additive (no longer "closed at three"): the trace-channel control slots, the correlation keys, and the retention knob are the three member groups today; further trace-channel-cross-cutting keys land here by extending this row in a Spec change. Reserved whether or not the implementation ships the call-site macro or the no-emit? meta — ports MUST NOT register the namespace for any other purpose. |
009 |
:rf.route.nav-token/* |
Navigation-token lifecycle trace operations. Closed reserved set of two members: :rf.route.nav-token/allocated (fresh nav-token cascade begins) and :rf.route.nav-token/stale-suppressed (async result carrying a now-superseded token; handler does NOT run). Per 012 §Trace events and 009 §Error event catalogue. |
012 / 009 |
:rf.adapter/* |
Substrate-adapter :kind discriminator values returned by (rf/current-adapter). Canonical members: :rf.adapter/reagent, :rf.adapter/reagent-slim, :rf.adapter/uix, :rf.adapter/helix, :rf.adapter/plain-atom, :rf.adapter/ssr. Third-party adapters publish their own :kind values outside :rf.adapter/* — the reserved namespace prevents silent collision with framework-owned discriminators. Per 006 §Adapter introspection. |
006 |
:rf.schema/* |
Schema / validation namespace. Closed reserved set of two members: :rf.schema/violation (hot-reload schema-mismatch warning — fires when a file-save re-evaluates reg-app-schema with a different schema for the same path and the live app-db value at that path no longer validates against the new schema; :op-type :warning, recovery :logged-and-skipped; per 009 §Error event catalogue and 010 §Schema migration on hot-reload) and :rf.schema/at-boundary (the boundary-validation interceptor's :id — per API.md §validate-at-boundary-interceptor and 010 §Production builds). v1's :spec per-reg-* metadata key, the v2 :rf.spec/* trace namespace, and the bare :spec/* interceptor-id namespace are all collapsed into :rf.schema/* under the unified schema vocabulary. Migration: see MIGRATION §M-54. |
009 / 010 |
Error-id and warning-id grammar¶
Error and warning ids follow :rf.error/<kebab-id> and :rf.warning/<kebab-id> — a single-segment kebab-case category under the reserved sub-namespace. The :rf.error/* and :rf.warning/* table rows above reserve the namespaces; the per-category vocabulary (the closed set of <category> values, what each one means, and which trace :operation it maps to) is enumerated in 009 §Error namespace convention. The same :rf.<prefix>/<category> shape applies to :rf.fx/* advisories, :rf.ssr/* advisories, and :rf.epoch/* operations — Conventions reserves the prefixes; 009 owns the per-prefix grammar.
Co-edit invariant: every new :rf.<area>/<category> event added by a feature Spec MUST land as a row in 009 §Error event catalogue in the same PR as the owning Spec change. The catalogue is closed-vocabulary; an entry referenced without a matching row there is a contract bug, not a deferred follow-up.
v1 :re-frame/* namespace¶
The v1 framework prefix :re-frame/* is not a runtime-resolved alias in v2. The runtime does not coerce :re-frame/<x> to :rf/<x>; direct authoring of :re-frame/* ids does not resolve. The v1→v2 path is the mechanical rewrite owned by the migration agent (per MIGRATION §M-20) — every :re-frame/<x> reference is rewritten to :rf/<x> (or to the per-rule replacement when the id names a v1 feature removed in v2) at migration time. Pre-alpha re-frame2 carries no in-flight v1 codebases that would benefit from a runtime coercion shim, so the prefix is not reserved here.
re-frame.alpha is dissolved¶
The v1 re-frame.alpha namespace is not part of v2. The generalised reg/sub/reg-sub-lifecycle surface — together with the built-in lifecycle policies :safe, :no-cache, :reactive, :forever and the query-map :re-frame/q shape — is removed. This is pre-v1 cleanup, not deprecation. The canonical surfaces are:
- Per-kind registration macros:
reg-event-db,reg-event-fx,reg-event-ctx,reg-sub,reg-fx,reg-cofx,reg-flow,reg-route,reg-machine,reg-app-schema,reg-view. - Vector-form subscribe:
(rf/subscribe [::id arg]).
The per-frame sub-cache uses a single disposal algorithm — synchronous ref-counting (dispose on derefer-count → 0) — per Spec 006 §Reference counting and disposal. For one-shot or persistent-value edge cases that would have leaned on a specific lifecycle policy, file a bead naming the actual need rather than reaching for a removed API.
Migration entries: MIGRATION §M-23.
User-defined route ids¶
User-defined route ids are not namespaced under any framework prefix. Routes are user-facing names; pick a feature prefix per the feature-modularity convention below — :cart/show, :auth/login-page, :account.profile/show. The framework's routing concerns (events that drive navigation, subs that read the route slice) live under :rf.route/*; user route ids share the user-feature namespace with their adjacent events and subs. This stops the framework prefix from leaking into app code and removes the :route/* ambiguity (was it a framework operation or a user route-id? — the v2 answer is unambiguously the latter has no :route/* prefix at all).
Library-owned prefixes¶
A handful of canonical libraries reserve prefixes outside the framework :rf/* root. These prefixes are library-owned (canonical when the library is loaded), not framework-reserved (closed by Spec change). The distinction matters: framework-reserved names are fixed-and-additive in the table above; library-owned prefixes belong to the library's own surface and would only collide with user code that loads the library and ignores its convention.
| Library-owned prefix | Library | Used for | Spec |
|---|---|---|---|
:story.<...> |
post-v1 stories library | Story ids (:story.auth.login-form) and variant ids (:story.auth.login-form/empty) |
007 |
:Workspace.<...> |
post-v1 stories library | Workspace ids (:Workspace.Auth/all-states) |
007 |
Library-owned prefixes do not violate the single-root invariant on framework-reserved ids (the rule that framework names live under :rf/* only) — they are user-space names that the library claims by convention. The framework's own assertion-event vocabulary used by the stories library's play functions and test runner is :rf.assert/* (per the table above) and remains framework-reserved.
Library-owned prefixes live outside :rf.* (e.g., Story's :story.*, Workspace's :Workspace.*). The exception is canonical devtools recognised in spec/Tool-Pair.md — they reserve a sub-namespace under :rf.* (e.g., :rf.xray/* for Xray, :rf.epoch/* for the epoch surface). The reservation rides framework-distance-zero status: canonical devtools ship lockstep with the framework, their wire vocabulary is part of the framework contract, and a third-party library claiming :rf.<x>/* would silently collide with framework-owned discriminators. Third-party libraries MUST NOT reserve under :rf.*.
Discipline¶
- User-registered ids must not collide. A user may not
(reg-event-fx :rf/hydrate ...)to override a framework event without going through the documented:on-create/ re-registration extension points. The linter rule is::rf/*and any:rf.X/*sub-namespace is reserved. The rule applies regardless of the segment shape under the sub-namespace — a user registration of either:rf.frame/<gensym>(the identifier form) or:rf.frame/<operation>(the trace-operation form) is a collision; both rows above sit inside the same closed reserved set. - Library authors choose their own prefixes. Third-party libraries SHOULD use their library name as a top-level segment (
:reagent/*,:re-pressed/*). Avoid re-using:rf/*. - Trace-event
:operationvocabulary is open by default. A library may add its own:my-lib.error/*/:my-lib.fx/*prefix for advisories it emits — but the framework's reserved set is closed (additive only by Spec change).
The reserved set is fixed-and-additive: names already in the table cannot be repurposed; new sub-namespaces are added by extending the table in a Spec change. New Spec areas ship under :rf.<spec-area>/* rather than inventing a top-level prefix.
Reserved fx-ids¶
re-frame2 reserves a small set of fx-ids — the runtime, the machine handler, and the navigation layer recognise them by name. User code MUST NOT register a reg-fx handler for these ids; doing so is a collision the registrar warns about.
| Reserved fx-id | Recognised by | Used for | Spec |
|---|---|---|---|
:dispatch |
runtime do-fx |
Standard intra-frame dispatch | 002 |
:dispatch-later |
runtime do-fx |
Delayed dispatch | 002 |
:raise |
machine handler (make-machine-handler) |
Self-event addressed to the same machine; processed atomically pre-commit. Outside a machine action's :fx, :raise is unbound and :rf.error/no-such-handler is the failure mode. |
005 |
:rf.machine/spawn |
re-frame.machines (canonical) |
Spawn a dynamic actor; record its id into the parent's :data via :on-spawn. Registered globally so user event handlers (and machine actions) emit it from :fx to register a new live actor. Args per :rf.fx/spawn-args. |
005 |
:rf.machine/destroy |
re-frame.machines (canonical) |
Destroy a dynamic actor: runs the actor's :exit action, dissociates its snapshot at [:rf/runtime :machines :snapshots <actor-id>], and clears its event handler from the frame-local registry. Symmetric counterpart to :rf.machine/spawn. Per 005 §:raise, :rf.machine/spawn, and :rf.machine/destroy are reserved fx-ids inside :fx. |
005 |
:rf.fx/reg-flow |
runtime do-fx |
Register a flow at runtime (per 013 §Dynamic toggle via fx). Args: a flow map. | 013 |
:rf.fx/clear-flow |
runtime do-fx |
Clear a registered flow; dissoc-in on its :path. Args: a flow id. |
013 |
:rf.nav/push-url |
re-frame.routing (canonical) |
pushState for the URL. :client platform only. Per 012 §Effects (reg-fx). |
012 |
:rf.nav/replace-url |
re-frame.routing (canonical) |
replaceState for the URL. :client platform only. Per 012 §Effects (reg-fx). |
012 |
:rf.nav/scroll |
re-frame.routing (canonical) |
Apply a scroll strategy. Args {:strategy :from :to :saved-pos :fragment}. :client platform only. Per 012 §Scroll restoration. |
012 |
:rf.nav/capture-scroll |
re-frame.routing (canonical) |
Capture current scroll position before leaving a route. :client platform only. Per 012 §Scroll restoration. |
012 |
:rf.route/with-nav-token |
re-frame.routing (canonical) |
Threads :nav-token into a downstream dispatch for stale-result suppression. Universal platform. Per 012 §Navigation tokens — stale-result suppression. |
012 |
Fx-id namespacing rule — three reserved fx-id sub-namespaces¶
The reserved fx-ids above span three sub-namespaces under the :rf/* root, plus a small set of unqualified runtime ids (:dispatch, :dispatch-later, :raise). Each sub-namespace carries a different kind of fx; the rule names which is which so a generator scaffolding fx-emitting code picks the right prefix without scanning the whole table. Audit Finding 2.
| Sub-namespace | Carries | Rule |
|---|---|---|
:rf.fx/* |
Generic framework fx-ids — operations the framework provides for cross-cutting concerns that are not bound to a single feature surface. | An fx-id lives under :rf.fx/* when the operation is framework-supplied for general use and the fx semantics are not bound to a specific feature substrate (machines, routing, …). Members: :rf.fx/reg-flow, :rf.fx/clear-flow, and the spawn-args reserved key :rf.fx/spawn-args (an args-payload shape, not an fx-id itself). |
:rf.<surface>/* |
Surface-specific fx-ids — operations bound to a named feature artefact (machines, routing, HTTP, etc.). | An fx-id lives under :rf.<surface>/* when the operation is part of the named surface's runtime contract and a port that omits the surface MUST NOT register the namespace. Members: :rf.machine/spawn, :rf.machine/destroy (machines); :rf.route/with-nav-token (routing); :rf.http/managed, :rf.http/managed-abort, etc. (HTTP). |
:rf.nav/* |
Navigation-primitive fx-ids — the small set of fx-ids that map directly onto host-platform navigation primitives (pushState, replaceState, scroll APIs). |
An fx-id lives under :rf.nav/* when it is a navigation primitive — a thin wrapper over a browser/host navigation API that is invoked by the routing artefact but is not itself "route-aware" (no nav-token threading, no :on-match dispatch, no schema validation). The split from :rf.route/* is deliberate: :rf.nav/* fx-ids could be invoked by a non-routing app that wants the primitive without the routing slice; :rf.route/* fx-ids require the routing artefact's slice and tokens to be meaningful. Members: :rf.nav/push-url, :rf.nav/replace-url, :rf.nav/scroll, :rf.nav/capture-scroll. |
Historical carve-out — :rf.fx/reg-flow / :rf.fx/clear-flow. These two fx-ids look like surface-specific operations (they register / clear flows — clearly bound to the flows surface) yet they live under :rf.fx/* rather than :rf.flow/*. This is the single principled exception: the v1→v2 rename moved v1's unqualified :reg-flow / :clear-flow to :rf.fx/reg-flow / :rf.fx/clear-flow to align with the Reserved namespaces single-root rule; a second rename to :rf.flow/reg-flow / :rf.flow/clear-flow would be a back-to-back churn on the migration agent's mechanical rewrite (per MIGRATION §M-20) for marginal naming-axis benefit. v1 holds the current placement; future surface-specific fx-id additions follow the :rf.<surface>/* rule above.
Reserved fx-ids elsewhere in the framework follow the rule. :rf.http/managed and family (per 014-HTTPRequests) sit under :rf.http/* per the surface-specific rule. The bare unqualified set (:dispatch, :dispatch-later, :raise) is pre-namespace-consolidation legacy — the three unqualified ids ship as-is in v1 because they are at the load-bearing centre of every event drain and the migration cost of moving them outweighs the consistency benefit. They are documented in their unqualified form in the table above.
Spawn-spec keys. Inside a [:rf.machine/spawn <spec>] entry, the spec map uses the following reserved keys (per 005 §Spawn-spec keys and Spec-Schemas §:rf.fx/spawn-args): :machine-id, :definition, :id-prefix, :data, :on-spawn, :start, :system-id. Two further keys are reserved for the runtime to stamp on declarative-:spawn spawns: :rf/parent-id (the parent machine's registration-id) and :rf/spawn-id (the absolute prefix-path of the :spawn-bearing state node) — together these address the runtime spawn registry slot at [:rf/runtime :machines :spawned <parent-id> <invoke-id>]. The spawned actor's snapshot lives at the runtime-managed [:rf/runtime :machines :snapshots <gensym'd-id>] — the spec does NOT carry a :path or :collection key. User-supplied spawn-spec keys outside the reserved set are tolerated (open shape) but unused by v1.
Reserved state-node keys (machine transition tables)¶
Inside a transition-table state node, the following keys are reserved by the runtime — per 005 §Transition table grammar and 005 §Capability matrix. User-defined keys MUST be namespaced to avoid colliding with the reserved set.
| Reserved state-node key | Used for | Capability axis | Spec |
|---|---|---|---|
:on |
Event-driven transition map | core (flat FSM) | 005 |
:entry / :exit |
Single-fn-or-id action on entering / leaving the state | core (flat FSM) | 005 |
:meta |
Tooling-visible metadata; e.g. {:terminal? true} |
core (flat FSM) | 005 |
:states |
Nested compound states (when present, the state is a compound state) | :fsm/hierarchical |
005 |
:always |
Eventless transition slot — fires when guard becomes true | :fsm/eventless-always |
005 |
:after |
Delayed transition slot — fires after a time delay | :fsm/delayed-after |
005 |
:spawn |
Declarative actor-spawn-on-entry / destroy-on-exit (sugar over imperative :rf.machine/spawn / :rf.machine/destroy); see 005 §Declarative :spawn |
:actor/invoke |
005 |
:spawn-all |
Declarative spawn-and-join — N parallel :spawns plus a join condition (:all / :any / {:n N} / {:fn ...}); see 005 §Spawn-and-join via :spawn-all |
:actor/spawn-and-join |
005 |
The reserved set is fixed-and-additive: existing reserved keys cannot be repurposed; new keys are added by Spec change. Keys outside the reserved set are tolerated as user metadata (open-map invariant) but ignored by the runtime.
The reserved set is fixed-and-additive: existing reserved fx-ids cannot be repurposed; new ones are added by Spec change. Library- and feature-owned fx ids should be namespaced (:auth.login/issue-request, :my-lib.fx/store) to avoid colliding with the reserved unqualified set.
Reserved app-db keys¶
Exactly one key at the root of every frame's app-db is reserved — the runtime owns it; user code MUST NOT write under it. The reserved set is fixed-and-additive (Spec-ulation): the root cannot be repurposed; new sub-containers are added by Spec change.
| Reserved app-db key | Owner | Used for | Spec |
|---|---|---|---|
:rf/runtime |
framework runtime | The single container holding ALL framework-owned per-frame state — four subsystem sub-containers: :machines (the machine runtime — :snapshots, :system-ids, :spawned, :spawn-counter), :routing (the routing runtime — :current route slice, :pending-navigation, :scroll-positions, :scroll-positions-order, :nav-token-counter, :pending-nav-counter), :elision (the wire-elision declaration registry — :declarations, :sensitive-declarations), and :ssr (the SSR hydration metadata — :hydration). The full value shape is pinned at Spec-Schemas §:rf/runtime. Each sub-container is allocated lazily — absent until the first subsystem write — and per-frame isolation is automatic (each frame's app-db has its own :rf/runtime). Locating ALL framework runtime state inside app-db (rather than in adjacent global state) is the named mechanism by which machine / routing / elision / ssr state inherits 000 §Frame state revertibility — every subsystem's state walks back atomically with app-db on a frame revert. |
005 / 009 / 011 / 012 |
:rf/runtime sub-container catalogue¶
The four sub-containers under :rf/runtime and their per-frame absolute paths:
| Subsystem | Path | Contents | Owning Spec |
|---|---|---|---|
| Machines | [:rf/runtime :machines :snapshots] |
Map of <machine-id> → :rf/machine-snapshot. Each registered machine's snapshot lives at [:rf/runtime :machines :snapshots <id>]. |
005 §Where snapshots live |
| Machines | [:rf/runtime :machines :system-ids] |
Per-frame reverse index for :system-id named-machine addressing — {<system-id> → <gensym'd-machine-id>}. A spawn whose args carry :system-id writes a slot here; destroy clears it. (rf/machine-by-system-id sid) reads against this slot. Allocated lazily — absent until the first system-id-bound spawn. |
005 §Named addressing via :system-id |
| Machines | [:rf/runtime :machines :spawned] |
Per-frame declarative-:spawn / :spawn-all spawn registry — {<parent-machine-id> → {<invoke-id> → <slot>}}, where <invoke-id> is the absolute prefix-path of the :spawn-bearing state node and <slot> is either a single <gensym'd-spawned-id> keyword (for :spawn) or a join-bookkeeping map (for :spawn-all). Allocated lazily — absent until the first declarative-:spawn / :spawn-all spawn. |
005 §Declarative :spawn / 005 §Spawn-and-join via :spawn-all |
| Machines | [:rf/runtime :machines :spawn-counter] |
Per-frame hand-emitted-spawn fallback counter — {<machine-id> <int>}. The :rf.machine/spawn fx-handler's fallback allocator bumps this slot when a spawn args carries no pre-allocated id (no :spawn-id, no :rf/spawned-id). Declarative :spawn does NOT use this slot — its counter lives inside the parent's snapshot at [:rf/runtime :machines :snapshots <parent-id> :rf/spawn-counter]. Allocated lazily — absent until the first hand-emitted spawn. The two-tier split is pattern contract per 005 §Spawn-id allocator — counter location. |
005 §Spawn-id allocator — counter location |
| Routing | [:rf/runtime :routing :current] |
The current route slice (:id :params :query :transition :error :fragment :nav-token). Schema :rf/route-slice. |
012 §The route slice |
| Routing | [:rf/runtime :routing :pending-navigation] |
The pending-navigation slot, populated by the runtime when a :can-leave guard rejects a navigation; cleared by :rf.route/continue or :rf.route/cancel. Allocated lazily — absent until the first guard rejection. Schema :rf/pending-navigation. |
012 §Navigation blocking |
| Routing | [:rf/runtime :routing :scroll-positions] |
Saved scroll-position map keyed by URL, LRU-capped. | 012 §Scroll restoration |
| Routing | [:rf/runtime :routing :scroll-positions-order] |
LRU recency vector for :scroll-positions. |
012 §Scroll restoration |
| Routing | [:rf/runtime :routing :nav-token-counter] |
Per-frame monotonic integer for nav-token allocation. | 012 §Navigation tokens |
| Routing | [:rf/runtime :routing :pending-nav-counter] |
Per-frame monotonic integer for pending-nav request ids. | 012 §Navigation blocking |
| Elision | [:rf/runtime :elision :declarations] |
Schema-derived size-elision nominations — {<path-as-vector> → {:large? bool :hint <str-or-nil> :source :schema}}. Populated additively at boot by the schema walker for every :large? true slot in (rf/app-schema). Schemas are the only nomination path — un-schema'd slots exceeding the threshold fire the dev-mode :rf.warning/large-value-unschema'd advisory but are NOT elided. Consulted by the rf/elide-wire-value walker at every wire-boundary emit. |
009 §Size elision in traces |
| Elision | [:rf/runtime :elision :sensitive-declarations] |
Privacy sibling for :sensitive? true slots — {<path-as-vector> → {:sensitive? bool :hint <str-or-nil> :source :schema}}. Populated additively at boot by the schema walker from every :sensitive? true Malli slot and consumed by the schema-validation emit-site's :value / :explain redaction path. |
009 §Size elision in traces / 010-Schemas.md §:sensitive? |
| SSR | [:rf/runtime :ssr :hydration] |
The SSR hydration metadata block — {:server-hash <8-char-hex-or-absent> :version <int-or-absent>}. Written by the reference :rf/hydrate event handler from the payload's :rf/render-hash and :rf/version; consumed by verify-hydration! (reads :server-hash after the first client render) and by the :rf.ssr/check-version fx (reads :version for the version-compatibility check). Allocated lazily — absent on frames that never hydrated. User code MUST NOT write here; implementations overriding the reference :rf/hydrate handler MUST preserve the :server-hash write or pass :server-hash opt to verify-hydration!. |
011 §The :rf/hydrate event |
User registrations and writes must avoid :rf/runtime. The migration agent flags any user-registered app-db schema or write under [:rf/runtime ...] as a Type-B migration. Schema-bearing implementations (re-frame2 reference) register the :rf/runtime schema at boot — (rf/app-schema-at [:rf/runtime]) returns the composed :rf/runtime shape (per Spec-Schemas §:rf/runtime), with per-machine refinements of :snapshots composed from registered machines' :data shapes.
Why one root. The :rf/* namespace already announces "framework-owned"; a structural container makes that footprint one key in user app-db rather than ten, supports future-additive subsystems (new sub-containers, never new roots) without churning the reserved-root contract, and lets tools dump "all framework state" with a single key read. The historical scheme (ten flat :rf/* siblings, plus four out-of-contract :rf.route/* drift keys) was retired by — pre-alpha clean break, no back-compat shim.
Reserved snapshot-internal keys (machine runtime)¶
A machine snapshot at [:rf/runtime :machines :snapshots <id>] is described in 005 §Snapshot shape as {:state :data :tags? :meta?} — the user-facing contract. The runtime also stamps a closed set of :rf/* slots inside the snapshot (some at the snapshot root, some inside :data) to thread per-machine bookkeeping through pure transitions and the SSR-survivable persisted state. These slots are framework-owned: user code MUST NOT write under them; conformance fixtures that pin them MUST treat them as the runtime's by-product. The reserved set is fixed-and-additive — names already in this table cannot be repurposed; new keys are added by Spec change.
| Reserved snapshot-internal key | Location | Value shape | Read/write rules | Spec |
|---|---|---|---|---|
:rf/spawn-counter |
snapshot root | {<id-prefix> <int>} — per-spawned-id-prefix integer counter map |
Written by the pure spawn-id allocator (allocate-spawned-id) on every declarative :spawn / :spawn-all so id sequencing is deterministic from the snapshot. (Hand-emitted :rf.machine/spawn fxs bypass this snapshot-internal counter and bump the parallel app-db-resident counter at [:rf/runtime :machines :spawn-counter <machine-id>] instead — see 005 §Spawn-id allocator — counter location for the two-tier split rationale.) synthesise-initial-snapshot stamps an empty map at registration. Hand-built fixture snapshots may omit the slot — (fnil inc 0) defaults absent slots to 0. Persists across pr-str / read-string round-trip. |
005, |
:rf/history |
snapshot root | {<compound-decl-path> <recorded-config>} — map keyed by compound declaration path (a [:vector :keyword]) to that compound's recorded configuration |
Written by the runtime during a history-bearing compound's exit cascade (per 005 §History states). The recorded value is a [:vector :keyword] absolute LEAF PATH for a deep-history compound (:deep? true) or a single :keyword DIRECT CHILD for a shallow-history compound (the runtime cascades its :initial chain on restore). NOT a single config — a machine may own several history-bearing compounds, each recorded independently; under :type :parallel the keys are region-qualified (head segment is the region name) so per-region recordings never collide (rf2-f87be). Read-only for users; synthesise-initial-snapshot does not seed it — allocated lazily, absent until a history-bearing compound is first exited (a machine with no history pseudo-states never carries the slot). Vectors-and-keywords only — EDN-clean; persists across pr-str / read-string round-trip. A recorded path that the current (hot-reloaded) definition no longer declares is a dangling recorded path — on restore the runtime falls back to the pseudo-state's :default-target / the compound's :initial rather than entering it (rf2-wgfv0). |
005 |
:rf/machine-type |
snapshot root | <machine-id> keyword OR an inline-:definition spec map |
Stamped by the spawn-fx onto a SPAWNED actor's snapshot (absent on singleton snapshots) so the actor's TYPE — and hence its handler — is recoverable purely from the revertible snapshot. A :machine-id spawn stores the registered TYPE keyword (the type outlives instances); an inline :definition spawn stores the spec map verbatim. The lazy resolver reads it on dispatch to re-materialise the actor's handler; the epoch-restore precondition reads it to admit a spawned-actor snapshot as a valid restore target. This is what makes a spawned actor's LIVENESS a pure function of app-db — eliminating the per-instance handler registration that previously held liveness outside the frame value. Persists across pr-str / read-string round-trip. Per 005 §Liveness is derived from app-db (rf2-a2sn1). |
005 |
:rf/bootstrap-pending? |
snapshot root | true (otherwise the slot is absent) |
Stamped by synthesise-initial-snapshot (and by the spawn-fx for spawned actors) on the freshly-allocated snapshot. The first event addressed to the machine runs the initial-entry cascade, then clears the slot via dissoc. NEVER true on a snapshot that has already processed an event. The slot is purely a "first dispatch" marker — it survives pr-str / read-string so a snapshot persisted mid-bootstrap (the SSR boundary case) resumes correctly. |
005, |
:rf/finished? |
snapshot root | true (otherwise the slot is absent) |
Transient. Set by the lifecycle-handler boundary (NOT by apply-transition-once) when the post-transition snapshot's active leaf declares :final? true — or, for parallel-region machines, when every region's active leaf is :final?. The lifecycle handler reads it to fire :on-done + auto-destroy, then the snapshot is dissoc'd from [:rf/runtime :machines :snapshots <id>] as part of teardown — so a finished :rf/finished? true snapshot NEVER persists. Pure machine-transition calls (the conformance corpus, JVM pure-fn tests) see snapshots free of this flag. Per 005 §Final states and. |
005, |
:rf/after-epoch |
inside :data |
{<decl-path-vector> <non-negative int>} |
The wall-clock :after-timer epoch map for flat / compound machines, keyed per scheduling node (its declaring state path), per 005 §Delayed :after transitions §Hierarchy interaction. commit-snapshot bumps ONLY the entries for nodes the transition exits / enters, so a still-active parent's entry — and its in-flight timer — survive a child-only sibling transition. A :rf.machine.timer/after-elapsed synthetic event carries [delay-key epoch decl-path]; the runtime fires the transition iff the scheduling node is still on the active path AND the carried epoch matches that node's current per-path entry. |
005, |
:rf/after-epoch-by-region |
inside :data |
{<region-name> {<decl-path-vector> <non-negative int>}} |
Per-region per-decl-path :after-timer epoch map for parallel-region machines, per 005 §Per-region :always / :after / :spawn scoping. Replaces :rf/after-epoch when the machine is :type :parallel — a sibling region's transition does not invalidate this region's in-flight timers via the shared :data slot. Each region carries its own per-node epoch map. |
005, |
:rf/self-id |
inside :data |
<spawned-machine-id> keyword |
Stamped by the spawn-fx on the spawned actor's initial :data so the actor knows its own address (e.g. for self-:dispatch, for the actor's body to read (:rf/self-id data)). Equal to the gensym'd id of the spawned actor; absent on singleton-machine snapshots. |
005 |
:rf/parent-id |
inside :data |
<parent-machine-id> keyword |
Stamped by the spawn-fx on a declarative-:spawn / :spawn-all spawned actor's initial :data. The finalize-cascade reads it to locate the parent's snapshot at [:rf/runtime :machines :snapshots <parent-id>] for the :on-done callback. Absent on hand-emitted (non-declarative) spawns. |
005 |
:rf/spawn-id |
inside :data |
<vector-of-keywords> — the absolute prefix-path of the :spawn-bearing state node |
Stamped by the spawn-fx on a declarative-:spawn / :spawn-all spawned actor's initial :data. Together with :rf/parent-id it addresses the runtime spawn-registry slot at [:rf/runtime :machines :spawned <parent-id> <invoke-id>]. |
005 |
:rf/spawn-all-id |
inside :data |
<vector-of-keywords> — the :spawn-all-bearing state node's prefix-path |
Stamped by the spawn-fx on each child of a :spawn-all spawn. The finalize-cascade uses it to locate the parent's join bookkeeping map at [:rf/runtime :machines :spawned <parent-id> <invoke-all-id>]. |
005 |
:rf/spawn-all-child-id |
inside :data |
child-machine-id keyword (the :id of the child in the :spawn-all :children map) |
Stamped alongside :rf/spawn-all-id so the finalize-cascade can mark exactly which child finished inside the parent's join bookkeeping. |
005 |
:rf/snapshot-version |
inside :meta |
int |
Versioning slot for snapshot/definition compatibility checks. When a definition's transition shape changes incompatibly, the author bumps :meta :rf/snapshot-version on the definition; restore compares the snapshot's version against the definition's and emits :rf.warning/machine-snapshot-version-mismatch (or, on the epoch-restore path, :rf.epoch/restore-version-mismatch) on disagreement. Per 005 §Snapshot shape (invariant 4), Spec-Schemas §:rf/machine-snapshot, and Tool-Pair.md §Time-travel. |
005 / Tool-Pair |
Persistence posture. Each row's transience is explicit in the "Read/write rules" column. The persisting slots (:rf/spawn-counter, :rf/history, :rf/machine-type, :rf/after-epoch, :rf/after-epoch-by-region, :rf/self-id, :rf/parent-id, :rf/spawn-id, :rf/spawn-all-id, :rf/spawn-all-child-id, :rf/snapshot-version) ride the snapshot across pr-str / read-string and through SSR hydration (011) and Tool-Pair epoch replay. :rf/history riding the (revertible) snapshot is what gives first-class history its restore-epoch / SSR-hydration behaviour for free, with no parallel side-table. The persistence of :rf/machine-type is load-bearing — it is how a spawned actor's liveness rides the (revertible) frame value rather than a parallel registrar. The transient slots are :rf/bootstrap-pending? (cleared on first event) and :rf/finished? (set transiently at the lifecycle-handler boundary and never persisted because the snapshot is dissoc'd on the same drain).
Runtime-stamped machine-spec keys (sibling vocabulary, NOT snapshot-internal). The runtime ALSO stamps a small set of :rf/* slots on the live machine-spec value (the runtime's "machine" record threaded through apply-transition-once and the lifecycle handlers) — these are NOT snapshot-internal and do NOT persist; they are reconstructed at handler-call time from the registrar and the dispatched event:
:rf/frame— the owning frame's id (defaulting to:rf/default):rf/platform— the active platform (:client/:server) per 011:rf/parent-id— the machine's own id (or the parent's id for spawned actors), used for trace addressing:rf/region— present iff the spec is a synthetic region-machine of a:type :parallelparent; the region-name keyword used byafter-epoch-pathto scope timers per 005 §Per-region scoping
These spec-level keys are stamped by prepare-machine-ctx (and by the parallel-regions synthesiser) and are visible to user callbacks via the unified context-map's :meta key (per — every machine callback receives a single context-map arg).
Open-map invariant. Snapshots are open maps: user :data keys at any depth are fine. The runtime-reserved set above is the closed subset of :rf/*-prefixed slots the runtime owns inside the snapshot. The migration agent flags any user write to [:rf/runtime :machines :snapshots <id> :data :rf/<reserved>] or to [:rf/runtime :machines :snapshots <id> :rf/<reserved>] as a collision.
Reserved sub-ids¶
The reserved set of framework-shipped sub-ids:
| Reserved sub-id | Returns | Spec |
|---|---|---|
[:rf/machine <machine-id>] |
The named machine's snapshot, or nil if not initialised. |
005 |
[:rf/route] / [:rf.route/id] / [:rf.route/params] / [:rf.route/query] / [:rf.route/transition] / [:rf.route/error] / [:rf.route/chain] |
Route-related reads | 012 |
For the user-facing API surface (signatures, status, cross-references) see API.md. For machine read mechanics see 005 §Subscribing to machines via sub-machine.
Cross-MCP indicator-field vocabulary (suppression counters)¶
The MCP servers (re-frame2-pair-mcp / story-mcp) consult the framework's wire-elision walker (rf/elide-wire-value, per API.md §rf/elide-wire-value) when assembling tool-response payloads. The walker drops :sensitive? true leaves and elides over-threshold :large? true leaves at the wire boundary; the count of suppressed items must surface back to the calling agent on the response map so the LLM can pattern-match "the payload was filtered" without re-inferring it from absence. Without a pinned vocabulary, each server invents its own slot shape (:dropped-sensitive vs :redacted-count vs :n-sensitive-dropped) and the agent host has to special-case per server. The cross-server value proposition collapses if every server invents its own dialect — same anti-pattern the :rf.mcp/* wire-marker pin (above) and the tools/mcp-conformance/NAMING.md verb pin defend against.
Reserved indicator slots (MCP-shaped returns)¶
| Indicator slot | Meaning | When present | Owner |
|---|---|---|---|
:dropped-sensitive |
Integer count of leaves the walker dropped because they matched :sensitive? true (Malli :sensitive? property or (rf/sensitive-path? ...) registration). |
On every tool response that walked a tree-typed payload, when the count is non-zero. Omit when zero. | MCP servers (cross-server reserved) |
:elided-large |
Integer count of leaves the walker replaced with the :rf.size/large-elided marker because they sat at a schema-declared :large? true slot. |
On every tool response that walked a tree-typed payload, when the count is non-zero. Omit when zero. | MCP servers (cross-server reserved) |
Unqualified, not namespaced. These two slots are reserved as unqualified keys (:dropped-sensitive, :elided-large) — not under :rf.size/* or :rf.mcp/*. The rationale: they ride alongside tool-shaped payloads ({:trace [...] :dropped-sensitive 3}, {:db {...} :elided-large 2}) where the tool's own slot vocabulary is unqualified by convention; introducing a namespaced key here would split the response shape across two key conventions and burn agent-host pattern-match budget for no information gain. The wire markers at the leaf-substitution site stay namespaced (:rf.size/large-elided, :rf/redacted) — those are addressable values the agent re-fetches; these counts are scalar summaries on the envelope.
Streaming payloads. Subscribe-style notifications (per re-frame2-pair-mcp's subscribe per tools/re-frame2-pair-mcp/spec/003-Tool-Catalogue.md) carry the same two slots on each progress payload and on the final summary — see skills/re-frame2-pair/references/streaming-subscriptions.md for the live shape.
Conformance gate. Per Spec 009 §"Size elision in traces" — "Indicator field on tool responses" (MUST-level: tools that return structured response maps MUST carry an :elided-large count alongside the existing :dropped-sensitive count, one MUST-level row per consumer-facing tool that walks a tree-typed payload). The shape-conformance test lives in tools/mcp-conformance/wire-vocab/ (cross-server vocabulary gate); the per-server catalogue entries (re-frame2-pair-mcp's 003-Tool-Catalogue.md and story-mcp's 002-Tool-Registry.md) document each tool's indicator-field row.
Reserved panel-chrome surface (on-box consumers)¶
Dev-tools panels that render trace data inherited from re-frame2-xray and the post-v1 stories library (per Tool-Pair.md) surface the same two counters as panel chrome, not as JSON fields. The chrome shape is:
| Chrome string | Meaning | Where rendered |
|---|---|---|
[● REDACTED N] |
N leaves dropped because they matched :sensitive?. Mirror of :dropped-sensitive on MCP returns. |
Xray panel bottom-rail indicator; story trace-panel inline marker; analogous slots in any future on-box panel. |
[● ELIDED N] |
N leaves replaced with a :rf.size/large-elided marker. Mirror of :elided-large on MCP returns. |
Same surfaces as [● REDACTED N]; the two indicators may render side-by-side when both are non-zero. |
The bullet glyph (●) and the square-bracket delimiters are the canonical shape — the user clicks the indicator to opt in for a single fetch (per Spec 009 §"Consumer-side defaults", on-box listener integrations). The pair is bullet-identical across panels so an agent watching a panel screenshot recognises the indicator on either consumer.
Cross-references¶
tools/mcp-conformance/NAMING.md— canonical verb-vocabulary home for the MCP triplet; the indicator-field vocabulary above is its scalar-counter peer.tools/mcp-conformance/TOKEN-BUDGETS.md— cross-MCP token-budget posture; indicator counters ride the same response envelope themax-tokens/:rf.mcp/overflowcontract governs.tools/mcp-conformance/wire-vocab/— Malli + grep conformance gate over the:rf.mcp/*/:rf.size/*wire-marker namespaces (the values these counters describe).- Spec 009 §"Size elision in traces" — Indicator field on tool responses — the MUST-level requirement this convention pins.
- Spec 009 §"Privacy / sensitive data in traces" — the
:sensitive?mechanism whose drops the:dropped-sensitivecounter sums.
Privacy config-knob naming (on-box UI vs off-box wire egress)¶
Cross-reference: Privacy.md §Config knobs — the cross-artefact inventory of every privacy surface (Spec 010 schema meta, Spec 014 HTTP denylists, Spec 015 path-marks, the epoch
:redact-fn, the cross-MCP filters) plus the composition order from handler exit to off-box wire. This section pins the verb split; Privacy.md pins where each verb is consumed.
Consumers of the :sensitive? filter (per Spec 009 §"Privacy / sensitive data in traces") expose a user-controllable knob that decides whether sensitive values pass through the consumer's surface. Two consumer classes exist, and they MUST use different verbs for the knob name — the verb encodes which trust boundary the user is crossing:
- On-box devtools UI consumers (Xray panel, story trace panel, future on-box panels) use the
show-sensitive?verb under the:trace/*ns (e.g.:trace/show-sensitive?). The semantics are "the panel is for me, do I want to look" — the sensitive values are already in the same process as the operator; the toggle controls UI visibility, not egress. - Off-box LLM-egress consumers (re-frame2-pair-mcp, story-mcp wire pipelines; re-frame2-pair preload before fan-out to a hosted LLM endpoint) use the
include-sensitive?verb, unqualified (the bare key on a per-call args map; e.g.(rf/elide-wire-value v {:include-sensitive? false}), or{:rf.size/include-sensitive? false}when carried alongside the size-elision policy keys per Conventions §Reserved namespaces:rf.size/*). The semantics are "do I cross the trust boundary out of the process" — the toggle controls wire egress, not panel visibility.
Both verbs default to suppress (show-sensitive? false, include-sensitive? false) per Spec 009's default-private posture. The verb choice is the discriminator: a reader scanning a config flag knows whether the knob governs UI visibility (show-) or wire egress (include-) without re-deriving from context.
A sixth consumer adding a knob picks the verb by trust-boundary class — on-box panel uses show-sensitive?, off-box wire uses include-sensitive?. Cross-reference: Spec 009 §Privacy / sensitive data in traces — Consumer-side defaults.
Feature-modularity prefix convention¶
A feature is identified by its id prefix, not by a registry kind. By convention a feature with prefix :cart:
- Event ids:
:cart/...and:cart.<area>/...(:cart/initialise,:cart.item/add) - Sub ids:
:cart/...(:cart/items,:cart/total) - View ids:
:cart/...(:cart/summary,:cart.item/row) - App-db slice:
[:cart] - Schemas registered under
[:cart]paths - Fx specific to the feature:
:cart.<sub-area>/...(:cart.persistence/save)
A feature does not reach into another feature's slice directly — it goes through the other feature's subs (to read) and dispatches the other feature's events (to write). Construction prompt CP-6 enforces this at scaffold time.
Full rationale: 000-Vision §Pointers to per-area Specs (Features) and Construction-Prompts.md §CP-6.
Canonical event-vector shape (best practice)¶
The canonical call-shape for an event vector — and for the parallel subscribe query vector — is id-first, with at most one trailing map:
[<event-id>] ;; trivial — id only
[<event-id> <single-scalar>] ;; single-argument
[<event-id> {<k> <v> ...}] ;; multi-argument — single map payload
Same shape for subscribe: [:items-filtered {:status :pending :limit 20}].
This is best practice, not enforced. The framework runtime accepts variadic vectors ([<id> a b c]) — the v1 multi-positional shape is tolerated for migration and caller convenience, and the linter nudges new code toward the map form (per MIGRATION §M-19). Boundary validators (dispatch, dispatch-sync, the pair-tool MCP dispatch surface) check vector? only; they do not reject variadic shape.
Why the canonical form is [<id> <map>]:
- Name over place. Map keys carry meaning that positional args don't; see Principles §Name over place.
- Refactor stability. Adding a field is one key, not a coordinated edit across every call site.
- Generator-amenable. AI scaffolds (per Construction-Prompts §CP-1) emit the map form; round-tripping through the spec preserves it.
unwrapsugar. The optionalunwrapinterceptor (per API §Standard interceptors) assumes this exact shape — handlers using it destructure the payload directly:(fn [_ {:keys [...]}] ...).
Cross-refs: 002 §Routing — canonical call shapes table, Construction-Prompts §CP-1 — Call-shape convention, MIGRATION §M-19.
Tool dispatch frame-envelope convention¶
Canonical re-frame2 devtools (Xray, Story, future tools under :rf.<tool>/* — per §Reserved namespaces) host their own state in their own frame. A tool view sitting under a frame-provider for, say, :rf.xray MUST end up dispatching its own events (:rf.xray.epoch/set-db-view-mode, etc.) into :rf.xray — never into :rf/default or into the inspected app frame. The convention has two cases — pick by the moment the dispatching fn is invoked, not by what the call site looks like.
| Case | Convention | Where the frame is captured |
|---|---|---|
Synchronous dispatch from a tool view (or a tool handler). The dispatch fires while the surrounding frame-provider / handler binding is still live — typical reg-view bodies, reg-event-fx bodies, sync helpers called from either. |
Bare (rf/dispatch [...]) — OR the explicit two-arg form (rf/dispatch [...] {:frame :rf.xray}) for a self-documenting call site. |
The framework captures :frame at dispatch time from the active resolution chain (per 002 §How :frame gets attached). Either shape is correct. |
Async boundary — the dispatching fn is created during render/handler-time but FIRES later: React :on-click / :on-change callbacks, setTimeout, setInterval, Promise.then, websocket onmessage, intersection-observer callbacks, third-party SDK callbacks. |
(let [{:keys [dispatch]} (rf/frame-handle :rf.xray)] (fn [...] (dispatch [...]))) — captures the frame at handle-creation and locks the op to it. Equivalent for an arbitrary fn body: (rf/frame-bound-fn [...] ...) (macro) or (rf/frame-bound-fn* :rf.xray (fn [...] ...)). |
The handle's op (or the frame-bound-fn binding [*current-frame* :rf.xray] block) carries the captured frame to every later invocation, so the dispatch is routed correctly regardless of when the callback eventually fires. (Per 002 §frame-handle.) |
Why two cases, not one. The framework's dispatch resolution chain (per 002 §How :frame gets attached) reads :frame correctly from explicit opts, from reg-view-injected closures, AND from the router-established dynamic binding around every running handler. The first case lets tools rely on those mechanisms without ceremony. The second case is the one place the chain falls through — async callbacks fire on a fresh JS stack with no dynamic binding, no React-context tier, no router scope; frame-bound-fn is what bridges that gap by capturing the frame as closure state at the moment the callback is constructed.
frame-bound-fn is NOT a fix for "view doesn't update on click". That symptom — the app-db slot changes, the sub recomputes, but the rendered tree doesn't refresh until an unrelated repaint — is a different bug class entirely: the Reagent adapter's lazy-seq deref tracking failure (a (for ...) body holding @(rf/subscribe ...) that hasn't realised by the time the reactive scope unwinds). The fix is doall / mapv / into [:<>] around the lazy seq inside the render-fn, NOT a frame-bound-fn wrap. Per 006 §Lazy-seq deref tracking. The two failure modes look superficially similar ("click does nothing visible") but the cures don't overlap — diagnosing the wrong class is a debugging trap. If the dispatch reached the right frame's handler and the app-db slot changed, the issue is reactive tracking, not envelope routing.
Tool authors writing new devtool surfaces should default to the synchronous shape for view-internal wiring and reach for frame-bound-fn only at genuine async boundaries — over-wrapping is harmless but adds noise; under-wrapping at an async boundary fails open to :rf/default. The framework emits :rf.warning/dispatch-from-async-callback-fell-through-to-default when an async-callback dispatch falls through, which is the runtime signal that a frame-bound-fn wrap is missing.
See 002 §frame-handle and 002 §frame-bound-fn / frame-bound-fn* for the primitives and 006 §Lazy-seq deref tracking for the adjacent-but-distinct Reagent bug class.
:interceptors is positional, not metadata (reg-event-*)¶
For reg-event-db / reg-event-fx / reg-event-ctx, the interceptor chain lives in the positional middle slot, not inside the metadata-map. The metadata-map is reserved for reflection (:doc, :schema, :tags, :platforms, :ns, :line, :file) — keys tooling reads back from the registrar to describe what was registered.
;; correct — metadata-map for reflection, interceptors in the third positional slot
(rf/reg-event-db :cart.item/add
{:doc "Add an item to the cart." :schema CartItemAddEvent}
[undoable schema/validate-at-boundary-interceptor]
(fn [db [_ item]] (update db :items conj item)))
;; correct — no metadata, just the legacy 2-arg `[interceptors] handler` form
(rf/reg-event-db :cart.item/add
[undoable]
(fn [db [_ item]] (update db :items conj item)))
;; WRONG — `:interceptors` inside the metadata-map is silently ignored.
;; The runtime emits :rf.warning/interceptors-in-metadata-map at registration.
(rf/reg-event-db :cart.item/add
{:doc "Add an item." :interceptors [undoable]} ;; <- chain dropped
(fn [db [_ item]] (update db :items conj item)))
The runtime warns at registration time when :interceptors appears inside the metadata-map (:rf.warning/interceptors-in-metadata-map, per §Reserved namespaces — :rf.warning/*). Hot-reload tools and 10x surface the warning so the typo doesn't reach production.
This rule is reg-event-*-specific. reg-frame's metadata-map does recognise :interceptors (per Spec 002 §:interceptors — add interceptors to a frame's events) — frames have no positional middle slot, so frame-level interceptors live on the metadata-map by necessity.
The three-form family is deliberately preserved. The three-form family (reg-event-db, reg-event-fx, reg-event-ctx) is deliberately preserved from re-frame v1 for migration ergonomics. The noise is acknowledged; the cost of breaking v1 muscle memory exceeds the readability win of collapsing.
No silent swallow — recognised input MUST signal¶
This is the repo-level honest-signal rule the §:interceptors is positional warning above is one instance of. It is the normative realisation of Principles §No silent swallow — that principle names the why; this section is the MUST.
Rule. A user-supplied value that is recognised as input but cannot be honoured MUST produce a structured warning or error. Silent ignore is allowed only for explicitly namespaced extension keys in an extension map.
"Recognised as input" means the value reached a slot the runtime reads — an opts key in a known opts position, an fx-override of a reserved fx-id, a callback whose return the runtime consumes, one of a pair of explicit options. "Cannot be honoured" means the runtime declines to act on it: it's an unknown key, the override is ignored, the return is dropped, the two options conflict. In every such case the runtime MUST signal — a dev-gated :rf.warning/* advisory (per §Error-id and warning-id grammar) when the cascade can continue safely, or a :rf.error/* when it cannot. Silence is a dishonest signal: the caller observes success, the value vanishes, and the defect surfaces far from its cause with no breadcrumb to follow.
The extension-key carve-out is load-bearing¶
The single exception — silent ignore of explicitly namespaced extension keys in an open extension map — is what makes the rule compatible with §Reserved namespaces' open-map accretion and Principles §Spec-ulation. An open map (registration metadata, a machine snapshot's :data, a spawn-spec, a frame config) tolerates user-namespaced keys it does not recognise by design — that is how a producer adds vocabulary without a coordinated breaking change. Those keys are not "recognised input the runtime declined to honour"; they are foreign keys the runtime was never asked to interpret. The discriminator is recognition: a key in the framework's known set that the runtime drops is a silent swallow (banned); a user-namespaced key in an explicitly open map that the runtime never claims to read is accretion (allowed). A bare or framework-namespaced key the runtime does not recognise is on the wrong side of the line — it reads as a typo of a real key and MUST warn (the :rf.warning/interceptors-in-metadata-map and :rf.warning/unknown-dispatch-opt cases).
Pre-alpha is the window to be strict¶
Pre-alpha re-frame2 carries no in-flight consumers a strict signal would break, so the rule ships at full strictness: every recognised-but-unhonourable input warns or errors today, with no tolerance grace period. The only sanctioned future relaxation is an opt-in :allow-unknown? true escape hatch on the relevant surface — a caller who deliberately wants forward-compatibility against a newer producer's vocabulary can suppress the unknown-key warning for that call. The escape hatch is not shipped pre-1.0 and is recorded here only to fix the one forward-compat lever so a later relaxation is additive (a new opt) rather than a contract change to the rule itself. Absent the opt, the default is strict.
Per-surface applications¶
The rule is cross-cutting; each surface applies it in its own spec and registrar, NOT here. The concrete instances that motivated naming the rule (each fixed in its own change, each an application of this principle):
| Surface | Recognised-but-unhonourable input | Instance bead |
|---|---|---|
| Core dispatch | unknown opts key in the dispatch opts map (:rf.warning/unknown-dispatch-opt) |
rf2-jbzhj |
| Core fx | reserved-fx fn-override that was silently ignored while :rf.fx/override-applied lied that it applied |
rf2-nnma3 |
| Managed HTTP | :on-failure nil swallowing a real (non-aborted) failure with no dev-time signal |
rf2-rl5tt |
| SSR | conflicting :payload-keys + :payload-policy explicit opts (consolidated to one :payload, fail-closed) |
rf2-pffil |
| State machines | :on-spawn callback return silently dropped while the teaching surface implied it was recorded |
rf2-g72p8 |
| pair-mcp | :unknown-tool error envelope that dead-ended an agent with no :hint / tools/list pointer |
rf2-tkmik |
| Schemas | reg-app-schema silently accepting a bare keyword as opts and registering against the default frame |
rf2-52dfy |
New surfaces apply the rule by mechanism: if a recognised input cannot be honoured, signal it — and reach for the extension-key carve-out only when the dropped key is a user-namespaced key in an explicitly open map. A surface that seems to need silent-ignore of a recognised input is evidence of a missing warning, not an exception to the rule — file a bead against the owning spec rather than swallowing.
Facade policy — a facade exports the front porch, the workshop lives behind a door¶
Sibling governance rule to §No silent swallow: that rule keeps a surface honest; this one keeps a facade legible.
re-frame2 has three facade namespaces — re-frame.core (the framework), re-frame.story (the stories library), and day8.re-frame2-xray.core (the Xray devtool). Each one is the single namespace a consumer requires to use its product. Left ungoverned, each draws the line between "what a newcomer needs first" and "what a power user reaches for occasionally" by accretion — and they draw it differently. The failure mode is a front porch with the workshop, the fuse box, and three oscilloscopes bolted to it where a newcomer expected a doorbell: the one var they need is buried among forty they will never call, and the namespace stops telling them where to start.
Rule. A facade leads with front-porch surfaces — the vars a typical consumer reaches for in ordinary use. Advanced and tooling surfaces default to explicit subnamespaces (
re-frame.<area>/re-frame.story.<area>/day8.re-frame2-xray.<area>) rather than the facade, unless a deliberate single-import re-export is justified (per §The ruling below). A subnamespace require is itself a signal — typing(:require [re-frame.tooling])is the consumer announcing "I am in advanced territory now", which a barere-frame.corerequire never has to whisper; the policy's job is to keep that signal meaningful, not to forbid every advanced var from the facade.
The tier vocabulary that classifies a surface — front-porch | advanced | tooling | adapter | testing | internal-public | deprecated — is owned by API.md; this section governs placement against those tiers, not the taxonomy itself.
The Tier column is a documentation/review tier, not a namespace boundary. The Tier value on a surface's API.md row (the field the downstream manifest consumes — per API.md §Tier taxonomy) records who reaches for it so the Guide, the skills, and a reviewer know what to foreground. It is not an instruction that every advanced or tooling surface must live in its own namespace. Namespaces split around dependency shape, product workflows, and reader expectations — not by mechanically mapping one tier to one namespace. A wholesale tier-equals-namespace carve-up is rejected (see the ruling below): it would scatter one product's app-authoring vocabulary across several requires and tend to silt up a vague re-frame.runtime junk drawer that means "everything that isn't front-porch", which is exactly the legibility loss this section exists to prevent.
The diff-time obligation. A diff that adds a public var to a facade namespace MUST, in the same change:
- Classify it — name the var's tier (per the API.md taxonomy above).
- Justify the facade placement — state why a
front-porchclassification earns a facade export, or why anadvanced/toolingsurface is nonetheless on the facade rather than behind a subnamespace door. "It was convenient" is not a justification; "every consumer calls it on their first hour" is.
A var that classifies as anything but front-porch and lands on the facade without a stated justification is a reviewable defect, not a style nit — it is the accretion this rule exists to stop, caught at the one moment (the diff) when the cost of moving it is a one-line require change rather than a consumer-visible break. A justified non-front-porch export (an intentional single-import re-export, per the ruling below) is not a defect — the defect is the silent one.
The ruling — keep product facades coherent; no wholesale tier-equals-namespace split¶
The aggressive namespace split was considered as a project decision and ruled against (2026-06-03): there is no wholesale tier-equals-namespace carve-up now. Product facades stay coherent on purpose. The policy above (front-porch on the facade; classify-and-justify each facade addition at diff time) holds; what it does not license is splitting a product's surfaces across many namespaces merely because the Tier column says they are not all front-porch.
Single-import product facades are kept coherent on purpose. re-frame.core's single-import ergonomics for the app-authoring surfaces — machines, routes, flows, schemas, frames, dispatch, subscribe — are valuable. A developer building a normal app reaches for vocabulary across several of these in one sitting; making them learn and require several namespaces to assemble an ordinary app is a worse experience than a somewhat large rf facade. The legibility this section protects is the front-porch-vs-workshop line within a coherent product facade, not a mandate to shard the facade itself.
Existing non-front-porch facade exports are intentional exceptions, not quiet violations. re-frame.core, re-frame.story, and day8.re-frame2-xray.core today re-export a number of surfaces an API.md row classifies as advanced or tooling. Under this ruling those are deliberate single-import re-exports, pending the audit below — they are recorded exceptions, not silent policy breaches to be flagged one-by-one. The diff-time classify-and-justify obligation above still applies to new facade additions; it is the forward gate, while the standing exports are grandfathered into the audit.
The audit obligation — before the first meaningful external release. Before re-frame2's first meaningful external alpha / beta release — not "before v1" — every non-front-porch re-frame.core (and, by extension, facade) export is reviewed and either moved to an existing feature / tool namespace or documented as an intentional single-import re-export with its justification. The deadline is the external release, not v1, because the moment real users copy examples and require the facade, a later namespace move becomes a breaking change for them even while the project is still pre-v1. The audit is the one cheap window to settle placement; after it, moves cost consumer breakage.
Story / Xray nuance — tooling is their front porch. re-frame.story and day8.re-frame2-xray.core are tooling products. For them, "tooling" surfaces can legitimately be the front porch — a Story user's everyday vocabulary is the story-authoring and evidence surfaces, even though the global tier classification calls them tooling. Splitting those facades into subnamespaces (e.g. re-frame.story.testing / re-frame.story.evidence / re-frame.story.fingerprint) is justified only when the subdomains are genuinely separate workflows a user enters at different times — not merely because the global Tier column says tooling. The discriminator is the user's workflow, not the tier label.
This ruling fixes the policy; the facade audit above is the application, deferred to its own pre-release pass. Nothing here mandates a particular subnamespace, and nothing here licenses sharding a coherent product facade to satisfy the tier column.
Lifecycle-verb law — a closed verb vocabulary for naming lifecycle and facade APIs¶
Sibling governance rule to §Facade policy: that rule decides where a surface lives; this one decides what it is called. A new lifecycle or facade API should be nameable by following the law, not by taste — so two authors adding symmetric teardown surfaces a year apart reach the same verb.
The law is a closed verb vocabulary. Each verb owns one shape of operation; the discriminator is mechanism, not feel. The §Tear-down verb axis and the §Naming — bang suffix sections are the in-depth treatment of two slices of this law (clear-/destroy- and the ! axis respectively); this section is the complete closed roster they sit inside.
| Verb | Owns | Mechanism (the discriminator) |
|---|---|---|
clear-* |
Drop registrations or caches. | Removes id(s) from a registry the process owns, or drops a process-local cache / buffer outright. Symmetric inverse of reg-*. Detail at §Tear-down verb axis. |
unregister-* |
Remove one thing by id. | Single-id removal from a global listener-style table where clear-* would read as "drop the whole registry". Used where the registration is a hook, not a registrar entry (unregister-listener!, unregister-epoch-listener!). |
destroy-* |
Tear down an instance plus the resources it owns. | The target has identity and a creation moment; teardown invalidates downstream consumers and releases per-instance machinery (destroy-frame!, destroy-adapter!). Lifecycle symmetry with the creating call. Detail at §Tear-down verb axis. |
reset-* |
Keep identity, return to baseline. | The instance survives; its state is wound back to initial. Distinct from destroy-* (identity is retained, not removed). reset-frame! is destroy-frame! + re-reg-frame under the same id — same address, fresh state. |
unsubscribe |
Release one ref-count. | Decrements a live ref-count on a cache entry (the inverse of subscribe, not of reg-sub). A carved-out singleton — un- is a one-element set, not a generalisable prefix. Detail at §Carve-out: unsubscribe. |
watch-* |
Start observing; return a 0-arity stop fn. | Begins an observation that runs until stopped; the return value is the stop handle — (let [stop (watch-x …)] … (stop)). There is deliberately no unwatch-* verb: the stop fn closes over exactly what it must tear down, so a separate id-keyed unregister surface is redundant. |
attach! / detach! |
Add / remove a listener to an existing thing. | A listener bound to a host or framework surface, paired symmetrically. The ! marks the process-level mutation per §Naming. |
mount! / unmount! |
Put a UI shell into / out of the DOM. | A DOM or render-shell lifecycle — the component or panel enters / leaves the document. Paired; ! per §Naming. |
open! / close! |
Toggle visibility, not lifecycle. | Shows / hides an already-mounted surface. Not a teardown verb: close! leaves the instance alive and re-open!-able; a closed panel still exists. Reach for destroy-* / unmount! when the thing actually goes away. |
The boundaries that earn their keep. Three pairs of verbs sit close enough to be confused; the law fixes which is which:
destroy-*vsreset-*— both rewind state, butdestroy-*removes the identity (the id no longer resolves) whilereset-*keeps it (same id, baseline state). A frame youreset-frame!is still addressable; a frame youdestroy-frame!is gone.close!vsunmount!/destroy-*—close!is visibility (the thing is hidden but intact and reopenable);unmount!/destroy-*is lifecycle (the thing leaves the DOM / is torn down). A modal youclose!keeps its state for the nextopen!; one youunmount!does not.watch-*'s stop-fn vs anunwatch-*that does not exist — observation teardown rides the returned stop fn, never a separate id-keyed verb. If you find yourself wantingunwatch-x, you wanted to capture and call the stop fnwatch-xalready handed you.
configure! (mutation) vs describe-config / current-config (reads) — resolving the bang inconsistency¶
The law extends to the configuration surface, and in doing so resolves an inconsistency the three facades carry today. The config-mutation verb is configure! — it ends in ! because it mutates a process-level slot (per §Naming bucket 2/3), exactly like attach! and mount!. The config-read verbs are describe-config (the declared shape / schema of the config surface) and current-config (the live values now in effect) — no bang, because reads mutate nothing.
| Surface | Verb | !? |
Owns |
|---|---|---|---|
| Mutate config | configure! |
yes | Set process-level / tool-level config knobs. |
| Read the config shape | describe-config |
no | The declared key set + value shapes the surface accepts. |
| Read the live config | current-config |
no | The values currently in effect. |
The inconsistency this resolves. Today re-frame.core exposes config mutation as (rf/configure key opts) — no bang — while re-frame.story and day8.re-frame2-xray.core already spell the same operation configure! (e.g. (story/configure! {…}), (xray-config/configure! {…})). Same operation, two spellings, depending on which facade you happen to be standing on. The law closes the gap in favour of configure! across all three: config mutation is a process-level mutation, so it takes the ! the rest of the bang axis already mandates for that mechanism. re-frame.core/configure is renamed to re-frame.core/configure!; the read pair describe-config / current-config is the standardised, no-bang counterpart on every facade. (This subsumes the standalone "core F2" configure-vs-configure! item — there is no longer a per-facade choice to make.) The existing §Configuration surfaces bucketing is unchanged in substance; only the verb spelling for bucket 1 moves from configure to configure!.
How to name a new lifecycle or facade surface¶
Ask, in order:
- Does it drop registrations / a cache? →
clear-*. Remove one hook by id? →unregister-*. - Does it tear down an instance + its owned resources (identity goes away)? →
destroy-*. Wind an instance back to baseline (identity stays)? →reset-*. - Does it release one ref-count on a cache entry? →
unsubscribe(the carve-out — do not coin a newun-*). - Does it start an observation? →
watch-*, returning a 0-arity stop fn (never anunwatch-*). - Is it a listener add/remove? →
attach!/detach!. A DOM/shell in/out? →mount!/unmount!. A visibility toggle? →open!/close!. - Is it config mutation? →
configure!. A config read? →describe-config(shape) /current-config(live values).
The roster is closed: a surface that fits none of these is evidence the law is missing a verb — file a bead against this section rather than coining dispose- / teardown- / shutdown- / a fresh un-*. Adding a verb is a Spec change to this table, not a per-author call.
Implementation note — persistent data structures¶
Conformant implementations need a structural-sharing persistent collection library for app-db and frame state. CLJS gets this free; other in-scope JS-cross-compile-language ports pick a host-idiomatic library (Immer or Immutable.js for TypeScript / Squint; im.kt or kotlinx.collections.immutable for Kotlin/JS; native PDS from the source language for Fable (F#) / Scala.js / PureScript / Melange / ReScript / Reason). For the per-host options, why this is pattern-required, and how it composes with Goal 2 — Frame state revertibility, see 000-Vision §Host-profile matrix — Note on persistent data structures.
Naming: when does a surface carry !?¶
The bang (!) suffix on a public surface marks process-level state mutation that the registrar abstraction does not already own. The rule is principled, not stylistic, and slots every framework surface into one of four buckets. New surfaces pick their bucket by mechanism, not by feel.
1. Registry-shaped registrations — no bang¶
reg-* and clear-* mutate the registrar, but the registrar IS the side-effect abstraction. Calling reg-event-db to install a handler is no more "imperative" than calling defn — the verb's whole purpose is to extend a registry. Adding a bang would tag every registration in the framework, which is the opposite of useful signal.
reg-event-db,reg-event-fx,reg-event-ctx,reg-sub,reg-fx,reg-cofx,reg-frame,reg-flow,reg-route,reg-machine,reg-app-schema,reg-view,reg-view*,reg-head,reg-error-projector,reg-http-interceptorclear-event,clear-sub,clear-fx,clear-flow,clear-http-interceptor,reset-frame!,destroy-frame!
(rf/reg-event-db :cart.item/add (fn [db [_ item]] ...)) ;; no bang
(rf/clear-event :cart.item/add) ;; no bang
2. Listener registrations — bang¶
The caller hands a fn to a global hook the framework will invoke from arbitrary call sites. This is not a registrar-shaped operation — the listener table is a process-level mutable slot the surface mutates directly — so the bang earns its keep.
register-listener!,unregister-listener!,clear-listeners!register-epoch-listener!,unregister-epoch-listener!
(rf/register-listener! ::audit (fn [event] ...)) ;; bang — hooks a global
(rf/unregister-listener! ::audit)
3. Adapter / platform installation — bang¶
Process-level state mutation outside the registrar — installing or tearing down the runtime's substrate adapter, swapping in a different schema validator, dropping the subscription cache. These surfaces touch implementation-defined slots that have nothing to do with the per-frame registries.
install-adapter!,dispose-adapter!set-schema-validator!,set-schema-explainer!clear-sub-cache!
(rf/install-adapter! reagent-adapter/adapter) ;; bang — installs runtime
(rf/set-schema-validator! my-validator-fn) ;; bang — swaps a global
4. Dispatch and subscribe — no bang¶
dispatch / subscribe are frame-relative side-effects, but the side-effect IS the program's normal mode of operation. Banging them would noise every domino call site in every event handler and every view. This is the "IO is the program" exemption — the same reason defn doesn't end in ! despite being a top-level effect.
dispatch,dispatch-sync,dispatch-latersubscribe,unsubscribe
How to slot a new surface¶
When adding a public surface, ask in order:
- Does it extend a registry by id? → bucket 1 (no bang).
- Does it install a fn into a global listener slot? → bucket 2 (bang).
- Does it mutate process-level state outside the registrar? → bucket 3 (bang).
- Is it a domino-shaped side-effect — dispatch, subscribe, drain? → bucket 4 (no bang).
The four buckets are exhaustive for the surfaces in API.md. The register-listener! rename rationale (no-bang → bang once the listener-registration shape was recognised) is recorded at API.md §Removed / not shipped. Surfaces that genuinely don't fit are evidence of a missing bucket — file a bead against this section rather than coining a fifth shape.
Tear-down verb axis — clear- vs destroy-¶
The bang axis above answers whether a tear-down surface carries !. The verb axis answers which verb the surface uses. The taxonomy is two-valued: clear- for in-process registrar / cache / buffer decrements, destroy- for lifecycle-boundary teardown. Every framework tear-down surface in API.md picks exactly one — except for one carved-out singular case (unsubscribe) explained below. New surfaces pick their verb by mechanism, not by feel.
clear-* — registrar / cache / buffer decrement (in-process)¶
Symmetric inverse of reg-*. Removes an id from a registry the process owns (event registrar, sub registrar, fx registrar, flow registrar, http-interceptor registrar) or drops a process-local cache / buffer outright.
- Single-id decrement, registry-shaped:
clear-event,clear-sub,clear-fx,clear-flow,clear-http-interceptor(bucket 1, no bang) - Drop-everything, process-level:
clear-sub-cache!,clear-trace-buffer!,clear-listeners!(bucket 3, bang)
destroy-* — lifecycle boundary¶
Tears down something with identity and a creation moment — a frame, an adapter installation. The pair (reg-frame …) / (destroy-frame! …) and (install-adapter! …) / (destroy-adapter!) are lifecycle symmetries: the second call invalidates downstream subscribers, releases per-instance machinery, and (for the adapter) flips the adapter-disposed? breadcrumb.
destroy-frame!destroy-adapter!
Renamed under this axis (one-cycle deprecation aliases)¶
The prior surface had dispose-adapter! for the adapter teardown. It collapses onto destroy- because adapter installation is a lifecycle boundary, symmetric with destroy-frame!:
dispose-adapter!→destroy-adapter!(lifecycle boundary)
The old name ships as a deprecated alias pointing at the same Var for one deprecation cycle; tooling that introspects :deprecated metadata will flag it. Per the Migration corpus M-53 for the v1→v2 mapping.
Carve-out: unsubscribe¶
unsubscribe is retained rather than renamed. The natural target name (clear-sub) is already taken by the symmetric inverse of reg-sub — the registrar decrement that removes a registration. The two operations are semantically distinct:
| Surface | Decrements | Symmetric with |
|---|---|---|
clear-sub |
the sub registrar (the registered handler fn for an id) | reg-sub |
unsubscribe |
the sub cache (a live ref-count for a query-v shape) |
subscribe |
Collapsing them would conflate registration with caching. The un- prefix is therefore carved out as the singular form for the cache ref-count decrement. Rule of thumb: if the framework grows another un-* surface, you have probably misread the axis — un- is a one-element set.
(Originally proposed unsubscribe → clear-sub; the rename was abandoned mid-implementation when the collision surfaced. The carve-out is the corrected resolution, recorded here so the next reader doesn't repeat the analysis.)
reset-frame! is not part of the tear-down axis — it is a compound destroy-frame! + reg-frame re-using the prior frame's id. The reset- prefix is reserved for this compound-recreation shape (the only current occurrence) and does not generalise.
How to slot a new tear-down surface¶
When adding a new tear-down surface, ask:
- Does it release a registered id, or drop a process-local cache / buffer? →
clear-*. - Does it tear down something with identity and a paired creation moment? →
destroy-*.
If neither fits, the surface is evidence the axis is missing a bucket — file a bead against this section rather than reaching for dispose- / reset- / un- / remove-. (un- is reserved for unsubscribe's carve-out and does not generalise.)
Configuration surfaces: configure! vs set-! vs per-frame metadata¶
re-frame2 has three orthogonal configuration surfaces. The user-facing question "where do I configure X?" depends on the lifetime of X and on whether the consumer needs to hand the framework a specific implementation reference (a function or component) versus just a keyword/value setting. The three buckets are exhaustive; every framework-owned config option slots into exactly one. New options pick their bucket by mechanism, not by feel. The mutation verb is configure! and the read pair is describe-config / current-config per §Lifecycle-verb law — configure! vs reads.
1. (rf/configure! key opts) — process-level runtime knobs¶
For knobs that apply globally to the framework runtime, are addressed by a keyword (no impl-reference required), and whose values are plain data (numbers, booleans, small maps). The full key vocabulary is enumerated at API.md §Configure keys and is fixed-and-additive.
(rf/configure! :epoch-history {:depth 50})— ring-buffer depth for the Tool-Pair epoch surface(rf/configure! :trace-buffer {:depth 200})— ring-buffer depth for trace events(rf/configure! :elision {:rf.size/threshold-bytes 16384})— wire-elision size threshold
The opts-map sub-keys mix two shapes deliberately: cross-surface policy slots use a namespaced keyword under the area's reserved :rf.<area>/* sub-namespace (e.g. :rf.size/threshold-bytes — the same key the wire-elision walker reads); one-off per-knob settings stay bare (e.g. :depth, :grace-period-ms). The rule is closed and the discriminator is whether the sub-key names a cross-spec policy slot or a one-off knob — full statement at API.md §Configure keys — Opts-key naming rule.
2. set-! / install-! fns — adapter-pluggable hooks¶
For substitution points where the consumer hands the framework a specific implementation (a function or component) that the framework will hold a strong reference to and call from arbitrary sites. The bang earns its keep because the surface mutates an implementation-defined process-level slot (per §Naming bucket 3).
(rf/install-adapter! reagent/adapter)— install the reactive-substrate adapter(rf/set-schema-validator! malli.core/validate)— swap the schema validator(rf/set-schema-explainer! malli.core/explain)— swap the schema explainer
These are NOT folded under configure because keyword-keyed addressing loses the type information that the consumer needs to pass an actual fn/component reference: configure is for data, set-! is for impls.
3. Per-frame metadata — frame-scoped overrides¶
For configuration whose lifetime is a single frame's existence — expressed at frame creation via reg-frame's metadata map or per-dispatch via the dispatch opts argument (per 002 §Per-frame and per-call overrides). These keys flow through the dispatch envelope; per-call merges over per-frame on key conflict.
:fx-overrides— replace registered fx handlers by id, for the lifetime of one frame (or one dispatch):interceptor-overrides— replace interceptors in the chain by:id:interceptors— add (prepend) interceptors to the chain:on-create/:on-destroy— lifecycle events fired at frame create / destroy:ssr {:public-error-id ... :dev-error-detail? ...}— SSR error-projection policy (per 011)
How to slot a new config option¶
When adding a new configuration surface, ask in order:
- Does it hand the framework a fn or component the framework must hold by reference? → bucket 2 (
set-!/install-!). - Is it a global runtime knob with a plain-data value? → bucket 1 (
configure!). - Does it apply only to a specific frame's lifetime (or a single dispatch)? → bucket 3 (per-frame metadata via
reg-frameor dispatch opts).
If the option seems to want two buckets, the option is doing two things and should be split. If it fits none, file a bead against this section rather than coining a fourth surface.
*-suffix naming for fn-versions of macros¶
When a macro has a fn-version (the unsweetened, runtime-callable surface), the fn gets a * suffix. Standard Clojure idiom — let / let*, fn / fn*. The macro is the ergonomic surface (parses extra shapes, captures source-coords from &form, defs Vars, injects locals, stamps invocation call-sites for tooling per 009 §:rf.trace/call-site — naming the invocation line); the *-fn is the plain-fn delegate that runtime callers invoke when they need a non-literal body, a computed id, registration without the macro tier, or higher-order use ((map dispatch* xs) — the macro can't ride a HoF position).
The current pairs:
| Macro (ergonomic) | Fn (* form) |
Spec |
|---|---|---|
reg-view |
reg-view* |
004 §reg-view* |
reg-machine |
reg-machine* |
005 §reg-machine vs reg-machine* |
dispatch |
dispatch* |
— call-site stamping |
dispatch-sync |
dispatch-sync* |
— call-site stamping |
subscribe |
subscribe* |
— call-site stamping |
inject-cofx |
inject-cofx* |
— call-site stamping |
->interceptor |
->interceptor* |
001 §Source-coordinate capture — definition-site coord stamping (rf2-siheh) |
The dispatch / subscribe / inject-cofx macros are the canonical invocation surface in user code — they pay no extra runtime cost in production (the call-site stamp DCEs under :advanced + goog.DEBUG=false) and let tooling render two click-to-jump links per error: registration-site (:rf.trace/trigger-handler) and invocation-site (:rf.trace/call-site). The *-fn forms exist for higher-order use and programmatic / REPL paths where there is no syntactic call site to attribute to.
Future macros that want fn partners follow the same convention.
The convention applies only where adding the * partner buys something — call-site stamping the macro performs that the fn-form must skip, per-element source-coord walks (reg-machine), or defn-shape expansion the macro performs (reg-view). For the other reg-* registrations (reg-event-db, reg-event-fx, reg-event-ctx, reg-sub, reg-fx, reg-cofx, reg-frame, reg-flow, reg-route, reg-app-schema, reg-app-schemas) the CLJS fn-alias lives under the macro's own name (per re-frame.core CLJS aliases): the macro stamps source-coords from &form on JVM; on CLJS, HoF / programmatic callers reach the same name as a plain fn (the call-site stamp is the only thing they lose). Adding a reg-event-db* synonym would be a pure alias and add no value; that's not done. (See Cross-Spec-Interactions §Family asymmetry for why the family is intentionally asymmetric.)
Coverage is asymmetric on purpose — and the asymmetry is invisible from a scan of the API. A reader sees dispatch* / subscribe* / inject-cofx* / reg-view* / reg-machine* and may infer a uniform convention; reaching for reg-event-db* then fails to resolve. The asymmetry is principled (only the macros above have a reason for a * partner) but easy to misread — surface this footnote when documenting new reg-* rows in spec/API.md §Registration.
Value-vs-fn naming — -interceptor suffix telegraphs value-shape¶
re-frame2 ships two classes of named, public, callable-looking surfaces. They share kebab-case identifiers and live side-by-side in user code, but only one class is actually a function. The convention answers a single question — "if I see this name, is it a Var holding a value or a callable fn?" — and lets future audit passes stop re-flagging the same fn-shaped callbacks as "name lies about value".
The discriminator is mechanical, not stylistic. Every public surface that looks callable slots into exactly one of the two classes below; new surfaces pick their class by mechanism.
Class 1 — Interceptor values (Vars holding maps · NOT callable)¶
A Var bound to a pre-built interceptor map (§Standard interceptors, §reg-event-* interceptor chain). The consumer drops the Var into a positional :interceptors vector; the framework treats it as a value, never invokes it as a fn. Calling such a Var as a fn ((rf/validate-at-boundary-interceptor ...)) raises ArityException.
Current Class-1 surfaces in API.md: validate-at-boundary-interceptor (Spec 010 — production-boundary schema validation), unwrap (Spec 004 — [id payload-map] unwrapping sugar). The factory (rf/redact-interceptor paths) (Spec 009 — payload-key redaction on the trace surface) is a fn that returns a Class-1 value; the returned interceptor value is the Class-1 artefact, and it inherits the rule below.
Rule. Class-1 surfaces MUST carry the -interceptor suffix on the Var name. The suffix telegraphs value-shape at the call site — a reader scanning an :interceptors vector sees the suffix and knows the slot holds a pre-built interceptor map, not a fn that needs invoking. The factory variant (redact-interceptor style) returns a -interceptor-suffixed value; the factory itself does not carry the suffix because it IS a fn.
;; correct — suffix telegraphs value-shape
(rf/reg-event-db :cart.item/add
[at-boundary-interceptor ;; Var · value
(redact-interceptor [[:credit-card]]) ;; factory returns value
unwrap-interceptor] ;; Var · value
(fn [db payload] ...))
;; reading this without the convention — is `validate-at-boundary-interceptor` a fn? a Var? Did the
;; author mean `(validate-at-boundary-interceptor)`? The suffix removes the ambiguity.
Class 2 — Function callbacks in spec-keyed slots (slot values that ARE fns · callable)¶
A slot in a registration map whose value the framework invokes as a fn at runtime. The slot-keyword identifies the surface (:on-spawn, :on-done, :on-error, :guard, :action, :entry, :exit, :after); the value MAY be a (fn [...]) literal OR a registered keyword id the framework resolves through a registrar (machines resolve :action / :entry / :exit through the action registry per Spec 005).
Current Class-2 slots: :on-spawn, :on-done, :on-error, :on-create, :on-destroy (lifecycle callbacks per Spec 002, Spec 005, Spec 014); :guard, :action, :entry, :exit, :after (machine-spec slots per Spec 005).
Rule. Class-2 slot-keywords keep their natural verb / noun naming. They do NOT carry an -fn suffix, an -interceptor suffix, or any other shape annotation. The slot-keyword's surrounding context — the spec-document that defines the slot, the registration map's enclosing macro — makes the fn-shape unambiguous. A reader meeting :on-spawn in a machine spec does not need a suffix to know the slot value is a fn; the slot-keyword's spec entry tells them so.
;; correct — no suffix on slot-keywords; the slot's spec defines the value-shape
(rf/reg-machine :auth.login/flow
{:initial :idle
:states {:idle {:on {:submit :submitting}}
:submitting {:entry :fire-request ;; registered action id
:on {:success :complete
:fail :failed}}
:complete {:on-done (fn [ctx] ...) ;; fn literal
:on-error ::handle-error}}}) ;; registered fn id
How to slot a new surface¶
When adding a callable-looking public surface, ask in order:
- Is the surface a Var bound to a pre-built interceptor map (calling it as a fn would raise
ArityException)? → Class 1, carry the-interceptorsuffix. - Is the surface a slot-keyword in a registration map whose value the framework invokes as a fn? → Class 2, keep natural verb / noun naming; no suffix.
If the surface seems to want both shapes — a name that doubles as both a Var and a fn — split it into two surfaces with distinct names. If it fits neither, file a bead against this section rather than coining a third shape.
Why two classes need two rules. The Class-1 / Class-2 split is real because the two surfaces resolve at different times: Class-1 Vars resolve at read time (the reader knows immediately whether the slot is a value or a call); Class-2 slot-values resolve at runtime through the slot's keyword identity (the reader looks up the slot's spec to learn the value-shape). The -interceptor suffix gives the Class-1 reader the same up-front clarity that the slot-keyword + spec gives the Class-2 reader. Surfacing this here saves future audit passes from re-flagging Class-2 fn-callbacks as "name lies about value" — they don't lie, the slot-keyword is the disambiguator.
Cross-references: API.md §Standard interceptors (Class-1 surfaces · current); Spec 005 §Machine spec (Class-2 slots · machines); Spec 002 §Per-frame and per-call overrides (Class-2 slots · frame lifecycle).
High-frequency abbreviations — fx, cofx, db are brand tokens, not aliases¶
fx, cofx, and db are deliberate terse abbreviations for the highest-frequency tokens in re-frame's vocabulary — they are not aliases for effect, coeffect, and app-db and the longer forms are not part of the API surface. The choice is inherited from re-frame v1 and preserved in v2.
Rationale:
- Frequency.
:dband:fxappear in every event handler's effect map.reg-cofx/inject-cofx/ the:coeffectskey appear in every coeffect site. Spelling them out (reg-coeffect,inject-coeffect,effect-map) would inflate handler bodies and registration sites by 10-15% for zero readability gain once the reader has met the abbreviations. - Brand. These tokens are part of how re-frame reads. The terseness is a feature: a handler body's structural shape is recognisable at a glance precisely because
:dband:fxare short. - One obvious way. Principles §Regularity over cleverness argues for one obvious way to do a thing. The abbreviations are that one way — there is no
effect/coeffect/app-dbsynonym in the public API. A fresh reader meets the abbreviations once (in the API reference, in the guide's first event-handler chapter); from then on the surface is uniform.
The rule for new public surfaces: if a token belongs to the closed handler vocabulary (fx, cofx, db, event, interceptor, frame, id, kind) use the established short form; if a token names a per-feature concept (flow, route, head, machine, schema) the spelled-out form is the canonical name. Do not coin alias pairs.
reg-* return-value convention¶
Every reg-* registration surface returns its primary id — the keyword (or path, for reg-app-schema) the caller registered with. This is uniform across the family: reg-event-db / reg-event-fx / reg-event-ctx / reg-sub / reg-fx / reg-cofx / reg-frame / reg-view / reg-view* / reg-machine / reg-machine* / reg-app-schema / reg-route / reg-flow / reg-head / reg-error-projector all return their first positional id argument. reg-flow returns the :id value of its flow-map (the primary id is carried by the map, not a separate arg); reg-app-schema returns its path (the path IS the registration id for app-db schemas, even though app-db schemas are not a registrar kind — they live in the schemas artefact's per-frame side-table per rf2-cq1ak / 010-Schemas §Per-frame schemas).
The uniformity is load-bearing. It lets call-site code thread the registration id without a separate literal:
(let [event-id (rf/reg-event-fx :cart.item/add ...)]
(rf/dispatch [event-id {:id ...}]))
(let [machine-id (rf/reg-machine :auth.login/flow ...)]
(rf/dispatch [machine-id :submit]))
Tooling, generators, and CP scaffolds rely on the return value to chain registrations into wiring code. The contract is fixed-and-additive: future reg-* surfaces ship with the same return shape.
reg-* frame-binding convention — opts kwarg, not main arg¶
The :frame keyword is the mounting concern for reg-* surfaces whose registrations are frame-scoped — it answers "which frame's registry does this slot live in", and is orthogonal to the registration's identity and behaviour. The uniform shape across the family is therefore: :frame rides on a trailing opts map (kwarg position), never mixed into the main registration arg.
;; correct — :frame in opts kwarg, separated from the registration's identity/behaviour
(rf/reg-flow flow-map)
(rf/reg-flow flow-map {:frame :session})
(rf/reg-app-schema [:user] UserSchema)
(rf/reg-app-schema [:user] UserSchema {:frame :session})
(rf/clear-flow :flow-id)
(rf/clear-flow :flow-id {:frame :session})
The convention extends dispatch / subscribe's opts-map shape — :frame is the same mounting key in the same kwarg position across the dispatch/subscribe/reg-*/clear-* family. Most reg-* surfaces adopt this shape: :frame lives in the trailing opts kwarg, not inside the registration's primary argument. The one principled exception is reg-http-interceptor per rf2-uheqq ((rf/reg-http-interceptor id interceptor-map)): because HTTP interceptors are themselves data — a registration IS an interceptor-map carrying :before / :after / :frame / :rf/registration-metadata — the shape mirrors the event-interceptor {:id :before :after} mental model (Spec 002) and folds :frame into the interceptor-map alongside its sibling slots. The family is uniform on intent (:frame is a kwarg, never a positional arg); the HTTP surface differs only in that its primary argument IS a map of kwargs.
reg-view auto-id derivation rule¶
Per Spec 004 §reg-view, the reg-view macro auto-derives the registered id from the symbol you supply:
This matches Clojure's defn Var-naming idiom: the symbol is the source of truth; the registry id mirrors it. Override the auto-derivation by attaching ^{:rf/id :explicit/id} metadata to the symbol:
(reg-view counter [label] body)
;; ⇒ id is :my.ns/counter
(reg-view ^{:rf/id :widget/counter} counter [label] body)
;; ⇒ id is :widget/counter
The metadata-override syntax is the single supported way to set a non-auto-derived id at the macro surface. Other slot metadata (e.g. :doc) lives on the same metadata map: ^{:doc "..." :rf/id :widget/x}. For computed ids, drop to re-frame.core/reg-view*.
Render-tree shape vs runtime lookup — Vars and ids¶
Render trees use Vars; runtime lookups use ids. reg-view bridges them — auto-defs the symbol AND auto-derives the registry id. The same render/lookup split applies to reg-view*: it registers a fn under an id without a Var def; consumers retrieve it via (rf/view id) and inline it into render trees.
;; reg-view: auto-defs the symbol AND registers under an auto-derived id
(rf/reg-view counter [label] [:button label])
[counter "Hello"] ;; render tree — Var reference
(rf/view :my.ns/counter) ;; runtime lookup — id
;; reg-view*: registers under an id, no Var binding
(rf/reg-view* :feature/widget (fn [args] [:span args]))
[(rf/view :feature/widget) "x"] ;; render tree — splice the looked-up fn
A bare [:keyword args] head in a render tree is an HTML element (Reagent's existing semantics) — the runtime does not intercept the keyword case to dispatch via the views registry. See Spec 004 §Calling a registered view and Cross-Spec-Interactions §21 Family asymmetry.
React keys: stable per-row identity, never positional, when rows can mutate¶
When a re-frame2 view renders a collection of sibling rows whose membership or ordering can mutate at runtime (rows added, removed, reordered, filtered, or replaced), each sibling MUST be keyed on a value that follows row identity, not row position.
This is a re-frame2 discipline because re-frame2 makes the mutable-collection case routine: a workspace lists every variant, a trace ribbon lists every captured trace, a controls repeater lists every entry the user has added. Whichever React substrate is mounted under the adapter port, React's reconciler uses :key to decide which DOM nodes (and which component instances, and which r/with-let brackets, and which use-effect cleanups) survive a re-render. A positional key ((map-indexed (fn [i row] ^{:key i} [row-view row]))) silently tells the reconciler that row i after the mutation IS row i before the mutation — even when the underlying row identity differs. The result is a class of bugs where a deletion leaks the deleted row's component state into the row that took its slot, an insertion mounts at the wrong index, and a with-let init body that should re-fire on identity change is silently retained.
Key naming¶
Namespace the key value with a single-letter source prefix so a 10x / Xray inspector can read what "kind of row" a duplicate-key warning came from at a glance:
| Prefix | Source | Example |
|---|---|---|
v: |
Variant cells (one row per registered variant) | (str "v:" variant-id) |
t: |
Trace rows (one row per captured trace) | (str "t:" trace-id) |
r: |
Mutable repeater rows (rows the user adds / removes / reorders) | (str "r:" row-id) |
<i> |
Bare positional, integer only | ^{:key i} [field-view ...] |
Bare positional ^{:key i} is acceptable only when the row collection has fixed arity — a destructured 2-tuple, a 3-row form layout where the row count is a compile-time constant. The moment a row collection can grow, shrink, or reorder, the prefix-namespaced identity scheme above is required.
r/with-let init keys¶
r/with-let (and any substrate equivalent that brackets first-render initialisation) re-fires its init body when the surrounding component remounts. When :key is the only signal that triggers a remount, the init body re-fires on key change but NOT on input change inside a single mounted instance. View bodies that read external inputs (variant id, run-key tuple, hot-reload tick) MUST include every relevant input in the :key tuple — otherwise the init body captures the first render's value and silently goes stale.
;; WRONG — init captures the first run-key seen; subsequent changes to
;; (run-key) are ignored inside the surviving with-let bracket.
[^{:key (str "v:" variant-id)} [cell variant-id (run-key)]]
;; RIGHT — every input that should re-init the cell rides the key tuple.
[^{:key [(str "v:" variant-id) (run-key)]} [cell variant-id (run-key)]]
Failure mode¶
The class of bug the rule catches: when a list-rendering body's ^{:key ...} is a positional index (^{:key i}) but the underlying identity is something else (a variant-id, a trace-id, a row-id), Reagent / React reuse the wrong DOM nodes on reorder. A row keyed by index 2 becomes "the third row", which means a deletion shifts every downstream row's identity by one and surfaces as silent state bleed across cells, repeaters, and trace-ribbon entries. The cure is mechanical (replace :key i with :key (str "<prefix>:" stable-id)); the diagnostic is hard (the wrong rendering is internally consistent — only an interaction probe reveals the swap).
Namespace size¶
A namespace is sized by cohesion + coupling, not line count. It is okay for a namespace to be large if it is internally cohesive and largely decoupled from other namespaces. File size alone is not a split trigger.
Split when concerns are independent. A namespace is appropriately split when its sub-parts could each live alone behind a small public seam — they share little internal state, they evolve on different cadences, and a reader of one part rarely needs the others on screen. The exemplar is re-frame.routing (rf2-2yabr, PR #2309): a 2184-LoC routing.cljc carrying 11 genuinely independent concerns (pattern compile, route ranking, nav-token allocation, URL parse, scroll restore, …) split cleanly into 13 concern-per-file siblings behind a thin facade. The seams already existed in the code's shape; the split made them visible.
Keep cohesive when the file is one algorithm. A namespace is appropriately kept whole when it implements ONE algorithm whose internal closures-over-state would be torn into fake seams by a split — the "parts" only make sense in each other's presence, and any seam between them is a lie about the code's actual coupling. The exemplar is re-frame.machines.transition (rf2-wep8y): a 1759-LoC transition engine that reads as one machine, kept cohesive because splitting it would invent public-shaped interfaces for what is genuinely one closure over the in-flight transition record.
The test is not "how many lines?" but "would a competent reader, asked to change one part, need to read the others?" If yes — keep it together. If no — split, and let the public seam between siblings stay narrow.
Packaging conventions¶
re-frame2 ships as multiple Maven artefacts. A user picks the artefacts their app needs; bundle isolation is structural, not vigilance-based — the wrong feature or the wrong substrate is absent from the classpath, not eliminated by a hopeful pass of dead-code analysis.
Artefact tiers¶
The CLJS reference's published artefact set partitions across three tiers.
Core — day8/re-frame2. The always-needed surface: registry, drain, fx, dispatch, subscribe, frame-provider, trace.
Per-feature — day8/re-frame2-<feature-id>. Optional capabilities. The feature-id matches the spec topic:
| Artefact | Spec | Feature |
|---|---|---|
re-frame2-machines |
005 | State machines |
re-frame2-flows |
013 | Flows |
re-frame2-routing |
012 | Routing |
re-frame2-http |
014 | Managed HTTP |
re-frame2-ssr |
011 | SSR & hydration |
re-frame2-schemas |
010 | Malli schema layer |
re-frame2-epoch |
Tool-Pair | Tool-Pair epoch surfaces |
Per-adapter — day8/re-frame2-<adapter>. Each adapter implements the Spec 006 substrate contract for one rendering substrate:
| Artefact | Spec | Adapter (substrate it covers) |
|---|---|---|
re-frame2-reagent |
006 | Reagent (browser default) |
re-frame2-uix |
006 | UIx |
re-frame2-helix |
006 | Helix |
In the repository layout the three adapters live under implementation/adapters/<name>/ (one directory per adapter); per-feature artefacts stay flat under implementation/<name>/. The canonical directory name is adapters/, not substrates/ — the directory holds the per-substrate adapter implementations of Spec 006's substrate contract, not the substrates themselves. Maven artefact names are unchanged — the on-disk grouping is a CLJS-reference repo concern; consumers of the published jars see the same coordinates as before.
Independence rule¶
Each per-feature artefact is independent. Core MUST NOT transitively :require any per-feature ns. Cross-references between features (e.g., flows depending on schemas at runtime) go through the late-bind hook registry, not direct requires. The discipline is exactly what makes opt-in work: a consumer who omits re-frame2-schemas does not pay for it, and the features that would benefit from schemas if present detect the absence and degrade silently.
The independence rule applies to the per-adapter tier too: adapters depend on core; core never depends on an adapter. The runtime's substrate-aware seams (e.g. re-frame.ssr/install-render-to-string!) are call-back hooks the adapter ns wires from its own load-time, not requires from core.
Optional-artefact wrapper convention¶
The pattern, named — facade re-export, artefact require. This is re-frame2's optional-capability shape: the public-API surface is re-exported from the always-loaded facade (re-frame.core), but each surface's implementation lives in a separate, optionally-present Maven artefact (day8/re-frame2-<feature>) that the facade reaches only at call time through the late-bind hook table. The facade carries the name and the contract; the artefact carries the code. The single-import ergonomics of a monolith are preserved while the bundle-isolation of a modular split is kept — but the binding is invisible at the call site, so the pattern carries two paired obligations (the self-explaining front-porch and the require-carrying error, both below) that make the invisibility safe.
Each optional-artefact wrapper lives in core under re-frame.core-<feature> (e.g. re-frame.core-routing, re-frame.core-flows). The wrapper publishes the public-API fns that consumers reach via re-frame.core re-exports, but the implementation of each fn lives in a separate Maven artefact (day8/re-frame2-<feature>).
Core MUST NOT statically :require the producing namespace — that would pull the feature's implementation onto every consumer's classpath even when no feature surface is used. Each wrapper fn instead looks the producing fn up through the late-bind hook table at call time, which the producing artefact populates from its own ns-load.
The single-import contract is preserved: users continue to write rf/reg-flow after (:require [re-frame.core :as rf]) — the wrapper ns is reached via re-frame.core's re-export. When the producing artefact is absent the wrapper raises a documented :rf.error/<feature>-artefact-missing ex-info with :where 'rf/<surface>, :recovery :no-recovery, and a :reason string naming the artefact and the ns to require at boot.
The wrappers live in sibling namespaces rather than in core.cljc itself so core.cljc stays free of optional-artefact glue. The file naming uses core_<feature> rather than core/<feature> because CLJS goog.provide for re-frame.core overwrites its parent object.
Obligation 1 — every artefact-missing error carries the require¶
The late-binding is invisible at the call site: a developer who forgets to :require the impl artefact calls a re-exported fn that exists. An opaque "no such hook" failure would leave them stranded. So the pattern is HARD-CONSTRAINED: every artefact-missing error MUST carry the exact copy-pasteable Maven coordinate and the namespace to require at app boot. The CLJS reference centralises this in re-frame.late-bind/require-fn! — the single throw skeleton the re-frame.core-<feature> wrappers route through. Its :reason slot reads "<where> requires <maven> on the classpath; add it to deps and require <require-ns> at app boot.", and its ex-data carries :rf.error/id, :where, :recovery :no-recovery. A port MUST mirror this: an artefact-missing error that does not name its fix is a contract break, not a stylistic difference.
Obligation 2 — the feature-inspection front-porch¶
The inverse query — which optional features are present, and what to add for the absent ones — is exposed as three production-shipping re-frame.core surfaces (per API §Feature inspection):
(rf/features)→ a map of every optional feature keyword to its coordinate data (:maven/:require/:spec) merged with a live:loaded?boolean.(rf/feature-loaded? :epoch)→ boolean presence check.(rf/require-feature! :epoch)→ asserts presence, throwing the exact copy-pasteable coordinate when absent (an early, self-explaining guard).
Static-data hard constraint (the production implication). The feature→coordinate mapping these surfaces read MUST be static data in the always-loaded facade — a plain table of {:feature {:maven … :require … …}} strings. It MUST NOT :require (live-reach) into the optional impl namespaces. A live reach-in would create exactly the hard facade→optionals reference the whole pattern exists to avoid: it would pull every optional artefact (epoch, machines, schemas, flows, routing, http, ssr) onto every production classpath and break §Bundle-isolation conformance. Presence is therefore detected without reaching in — feature-loaded? does a pure keyword lookup in the always-loaded late-bind hooks atom against a representative key the impl publishes at its own ns-load. These three fns ship to production (runtime queries, not instrumentation — NOT elided), and the CLJS reference proves the static-table claim through the bundle-isolation, elision, and perf-bundle gates.
Late-bind hook key grammar¶
Every key published through the re-frame.late-bind hook registry follows a closed grammar so the namespace prefix is predictable and the table stays browsable. The full inventory lives at implementation/core/src/re_frame/late_bind/directory.cljc (the drift-test source of truth per H8 /); the grammar is:
| Prefix shape | Producer | When to use | Example |
|---|---|---|---|
:<feature>/<surface> |
A single per-feature artefact ns | The default case: an optional artefact publishes its public-API impl behind a late-bind seam. <feature> matches the feature-id (the artefact name without the re-frame2- prefix). <surface> names the function — <verb-noun> shape, kebab-case, bang-suffixed only when the producer mutates process-level state per §Naming: when does a surface carry !?. |
:flows/reg-flow, :schemas/validate-app-schema!, :machines/spawn-fx, :ssr/render-to-string, :epoch/settle!, :http/clear-all-in-flight! |
:adapter/<surface> |
Chained across every shipped CLJS adapter | Substrate-routed seams — each adapter ns registers a fn keyed by :adapter/<surface>; the runtime dispatches through current-adapter to pick the right one. The drift directory marks these :chained? true with :producer-ns as a vector of every adapter ns. |
:adapter/current-frame, :adapter/ratom, :adapter/wrap-view |
:rf2/<runtime-stamp> |
Core (or an artefact publishing a globally-visible runtime fact) | Runtime-static, framework-owned stamps that any artefact can read — version numbers, build flags, schema digests, etc. The :rf2/ prefix marks the key as "framework-global, not feature-scoped." Used sparingly — most cross-feature plumbing should pick a feature prefix instead. |
:rf2/runtime-version |
Rules.
- The
<feature>segment names a single artefact (flows,schemas,machines,routing,http,ssr,epoch,reagent,views,subs,router,trace,event-emit,error-emit,privacy). New artefacts pick a single-segment kebab-case feature-id that matches the producing namespace's last segment. - Multi-word
<surface>is kebab-case (reg-flow,clear-all-in-flight!,render-to-string,app-schemas-digest). The:between prefix and surface is the only separator; no dots inside the surface segment. - The drift test enforces directory ↔ producer-ns parity (
implementation/core/test/re_frame/late_bind_drift_test.clj). Aset-fn!call site whose key isn't in the directory — or a directory entry whose key isn't reached by anyset-fn!— fails CI. Add a hook = update both the producer ns AND the directory entry in a single change. - The hook key is stable across the artefact's lifecycle — adapters / consumers reference it by string-spelled keyword, so renaming a hook key is a breaking change of the same magnitude as renaming a public fn.
- Hooks that fan out chronologically (callback-style listeners called in registration order rather than overwriting) are documented with
:chained? truein the directory; this is an additive property of the same naming grammar. - Hot-path consumers MUST use a sticky (per-key cached) resolution form. The late-bind registry exposes two read shapes: a re-dereferencing form (reads the hooks map every call), and a per-key cached form whose cache is invalidated on
set-fn!/chain-fn!. Cascade-frequency call sites — interceptor chain wiring, fx dispatch, epoch capture, the per-event drain — MUST resolve through the cached form. The unmemoised form is reserved for boot-time and rare-path callers where allocator pressure and atom-deref count are not load-bearing. The reason is performance-pattern contract, not implementation discretion: a 100-event cascade reads ~6+ hook keys per event, so the unmemoised form costs hundreds of atom-derefs per cascade and busts the V8 megamorphic IC at the hooks-map deref site. A port that wires hot-path consumers through the unmemoised form ships a measurable cascade-throughput regression versus the CLJS reference. Cache invalidation onset-fn!/chain-fn!keeps hot-reload — re-registration of a hook function — observable on the next call without per-call atom-deref cost.
The grammar holds across every per-feature artefact split landed under the Strategy B rollout; new per-feature splits MUST mint hook keys under their own <feature> segment so the naming stays grep-friendly across the codebase.
Naming convention¶
The artefact-naming convention is re-frame2-<thing>, where <thing> is the feature-id (per Spec topic) or adapter name. The Maven group is day8 for the CLJS reference's published artefacts.
The *-suffix convention for fn-versions of macros (per the Clojure idiom of let/let*, fn/fn*; see §*-suffix naming for fn-versions of macros) is orthogonal to artefact naming: *-suffix is symbol-naming inside an artefact; re-frame2-<thing> is the artefact's coordinate.
Bundle-isolation conformance¶
A production-elision build of an app that consumes day8/re-frame2 plus day8/re-frame2-reagent carries re-frame.adapter.reagent strings AND does NOT carry re-frame.adapter.uix or re-frame.adapter.helix strings, AND does NOT carry the namespaces of any per-feature artefact the app didn't add to its deps.edn. The check is a grep over the advanced-compile output.
Lockstep versioning through 1.0¶
Through the v0.0.1.alpha → 1.0 stretch every artefact ships at the same version sourced from the repo-root VERSION file. The mechanism is structural: every artefact's :clein/build :version declares the relative path "../../VERSION", and every non-core artefact references core via {:local/root "../core"} (rewritten to {:mvn/version <VERSION>} on the throwaway runner checkout at deploy time). The lockstep contract is enforced by .github/scripts/verify-version-lockstep.sh, invoked by both .github/workflows/test.yml (PR-time drift detection) and .github/workflows/release.yml (pre-deploy gate). Independent versioning is revisited post-1.0; until then, adding a literal :mvn/version for a day8/re-frame2-* artefact in a committed deps.edn is a contract break that the verify script flags.
The release pipeline — topological deploy DAG, recovery procedure when a partial deploy fails, pre-flight checklist — is documented in docs/release-process.md.
Cross-references¶
- §Adapter shipping convention below — the per-adapter tier in operational detail.
- README §Project layout — the per-artefact directory structure under
implementation/. - docs/release-process.md — operational doc for cutting a release: topological DAG, recovery procedure, lockstep enforcement.
Adapter shipping convention¶
Adapters ship as separate Maven artefacts alongside the core (per §Packaging conventions §Per-adapter above). The CLJS reference's published artefact set is:
| Artefact | Contents |
|---|---|
day8/re-frame2 |
Core: registry, drain, fx, dispatch, subscribe, frame-provider, trace, the substrate-adapter contract, the headless plain-atom adapter. Seven per-feature surfaces ship as separate artefacts alongside core: day8/re-frame2-schemas, day8/re-frame2-machines, day8/re-frame2-routing, day8/re-frame2-flows, day8/re-frame2-http, day8/re-frame2-ssr, day8/re-frame2-epoch. The per-feature split set is closed. |
day8/re-frame2-reagent |
Reagent adapter (re-frame.adapter.reagent) |
day8/re-frame2-schemas |
Schemas (Spec 010) — re-frame.schemas, the Malli-backed schema-attachment surface (reg-app-schema, app-schema-at, app-schemas, the validation hot-path entry points). |
day8/re-frame2-machines |
State machines (Spec 005) — re-frame.machines, the machine grammar surface (reg-machine, make-machine-handler, machine-transition, the :rf/machine framework sub, the :rf.machine/spawn / :rf.machine/destroy actor-lifecycle fxs, the in-snapshot :rf/spawn-counter allocator (per-machine-id, lives inside each machine's snapshot for pure-functional allocation)). |
day8/re-frame2-routing |
Routing (Spec 012) — re-frame.routing, the route grammar (reg-route, match-url, route-url), the :rf.route/navigate / :rf.route/transitioned / :rf/url-requested / :rf.route/handle-url-change / :rf.route/continue / :rf.route/cancel events, the :rf.nav/push-url / :rf.nav/replace-url / :rf.nav/scroll reserved fxs, and the :rf/route / :rf.route/{id,params,query,transition,error} framework reg-subs. |
day8/re-frame2-flows |
Flows (Spec 013) — re-frame.flows, the flow grammar (reg-flow, clear-flow), the :rf.fx/reg-flow / :rf.fx/clear-flow runtime fxs, the per-frame flow registry, the topological-sort engine, and the run-flows-on-db walker (the flow transform that runs as the outermost :after, right after the handler, rewriting the pending :db effect before the single deferred install). |
day8/re-frame2-http |
Managed HTTP (Spec 014) — re-frame.http-managed, the production-eligible :rf.http/managed and :rf.http/managed-abort fxs, the in-flight request registry, the Fetch / java.net.http.HttpClient transport adapters, the encode / decode pipeline, the retry-with-backoff machinery, and the eight-category :rf.http/* failure taxonomy. The HTTP test surface — the two canned-stub fxs (:rf.http/managed-canned-success and :rf.http/managed-canned-failure) AND the with-managed-request-stubs family of macros / fns — ships in the same Maven artefact under the sibling re-frame.http-test-support namespace (single discoverable home for HTTP test surfaces). Tests opt in by :require-ing re-frame.http-test-support; production code paths must not (per Spec 014 §Test-support require). |
day8/re-frame2-ssr |
SSR & hydration (Spec 011) — re-frame.ssr, the pure hiccup → HTML emitter (render-to-string), the FNV-1a structural render-tree hash (render-tree-hash), the :rf/hydrate event with :replace-app-db semantics, the six :rf.server/* server-only fxs (set-status, set-header, append-header, set-cookie, delete-cookie, redirect), the per-request HTTP response accumulator at [:rf/response], the reg-error-projector registry kind plus the built-in :rf.ssr/default-error-projector, the SSR error-projection trace listener, and the data-rf2-source-coord annotation on registered-view roots. |
day8/re-frame2-epoch |
Epoch / time-travel (Tool-Pair §Time-travel) — re-frame.epoch, the per-frame :rf/epoch-record ring buffer (epoch-history), the (rf/configure! :epoch-history {:depth N}) knob, the register-epoch-listener! / unregister-epoch-listener! listener API, the restore-epoch rewind with its seven documented failure modes (:rf.epoch/restore-unknown-epoch, :rf.epoch/restore-schema-mismatch, :rf.epoch/restore-missing-handler, :rf.epoch/restore-version-mismatch, :rf.epoch/restore-during-drain, :rf.epoch/restore-non-ok-record, plus :rf.error/no-such-handler for the unknown-frame case), the per-cascade trace-capture buffer the router and the trace surface feed via the :epoch/capture-event / :epoch/settle! / :epoch/discard-buffer! late-bind hooks, the :rf.epoch/snapshotted and :rf.epoch/restored trace events, and the :sub-runs / :renders / :effects per-cascade projections. |
day8/re-frame2-uix |
UIx adapter (re-frame.adapter.uix) — the use-subscribe hook, flush-views! test-flush wrapping React's act(), the source-coord wrapping component, and the UIx-side frame-provider consuming the shared React context. Targets UIx 2.x. |
day8/re-frame2-helix |
Helix adapter (re-frame.adapter.helix) — the use-subscribe hook, flush-views! test-flush wrapping React's act(), the source-coord wrapping component, and the Helix-side frame-provider consuming the shared React context. Targets Helix 0.2.x. Helix and UIx share the React + hooks substrate model, so the UIx adapter's design decisions transfer unchanged. |
A consumer picks their substrate by adding the matching adapter alongside the core:
;; deps.edn for a Reagent app
{:deps {day8/re-frame2 {:mvn/version "2.0.0"}
day8/re-frame2-reagent {:mvn/version "2.0.0"}}}
;; deps.edn for a UIx app
{:deps {day8/re-frame2 {:mvn/version "2.0.0"}
day8/re-frame2-uix {:mvn/version "2.0.0"}}}
;; deps.edn for a Helix app
{:deps {day8/re-frame2 {:mvn/version "2.0.0"}
day8/re-frame2-helix {:mvn/version "2.0.0"}}}
;; deps.edn for a Reagent app that uses Spec 010 schemas
{:deps {day8/re-frame2 {:mvn/version "2.0.0"}
day8/re-frame2-reagent {:mvn/version "2.0.0"}
day8/re-frame2-schemas {:mvn/version "2.0.0"}}}
;; deps.edn for a Reagent app that uses Spec 005 state machines
{:deps {day8/re-frame2 {:mvn/version "2.0.0"}
day8/re-frame2-reagent {:mvn/version "2.0.0"}
day8/re-frame2-machines {:mvn/version "2.0.0"}}}
Rationale. Bundle isolation is guaranteed by structure rather than by careful dead-code elimination: a Reagent-only application simply does not have UIx code on the classpath. The Closure Compiler's DCE does not have to be perfect; the wrong substrate is structurally absent. This reinforces the substrate-independence-of-core thesis (Spec 006 §The reactive-substrate adapter contract) at the package layer. The same argument generalises to per-feature artefacts (e.g. day8/re-frame2-schemas, day8/re-frame2-machines, day8/re-frame2-routing, day8/re-frame2-flows, day8/re-frame2-http, day8/re-frame2-ssr, day8/re-frame2-epoch): an app that doesn't register any schemas doesn't carry the re-frame.schemas namespace or its Malli dep on its classpath; an app that doesn't register any machines doesn't carry the re-frame.machines namespace, the machine-transition engine, or the :rf.machine.spawn/spawned / :rf.machine/destroyed trace strings; an app that doesn't register any routes doesn't carry the re-frame.routing namespace, the route-rank / pattern-compile / nav-token machinery, the :rf/route reg-sub family, or any :rf.route/* / :rf.nav/* keyword strings; an app that doesn't register any flows doesn't carry the re-frame.flows namespace, the per-frame flow registry, the topological-sort engine, the dirty-check last-inputs map, or the run-flows-on-db walker (the outermost-:after flow transform); an app that doesn't issue any managed-HTTP requests doesn't carry the re-frame.http-managed namespace, the in-flight request registry, the Fetch / HttpClient transport adapters, the encode / decode pipeline, the retry-with-backoff machinery, or any of the :rf.http/* keyword strings; an app that doesn't render server-side doesn't carry the re-frame.ssr namespace, the pure hiccup → HTML emitter, the FNV-1a render-tree-hash machinery, the per-request [:rf/response] accumulator, the six :rf.server/* server-only fxs, the reg-error-projector registry kind plus its built-in default, or any of the :rf.ssr/* / :rf.server/* keyword strings; an app that doesn't consume the pair-tool / time-travel surface doesn't carry the re-frame.epoch namespace, the per-frame :rf/epoch-record ring buffer, the per-cascade trace-capture path, the :sub-runs / :renders / :effects projection walker, the schema-validate / machine-version / missing-reference predicates, or any of the :rf.epoch/* keyword strings.
Dependency direction. Adapter and feature artefacts depend on core; core never depends on either. The runtime's cross-namespace seams (e.g. re-frame.ssr/install-render-to-string!, re-frame.schemas/validate-app-schema!, re-frame.machines/reg-machine, re-frame.routing/reg-route, re-frame.flows/reg-flow, re-frame.http-test-support/install-managed-request-stubs!, re-frame.epoch/settle! / re-frame.epoch/restore-epoch) are call-back hooks the producing artefact wires from its own load-time, not requires from core. The wiring goes through re-frame.late-bind's hook table — when the producing artefact isn't on the classpath, the consuming code's lookup returns nil and the call no-ops cleanly (or, for active surfaces like rf/reg-machine / rf/reg-route / rf/reg-flow / rf/with-managed-request-stubs / rf/render-to-string / rf/render-tree-hash / rf/reg-error-projector, throws a clear :rf.error/<feature>-artefact-missing). The epoch surface is dev-tier — its public re-exports (rf/epoch-history, rf/restore-epoch, rf/register-epoch-listener!, rf/unregister-epoch-listener!, (rf/configure! :epoch-history ...)) degrade silently to empty-vector / false / no-op when the artefact is absent rather than throwing, since the surface is already gated on interop/debug-enabled? and a release build that omits the artefact must not raise from a leftover dev-time call site.
Views-layer decoupling — partial. The Reagent-coupled views layer (re-frame.views) currently lives in core because the CLJS reference is Reagent-default. The React frame Context has been factored out of re-frame.views into re-frame.adapter.context (CLJS-only file in core) so the UIx adapter consumes the same createContext object — that's the slice the UIx-adapter work needed. The rest of re-frame.views (the reg-view macro, the source-coord injection walk, the per-render-key trace plumbing) stays Reagent-flavoured; UIx users call reg-view* (plain-fn) and the UIx adapter wraps user components for source-coord injection at the substrate boundary. Full views-layer decoupling — moving every Reagent symbol out of core — remains an optional future step.
Conformance check (bundle isolation). A production-elision build of an app that consumes day8/re-frame2-reagent carries re-frame.adapter.reagent strings AND does NOT carry re-frame.adapter.uix or re-frame.adapter.helix strings (and, symmetrically, a UIx app's bundle contains re-frame.adapter.uix and is clean of re-frame.adapter.reagent). The CI's bundle-grep step (scripts/check-bundle-isolation invoked by examples/scripts) builds both the Reagent counter and the UIx counter under :advanced and asserts each pair of substrate-specific symbols is absent from the wrong bundle. The same applies to feature artefacts: a counter-style app that registers no schemas builds an :advanced bundle clean of re-frame.schemas symbols and Malli code; an app that registers no machines builds an :advanced bundle clean of re-frame.machines symbols, reg-machine / machine-handler / machine-transition strings, and the :rf.machine.spawn/spawned / :rf.machine/destroyed trace strings; an app that registers no flows builds an :advanced bundle clean of re-frame.flows symbols, the topological-sort engine, and the dirty-check last-inputs machinery; an app that issues no managed-HTTP requests builds an :advanced bundle clean of re-frame.http-managed symbols, the in-flight registry, the Fetch transport adapter, and every :rf.http/* keyword string; an app that doesn't add the epoch artefact builds an :advanced bundle clean of re-frame.epoch symbols, the per-frame :rf/epoch-record ring buffer, the per-cascade trace-capture path, the :sub-runs / :renders / :effects projection walker, and every :rf.epoch/* trace string. The check is a grep over the advanced-compile output.
Adapter adapter Var convention. Each adapter namespace exports an adapter Var holding the spec map; consumers require the namespace and pass the Var to (rf/init! adapter-map). There is no default-adapter registry and no ns-load side-effect. The Reagent adapter exports re-frame.adapter.reagent/adapter, the UIx adapter re-frame.adapter.uix/adapter, the Helix adapter re-frame.adapter.helix/adapter; SSR exports re-frame.ssr/adapter (the JVM-side substrate); plain-atom exports re-frame.substrate.plain-atom/adapter. Future adapters follow the same convention: a def adapter at the bottom of the adapter namespace, value being the nine-fn spec map. See Spec 006 §Adapter selection at boot for the boot-time wiring and the rationale.
Adapter test matrix policy¶
Reagent is the canonical adapter: the full re-frame2 test suite (every clojure -M:test run, every node-test build, every :browser-test run, every examples run, every conformance fixture) executes against the Reagent adapter. The UIx and Helix adapters are smoke-tested via a representative subset — counter + login per Decision 7 of each adapter's locked-decision set (realworld is skipped for both UIx and Helix; deferred until a substrate user wants it). Full per-adapter-matrix conformance — every test, every fixture, every example, against every shipped adapter — remains a per-adapter responsibility, not a re-frame2-core responsibility. The policy is a deliberate concentration of the test budget on the substrate the spec was authored against; the substrate contract (Spec 006 §The reactive-substrate adapter contract) is what the smoke pair confirms each non-canonical adapter has implemented correctly.
Per-port conventions¶
Conventions that exist only because of a host-language constraint live here. Each entry names the constraint, the port(s) it applies to, and the convention the spec adopts in response.
CLJS — goog.provide collision: dash-form sub-namespaces of facade namespaces¶
ClojureScript compiles each namespace to a goog.provide call, which unconditionally overwrites the parent object on the host. The consequence: a host cannot carry both re-frame.core AND re-frame.core.flows as namespaces — loading re-frame.core wipes the re_frame.core object and with it every re_frame.core.<sub> previously defined under it. The canonical write-up is clojurescript.org/about/differences (search "goog.provide"). This is structural to the CLJS compilation model, not a bug; the JVM does not share the constraint.
For sub-namespaces of a facade namespace (the canonical case is re-frame.core — the user-facing API surface — but the same applies to any other facade a port chooses to ship), the CLJS reference adopts a dash-form naming convention: substitute a hyphen for the dot between the facade name and the sub-name.
| Wrong (collides) | Right (dash form) |
|---|---|
re-frame.core.flows |
re-frame.core-flows |
re-frame.core.machines |
re-frame.core-machines |
re-frame.core.routing |
re-frame.core-routing |
re-frame.core.schemas |
re-frame.core-schemas |
re-frame.core.ssr |
re-frame.core-ssr |
re-frame.core.epoch |
re-frame.core-epoch |
re-frame.core.http |
re-frame.core-http |
The user-facing alias (:require [re-frame.core :as rf]) still resolves the documented symbols — rf/reg-flow, rf/reg-machine, etc. — via re-exports inside re-frame.core itself. The sub-namespace's existence is an implementation detail of the per-feature artefact (per §Packaging conventions); the dash-form name is what makes the artefact loadable alongside core on the CLJS host.
The convention applies wherever a port targets a host with the goog.provide-style "parent object" model. Ports whose host language does not share the constraint (the JVM port, ports using flat module systems) MAY use dot-form sub-namespaces — but the dash-form is portable and the spec recommends it uniformly for symmetry across ports.
Cross-references¶
- 000-Vision.md — goals, constraints, the pattern's minimal core.
- Principles.md — the discipline principles that motivate these conventions.
- 001-Registration.md — registration metadata-map shape; what each
reg-*accepts. - MIGRATION.md — framework-keyword consolidation under
:rf/*(§M-20) and the Type-A vs Type-B migration classification.