Spec 010 — Schemas¶
Schemas are how dynamically typed hosts describe shape. CLJS is dynamically typed, so the CLJS reference ships a runtime schema layer (Malli, by default). Statically typed hosts (TypeScript, Kotlin, Rust, F#) describe shape via the type system instead and may omit a runtime schema library entirely. The pattern requires shape description; the mechanism is host-specific.
Portable contract (every port):
:schemametadata on everyreg-*; path-basedapp-dbschemas viareg-app-schema; pluggable validator viaset-schema-fns!; implementation-defined default validator. Schemas are open by default — consumers tolerate unknown keys; producers add new keys additively;:closedis opt-in only at system boundaries. Statically typed hosts express the same open-with-known-keys idiom via index signatures + known fields (type T = { knownField: string; [k: string]: unknown }).CLJS reference's default validator: Malli (
malli.core/validate+malli.core/explain). On the CLJS reference, schema implies validation — requiring there-frame.schemasartefact wires Malli automatically (the facade:requires there-frame.schemas.malliadapter), soreg-app-schemaalways validates. The recommended soft-pass (claim 4) is the cross-port default-absent posture; the CLJS reference has no default-absent state, because Malli is a dependency of the schemas artefact. Other ports document their own defaults (see §Default validator and the validator-fn extension point).
Abstract¶
A schema describes the shape of data flowing through a re-frame app:
- The dispatched event a handler expects.
- The value a subscription returns.
- The arguments an effect handler receives.
- The data a coeffect injector produces.
- The structure of
app-dbat any path.
re-frame2 lets users attach a schema to any of these via the :schema metadata key on the relevant reg-* registration, plus a dedicated reg-app-schema API for app-db. In dev builds the framework validates against schemas at well-defined points; in production validation elides (or is restricted to system boundaries) to keep the hot path cheap.
One vocabulary. The framework speaks one term — schema — across every surface: the per-
reg-*metadata key is:schema, the reserved namespace is:rf.schema/*, the production-boundary opt-in is:boundary? true, and the trace tag is:schema-id. There is no:specmetadata key,:rf.spec/*namespace,:spec/at-boundaryid, or:spec-idtag. Migration: MIGRATION §M-54.
The :schema value is opaque to re-frame's validation. No validation site interprets a schema itself; every one routes through the registered validator fn (the dev-only :sensitive?/:large? prop walkers MAY read vector-form Malli EDN as plain data — a compiled schema value is an opaque leaf to them, per §:sensitive?) (set-schema-fns!, see §Default validator and the validator-fn extension point). The validator chooses the schema language: Malli on the CLJS reference, Zod or similar on a TypeScript port, Pydantic on Python, dry-rb on Ruby, the host's structural-typecheck wrapper on a statically typed port. Substituting a different validator is a single registration call; the rest of this Spec (when validation runs, what happens on failure, how digests are computed) applies whichever validator is registered.
Where schemas attach¶
On every reg-*¶
Every registration accepts an optional :schema in its metadata map:
(rf/reg-event :auth/login
{:doc "Submit credentials for verification."
:schema [:cat [:= :auth/login]
[:map [:email :string] [:password :string]]]}
(fn auth-login-handler [m] ...))
(rf/reg-sub :pending-todos
{:doc "Filter todos to those still pending."
:schema [:vector TodoSchema]} ;; sub return value
(fn [db _] (filter pending? (:items db))))
(rf/reg-fx :http-xhrio
{:schema [:map [:method :keyword] [:url :string]]}
http-xhrio-handler)
(rf/reg-cofx :now-wall ;; DIAGNOSTIC only — not a durable source
{:recordable? false :schema inst?}
(fn [] (js/Date.))) ;; value-returning ambient supplier (EP-0017)
A raw host-clock cofx like
:now-wallis for diagnostic / host-transient reads — values that never fold into a durable app-db write. A durable timestamp declares the framework's recordable coeffect instead —:rf.cofx/requires [:rf/time-ms], read flat asrf/time-ms, stamped once at the dispatch boundary so replay / restore / SSR stay deterministic (EP-0010 §The World-Input Rule, 002 §Recordable coeffects).
Machines (per 005 §Schema validation) declare their data-context schema at [:schemas :data] — the value validates the machine's :data slot at every macrostep boundary and at bootstrap. The schema is nested under the root-only :schemas block rather than written as a bare top-level :schema because the machine spec is the only registration surface where the validated value (:data) has its own visible sibling key; the nesting is what says which fact the schema bounds, so no qualified key name is needed (there is no root :data-schema shorthand, per EP-0029 A3):
(rf/reg-machine :drawer/editor
{:initial :idle
:data {:circles []}
:schemas {:data DrawerData} ;; validates :data
:states {...}})
A failure emits :rf.error/schema-validation-failure :where :machine-data and rolls back the cascade (per §Per-step recovery row 7).
App schemas validate the app-db partition only¶
The public API name is
reg-app-schema, and the term is "app-db schema". The precision to hold onto: an app-db schema validates only the app-db partition of a frame — the user-owned application data. It does not describe, validate, or authorise the framework-owned runtime-db partition (machine snapshots, route slice, resource state, SSR metadata — per 002 §The two-partition frame contract).
Concretely:
- App-db schema paths are
get-in/assoc-inpaths into app-db ([:user],[:cart :items],[]for the whole app-db partition). They are never:rf.runtime/*paths. - A user-registered app schema whose path's first segment reaches into runtime-db (a
:rf.runtime/*keyword or the:rf.db/runtimecontainer root) is a category error, not a warnable misuse, and is hard-rejected at registration with:rf.error/app-schema-runtime-path(per Conventions §Reserved runtime-db keys and 009 §Error event catalogue).reg-app-schemavalidates only app-db ((get-in app-db path)) and skips a path app-db lacks, so a runtime path silently installs a contract over a value its validator never reads, one the author falsely believes guards runtime-db — there is no behaviour to soft-land, and no legitimate caller.reg-app-schemasrejects the whole batch atomically before any entry lands; the migration agent flags it. The runtime-db partition is framework-owned, so there is no public schema surface to redirect the user to: the honest remedy is to drop the runtime path (the:reasonsays exactly that, and does NOT name a non-public, framework-owned API). - The framework registers a separate runtime-db validator (
:rf/runtime-db, per Spec-Schemas §:rf/runtime-db) over the runtime-db partition at boot. That is a framework-owned, internal validator, NOT an application-owned app schema and NOT a publicrf/*registration surface — user code never registers it (there is norf/reg-runtime-schemaexport; the name appears only as internal boot vocabulary). Runtime-db is validated as a partition by the framework-owned internal validator, never viareg-app-schema— there is no public app-schema surface over runtime-db. - Because app-db holds nothing but app data,
(re-frame.schemas/app-schemas {:frame f})describes a pure application contract an AI agent can read without framework noise — the AI-legibility payoff of the partition (per Principles.md). - The whole-
app-dbroot schema(rf/reg-app-schema [] …)validates the whole app-db partition — never frame-state, never runtime-db.
The validation timing, candidate-rejection, and per-step recovery rules below apply unchanged; they operate over the app-db partition.
app-db schemas — path-based¶
Rather than one giant schema for the whole app-db, schemas are registered at paths. The path is the registration id (the one principled asymmetry); the schema is the positional value slot, uniform with every other reg-* surface:
(rf/reg-app-schema [:user] UserSchema)
(rf/reg-app-schema [:todos] TodosSchema)
(rf/reg-app-schema [:auth] AuthSchema)
An optional middle metadata map carries :doc and open :my/* keys for the 3-slot form: (rf/reg-app-schema [:user] {:doc "The signed-in user."} UserSchema). It never carries a frame. A registration is a top-level program member, like reg-event, and every frame that resolves it validates against it (per §Schemas resolve through the frame's images), so a :frame key — or a bare frame value (rf/make-frame's return token) in that slot — is a loud :rf.error/app-schema-frame-target naming the image route. Any other non-map middle argument is a loud :rf.error/app-schema-bad-metadata (the common slip is passing the schema where the metadata map goes).
App-db schemas are checked in development builds only.
reg-app-schemaarms a development-time assertion, not a production integrity guarantee. A production build still performs the registration —app-schemasandapp-schema-metakeep answering, so tools and agents introspect exactly the same shapes — but the candidate validator is compile-time eliminated along with the rest of the dev instrumentation, so nothing consults them. A candidate that violates a registered app-db schema therefore installs in production, silently: no rejection, no rollback, no trace. In the words an application author needs: your app-db schemas do not run in production builds. The candidate-rejection and per-step-recovery rules stated below are normative for dev builds and describe nothing that happens in a release build. An invariant that must hold in production belongs in the handler itself; untrusted input crossing a system boundary belongs behind the:boundary? trueflag, whose check survives the elision. See §Production builds.
This fits re-frame's grain — code already accesses app-db via paths; schemas are similarly path-anchored. Composable. Hot-reload-friendly per slice. Tooling and agents can ask "what's the schema at path P?" and get a precise local answer.
reg-app-schema returns its path argument as a canonical vector — the id the schema registers under, per the family-wide reg-* return-value convention. App-db schemas are the registrar kind :app-schema, keyed by that path, so a schema registration is an ordinary program registration: it records its source coordinates and provenance namespace, an image selects it by namespace, (rf/clear :app-schema path) removes it, and the hot-reload sweep drops one a reloaded namespace no longer registers.
The path must be a get-in/assoc-in-shaped path — a sequential collection of keys (a vector is canonical; any sequential seq is accepted), or the empty vector [] for the whole-app-db root (per §A schema for the whole app-db). A non-sequential scalar — a bare keyword, string, number, nil, map, or set — is rejected at registration time with :rf.error/app-schema-bad-path, thrown before anything registers so nothing lands. This shape check is mandatory and always-on (not dev-only): the candidate validator walks every registered path with (get-in db path), and a non-sequential path would make that call throw at validate-time — a registration bug the authoring boundary catches up front rather than surfacing as a per-dispatch validator throw. reg-app-schemas validates every key up front and rejects the whole batch atomically if any is malformed, so a single bad key cannot half-register the batch.
Malformed schema VALUE — the symmetric fail-closed. The same lazy-throw hazard is reachable through the schema argument, not just the path. Malli validates schema forms lazily — at validate-time, not registration-time — so a structurally malformed schema (a childless [:vector], an unknown op) that reaches registration unchecked registers cleanly and then makes the registered validator THROW on the first candidate validation. The candidate validator therefore isolates that throw per-entry: it surfaces a distinct :rf.error/malformed-schema trace (so the throw can never masquerade as a clean validate), fails CLOSED (REJECTS the candidate transition — the unvalidated state is never installed, :rollback? true = transaction rejected), and continues validating the frame's sibling schemas (so one bad schema cannot disable candidate validation — including the privacy-bearing redaction traces — frame-wide). In development builds reg-app-schema and reg-app-schemas check the form first, through the port's optional :compile (per §How the surface works), so a malformed app-db schema throws at the call that wrote it and registers nothing; the per-entry catch is the backstop for a port with no :compile. The router's defensive guard additionally emits the same :rf.error/malformed-schema category — also fail-closed (:rollback? true; the candidate is REJECTED — there is no treat-as-pass arm) — if a wholesale validator-machinery throw still reaches its catch, so a throwing validator can neither pass unvalidated state nor go invisible. Both of those sites also fan a structural-only record onto the always-on :errors stream in the build where they fire, exactly as the value-failure rejection in row 4 does (per 009 §The promotion criterion) — the same consequence, reached through the schema FORM or the validator rather than the value, so reported the same way. The record's :reason is a constant framework sentence and never the throwing validator's message, which is unbounded and may embed the value it choked on; that message stays on the dev trace.
reg-app-schemas — bulk plural form¶
The plural reg-app-schemas takes a {path -> schema} map and registers every entry in one call. The shape suits feature-modular apps (per Conventions §Feature-modularity prefix convention) where a feature module declares 5–20 schemas under a shared path prefix:
(rf/reg-app-schemas {[:auth] AuthSlice
[:auth :login-form] FormSlice
[:auth :register-form] FormSlice
[:cart] CartSlice
[:cart :items] [:vector CartItem]
[:cart :coupon] [:maybe CouponSchema]})
The first argument MUST be a {path -> schema} map — including the empty map {}, which is the documented no-op returning []. A non-map first argument — nil, a vector, a string, a seq of pairs, a set — is rejected at registration time with :rf.error/app-schemas-bad-batch, thrown before any entry registers so the whole batch is rejected atomically. This check is mandatory and always-on: without it (reg-app-schemas nil) (and any non-map) would iterate zero entries and return [] — indistinguishable from the {} no-op — so a boot/config/schema-loader bug passing nil would get a false green with schema enforcement silently disabled for the batch.
The plural form takes no frame either: a frame-id or frame value as its second argument, or an opts map carrying :frame there, is :rf.error/app-schema-frame-target, and any other non-map second argument is :rf.error/app-schemas-bad-arg. Each entry routes through the singular reg-app-schema, so source-coords captured at the call site stamp every registration. Returns the vector of paths registered, in iteration order; last-write-wins on duplicate paths.
Use the singular reg-app-schema when a feature spans only one or two paths, when a path needs its own :doc, or when deterministic ordering matters (the plural form relies on map iteration order, which for hash maps is undefined; small map literals preserve source order).
A schema for the whole app-db¶
The empty path [] means "the whole app-db" — same convention as get-in/assoc-in. Use it to register a root schema:
The root schema validates against the entire app-db partition after every handler. It composes with sub-path schemas: both validate; either failing reports a violation.
The root, or any parent, schema is also where a key's required presence is declared. A path schema constrains its value only where its path exists (§When schemas are checked), so (rf/reg-app-schema [:cart] CartSchema) alone admits an app-db with no :cart. A required entry in an enclosing map schema restores presence for exactly the keys it names:
Because [] is checked whenever the app-db changes, the keys a root schema requires are seeded in one :db write.
Use cases:
- The team wants strict closed-map semantics on top-level keys (
[:map {:closed true} ...]) to catch typos. - A simple flat
app-dbshape doesn't warrant per-slice schemas. - An umbrella schema documents the overall shape while sub-path schemas detail individual slices.
Open vs closed map semantics is the team's choice; every schema language re-frame2 integrates with (Malli on the CLJS reference, Zod / Pydantic / dry-rb / native types on other hosts) supports both.
Multiple schemas at the same path¶
Re-registering a schema at a path replaces the previous one (last-write-wins, same as handler re-registration). A same-form re-register (hot reload) is benign; a different-source re-register at the same path is probably a bug, but nothing detects it — the schema-registration path emits no replacement trace and does not compare the new registration's source coords against the previous one.
Validation timing¶
When schemas are checked¶
| Schema attached to | Validates | Failure recovery (canonical, see §Per-step recovery for full detail) |
|---|---|---|
reg-event-* :schema |
The dispatched event vector, before the handler runs. | Skip handler; emit :rf.error/schema-validation-failure :where :event; downstream queue continues. |
reg-sub :schema |
The sub's return value, after compute. | Report and pass through — :recovery :no-recovery; the consumer still receives the computed value. Dev-only. |
reg-fx :schema |
The effect's argument data, before the fx handler runs. | Skip the offending fx only; sibling fx in the same :fx vector continue; downstream queue continues. Dev-only — a production build elides the check, so nothing is skipped and the malformed args reach the handler (per §Production builds). The reserved :rf.server/* response effects guard their own arguments separately, in every build; see §Per-step recovery row 5. |
reg-cofx :schema |
A recordable coeffect (:rf.cofx/requires), before it folds into the handler context — supplied on the dispatch token, replayed from a record, or freshly generated. |
Production hard error — emit :rf.error/cofx-value-invalid and throw (:recovery :no-recovery); the run halts. NOT the dev-only schema-validation trace. See 001 §reg-cofx and 009 §:rf.error/cofx-value-invalid. |
reg-app-schema (path-based) |
The slice at the registered path in the candidate frame transition, after every handler completes and before the transition installs — when the candidate has that path and writes it (see below). | Reject the candidate transition (nothing installs); treat dispatch as failed. Dev-only — a production build elides the check, so the violating candidate installs silently (per §Production builds). |
Not every schema failure aborts dispatch. The recovery depends on where the failure occurs: an event-vector failure skips the handler; a recordable-coeffect failure throws :rf.error/cofx-value-invalid and halts the run (a production hard error, not a recoverable trace); in-flight fx failures skip just the offending fx; app-db candidate failures reject the dispatch's whole frame transition before it installs. Downstream queued events continue draining for the recoverable cases (per the run-to-completion drain — a single failed event does not poison the queue). The detailed per-step table below is normative; this summary table is its index.
An app-db commit checks the registered slices it writes. A path schema constrains the value at its path only when that path exists in the candidate: a path the candidate lacks (a missing key anywhere along it) is not checked, and a present value, nil included, is. Whether a key must exist is said by an enclosing schema, usually the root [], with required map entries (§A schema for the whole app-db), so a slice that boots empty needs no [:maybe …], and a dissoc of a registered slice installs unless a parent or the root requires the key. A registered path whose slice the candidate leaves identical? to the installed app-db is not checked again, because the commit does not write it. A parent path is checked when a write lands anywhere beneath it, and [] whenever the app-db changed. The guarantee that follows: every value a commit writes at a registered path conforms, and a non-conforming value already present when its schema is registered, first or again with a changed schema, is named by :rf.schema/violation (§Schema migration on hot-reload) rather than blocking the events that do not write it.
This is the portable contract every port implements: an absent path is not checked, and a present null is. It maps onto optional properties in Zod and TypeScript, where a missing key passes and a null is checked against the declared type.
All validation points emit machine-readable errors per Goal 10 (Strong introspection surface) and the structured error contract in 009 §Error contract — :rf.error/schema-validation-failure events carry {:where :event/:sub-return/:app-db/...; :path [...]; :value <bad>; :explain <validator-supplied explanation>}. The explanation's inner shape is whatever the registered explainer fn returns (a Malli explanation map on the CLJS reference; a Zod issue list on a TS port; etc.); consumers that need to branch on the inner shape inspect the port they're talking to.
For :where :app-db failures, the trace's :path is the failing leaf path — the registered root concat'd with the explainer's value-navigation suffix (Malli's :in, not its schema-walk :path). On a sensitive failure the suffix keeps only its leading declared :map keys and every later segment is :rf/redacted (per §:sensitive?). The trace also carries :registered-path — the registration root — so tooling that needs the registration anchor reaches (:registered-path tags) while consumers reading the failure locator reach (:path tags). When the registered explainer is absent or returns no extractable suffix (non-Malli validator, structurally-different explanation), :path falls back to the registered root and :registered-path mirrors it. Other surfaces (:where :event / :fx-args / :sub-return) emit :path per their existing contract; no :registered-path tag is stamped on those surfaces because the registration is named by :failing-id / :schema-id directly. (Recordable-coeffect validation does not emit :rf.error/schema-validation-failure at all — it is the :rf.error/cofx-value-invalid hard error, per step 2 above.)
Validation order on event processing¶
For a single dispatched event, schema checks fire in this order:
- Event-vector schema (from
reg-event-*:schema) — before any handler runs. - Recordable-coeffect schemas (from
reg-cofx:schema) — each recordable coeffect a handler:rf.cofx/requiresis validated against its registration's:schemaas it is satisfied (supplied on the token, replayed, or generated), before it folds into the handler context. This is a production hard error, not the dev-only schema-validation trace — a mismatch emits:rf.error/cofx-value-invalidand throws (per 002 §Satisfaction and 001 §reg-cofx). There is no injection-time cofx step: there is noinject-cofx(per 001 §inject-cofxis removed) and no ctx-mutating injection point. - Handler runs.
app-dbpath schemas — at the single deferred commit boundary, validating the flow-augmented CANDIDATE:dbvalue before it installs (the candidate transition is built without touching the container; validation runs over it; only a full pass performs the one atomic install — the validate-before-install contract). By this point the flow transform has already rewritten the pending:dbeffect as the outermost:after(per 013 §Drain integration), so the value validated (and, on a pass, installed) is the flow-augmented db, not the handler's raw:db. 4a. Machine-:dataschemas (fromreg-machine[:schemas :data]) — alongside step 4 the runtime walks the candidate runtime-db's[:rf.runtime/machines :snapshots]and validates each snapshot's:dataagainst its registered machine's[:schemas :data]schema. Both validators AND-conjoin over the complete candidate, with no first-failure short-circuit (every violation surfaces its own trace): afalsefrom either rejects the whole candidate frame transition. Per 005 §Schema validation.- Effect schemas (from
reg-fx:schema) — before each fx handler runs. Dev-only, like step 4. The reserved:rf.server/*response effects carry no:schema; an always-on guard of their own checks their args instead, in every build (per §Per-step recovery row 5). - Sub return-value schemas — after each materialisation/recompute that involves a schema'd sub.
A failure at any step emits a structured error, and what it stops depends on the step (§Per-step recovery): steps 1 to 4a stop that event, while an fx-args failure skips only the offending effect and a sub return-value failure is reported while the value still reaches its consumer.
Per-step recovery¶
| Step | Failure mode | Recovery |
|---|---|---|
| 1. Event-vector | The dispatched event vector doesn't conform to the handler's :schema. |
Handler is not invoked; emit :rf.error/schema-validation-failure with :where :event. The run stops at this event; downstream events in the queue continue. |
| 2. Recordable cofx | A recordable coeffect's value (supplied / replayed / generated) doesn't conform to its reg-cofx :schema. |
Production hard error — emit :rf.error/cofx-value-invalid (:recovery :no-recovery) and throw, halting the run before the handler runs. This is the recordable-value contract, validated in re-frame.cofx as the coeffect is satisfied — NOT a dev-only :rf.error/schema-validation-failure :where :cofx trace. Folding an out-of-contract value into the durable causal ledger is corrupt state, so the check fires in production too. See 001 §reg-cofx, 002 §Satisfaction, and 009 §:rf.error/cofx-value-invalid. |
| 3. Handler exception | A registered handler throws. | :rf.error/handler-exception (per 009); the failing handler's run halts — its :db, flows, and :fx are suppressed (the interceptor chain captured the exception before :effects were populated). Downstream events already queued continue to drain — handler-exception does not abort the drain. (See Spec-Schemas §:rf/epoch-record §Outcomes — no :halted-handler-exception record is committed under the current runtime; the per-event error surfaces in the drain's :ok epoch record as a trace under :trace-events.) |
4. app-db path |
The flow-augmented CANDIDATE :db value at a registered schema-bound path doesn't conform. The complete candidate frame transition (the nil-coerced flow-augmented :db, plus any :rf.db/runtime value) is built without touching the container and validated before install; all touched partitions validate conjoined with no first-failure short-circuit. |
Emit with :where :app-db; the trace's tags carry :rollback? true (the stable public vocabulary for transaction REJECTED — it does not imply a physical write-pair), and the trace carries :recovery :no-recovery at its top level, beside :operation and :tags. The candidate is never installed (no commit — app-db keeps its pre-event value and no observer can see the candidate: no trace listener, container watch, or substrate subscriber ever observes the invalid value, and the frame's subs are not notified). The dispatch is treated as failed — NO :rf.event/db-changed, NO :rf.event/db-noop, and NO :rf.event/frame-state-changed fire, and :fx does not walk for this dispatch. The frame's flow bookkeeping does not advance either: its evaluation memo rows publish only with an install (per 013 §Failure semantics rule 2), and the pass's output-path vacations were applied to the candidate and leave with it. The flow writes that were folded into the pending :db are discarded with it (they were never committed). Downstream queued events still drain (per run-to-completion). In the build where it fires, the rejection ALSO fans one structural-only record per failing registration onto the always-on :errors stream, so it reaches error listeners, the frame's :observability :errors sink and, when nothing owns that stream, the dev console fallback (per 009 §The promotion criterion) — without which a rejected transaction would report nowhere an application can hear. A success is exactly ONE commit with at most one forward db-changed; there is no post-commit validation pass. Dev-only. This whole row describes development builds. Production elides the candidate validator, so the failure mode it recovers from is never detected: the violating candidate installs, the handler's own :db installs with it, and no trace is emitted (per §Production builds). |
| 5. Fx-args | A registered fx's args map doesn't conform to its :schema. |
The offending fx is skipped; emit with :where :fx-args, :fx-id, :fx-args. Other fx in the same :fx vector continue to run (per the run-to-completion drain — fx are independent). The run does not halt; downstream events in the queue still drain. The skipped-fx outcome is :recovery :skipped, mirroring :rf.fx/skipped-on-platform. Dev-only. Like row 4, this row describes development builds. validate-fx! sits behind re-frame.interop/debug-enabled?, so a production build runs no fx-args check at all: nothing is skipped, the malformed args reach the handler, and there is no trace to say so (per §Production builds). The exception is the framework's own — the reserved :rf.server/* response effects guard their own arguments in every build, by a mechanism that is not this step (per §Production builds and 011 §The reserved fx guard their own args). |
| 6. Sub return-value | A schema'd sub's computed value doesn't conform. | Emit with :where :sub-return. Recovery: :no-recovery — the sub returns its computed value to its consumer, so the view renders what the sub produced rather than a nil that would fail somewhere far from the report. Dev-only, like rows 4 and 5. |
7. Machine :data |
A machine snapshot's :data slot — in the candidate runtime-db after a macrostep, at bootstrap, or at spawn-install time — doesn't conform to its registered [:schemas :data] schema (declared on the reg-machine spec per 005 §Schema validation). |
Same as row 4 — full-cascade candidate rejection at the macrostep/bootstrap boundary (the candidate runtime-db is validated BEFORE install; nothing commits). Emit with :where :machine-data, :failing-id = :machine-id = the failing machine, :frame = the frame whose transition carried the snapshot, :phase :macrostep (post-transition) or :phase :bootstrap (initial install) or :phase :spawn (pre-install rejection), :value = the offending :data, :explain = validator's explanation, :rollback? true (macrostep/bootstrap — the whole event transaction is rejected) or false (spawn — a local skipped install, not an event-transaction rejection), :recovery :no-recovery. The :phase :spawn failure short-circuits the spawn-install: the actor never enters the runtime, no sibling [:rf/runtime :spawned] bookkeeping is recorded. In the build where it fires, a :rollback? true rejection (:phase :macrostep / :bootstrap) ALSO fans one structural-only record per failing machine onto the always-on :errors stream, so it reaches error listeners, the frame's :observability :errors sink and, when nothing owns that stream, the dev console fallback (per 009 §The promotion criterion) — the same route row 4 takes, and for the same reason. The :rollback? false :phase :spawn does NOT: a skipped install is not a discarded transaction, and the promotion is scoped to the consequence rather than to the boundary. |
The fx-args recovery is "skip the offending fx, continue the rest" rather than "halt the dispatch" because a single broken fx (a typo in a :url, a missing required key) should not take down the rest of an event's effects. The trace event names the failing fx; the rest of the page continues to render.
:frame on every per-step trace. Every :rf.error/schema-validation-failure trace carries a :frame tag naming the frame the failure occurred in — the in-flight run's frame for the event / cofx / fx / app-db / machine surfaces and a navigation's route params, the reaction's frame for the sub-return surface (the example below shows :frame :rf/default on the :where :app-db trace). This is load-bearing for per-frame epoch capture: the epoch recorder buffers a validation trace into the in-flight run only when the trace's :frame matches the run's, so an untagged violation reaches the global trace stream but is silently dropped from the per-frame epoch's :trace-events — leaving the per-frame timeline tools (e.g. Xray's Schema-timeline lens) blind to it. A sub that recomputes after its event settled — the usual browser case, since subscriptions deref at render time — has no in-flight run to join, so its sub-return failure is back-filled with its sibling :rf.sub/run into the frame's most recently settled epoch, the cascade that caused the recompute; a :where :sub-override failure is treated the same way. The only per-step traces that omit :frame are direct (non-runtime) validation calls — the elision probe and unit tests — where there is no in-flight run or reaction to attribute to.
A malformed schema. A schema form the validator cannot evaluate (a childless [:vector], an unknown operator, or a custom validator that throws) is not a pass and not an ordinary failure. Every surface reports it as :rf.error/malformed-schema, carrying the surface's :where, :failing-id and :frame and the :schema to fix but no value, because the validator never decided the value; then the surface applies its row's recovery, so a boundary or recordable-coeffect check still fails closed. All of this is §The validation seam's single contract.
Flow output. A flow's output shape is declared with reg-app-schema at the flow's :output-path; reg-flow refuses a :schema key of its own. The flow walk runs as the outermost :after, transforming the pending :db effect before the single deferred install (013 §Drain integration), so step 4 validates the flow-augmented candidate and an output that violates its schema rejects the whole event with :where :app-db, like any other write. See 013 §Flow output validation.
Sub override (:where :sub-override). Out of band with the event-processing order, a development tool MAY pin a subscription's value at the view's deref point via the debug-gated :subs/resolve-sub-override seam in subscribe (per 006 §The sub-override subscribe seam — the render-phase half of Story's :sub-overrides fidelity rung). When the overridden sub declares an output :schema, the pinned value is validated against it the SAME way step 6's :where :sub-return validates a real recompute — through §The validation seam, dev-only, elided in production. A mismatch emits :rf.error/schema-validation-failure :where :sub-override carrying :rf.sub/id, :failing-id, :schema-id, :rf.sub/query-v, :frame, :value, :received, :explain, :explain-humanized and :reason; recovery mirrors :sub-return — :no-recovery: the failure is reported and the pinned value renders as pinned. An override that violates the sub's own output contract is the "pin a state the real derivation could never produce" anti-pattern; schema-validating it closes the honesty gap. The override itself never touches app-db or compute-sub, so it can never satisfy :rf.assert/sub-equals regardless of validation outcome (the honesty boundary is structural, not validation-dependent).
Dev vs production¶
Dev builds¶
All registered schemas are checked at every validation point. The intent is to catch shape violations as early as possible. Performance cost is real but tolerable for dev iteration.
Production builds¶
Validation is elided by default — schemas remain registered (so tooling can introspect them) but the validation calls are compile-time-eliminated, alongside trace emission. The mechanism: every validate-*! body is wrapped in (when re-frame.interop/debug-enabled? ...) on the CLJS reference (other ports use the host's equivalent debug-enabled gate). debug-enabled? is an alias of goog.DEBUG on CLJS (default true in dev, false in :advanced production), so under :closure-defines {goog.DEBUG false} the closure compiler constant-folds and DCEs every validation site — the validator call, the trace-error envelope, the human-readable reason string, and every keyword the failure tags carry. See 009 §Production builds for the full elision contract and the CI verifier that enforces it.
What elision means for reg-app-schema. It is not a reduction in diagnostic detail — it removes the check itself. A production build registers each app-db schema and never consults it, so the candidate-rejection contract in §Per-step recovery row 4 does not hold there: a candidate whose value violates a registered schema installs, the handler's own :db installs with it, and there is no trace, no :rollback? true tag, no :errors record, and nothing for an operator to see. That is the designed posture — schema validation is a development tool and production trusts the programmer, the same position clojure.spec instrumentation and Malli's Var instrumentation take — but it is the sentence an application author needs before reading a registered schema as an integrity guarantee. Correctness rules that must hold in production belong in handler and domain logic; untrusted input belongs behind the boundary interceptor below, which survives the gate.
What elision means for a reg-fx :schema. Step 5 reads the same way. validate-fx! is gated on re-frame.interop/debug-enabled?, and on the JVM that flag is a def read once at namespace load, so under -Dre-frame.debug=false the fx-args boundary does not become quieter — it does not run. A production build therefore skips nothing: an effect whose args violate its registered :schema is invoked with those args, and the :skipped recovery in §Per-step recovery row 5 never happens. That is the intended posture for a :schema a programmer declares over their own effect. Paying a hot-path validation cost on every effect in every app is the thing this design is refusing, and it is refusing it on purpose.
The framework's own effects are not covered by that trust. C-000.35 settles what may be elided by what the check is for, not by who declared the schema it reads: an ordinary registration diagnostic elides, because the programmer is trusted in release; a check the framework relies on to keep a promise of its own does not, and a promise kept only in dev is not a promise. The six reserved :rf.server/* response effects (011 §Standard fx) sit on the second side of that line. They are a closed set the framework itself publishes, their arguments become the HTTP status line and header block, and the accumulator they write is a public host-adapter surface — so each of them checks its own arguments against its published type before touching that accumulator, identically in every build. They carry no :schema, so step 5 never runs for them in any build; the guard is their one check, and it fails closed: :rf.error/server-fx-args-invalid throws before the first write, the registered-fx containment runs the sibling effects, and an always-on :rf.error/fx-handler-exception record reaches error listeners and SSR's projector. The full contract is 011 §The reserved fx guard their own args.
Read that narrowly. It is a statement about six framework-owned effects, not about fx args in general. No user effect acquires a production check from it, and there is no flag that turns one on.
For users who want production validation at system boundaries — typically incoming events from untrusted sources (HTTP responses, websocket messages, postMessage) — a handler opts in by declaring :boundary? true on its registration. Boundary validation runs even when global validation is elided.
Relationship to the handler's :schema. :boundary? re-uses the handler's existing :schema — it does not introduce a parallel schema. Its only job is to force validation against :schema regardless of the global elision flag. Concretely:
- In dev builds, every event handler's
:schemais checked anyway (per §Validation order step 1), so the flag changes nothing: validation does not run a second time. - In production builds,
re-frame.interop/debug-enabled?isfalseand step-1 validation is elided — except for handlers carrying:boundary? true, which are still checked at that same step-1 site. :boundary? truewithout:schemais rejected at registration time (per 009 §Error event catalogue —:rf.error/at-boundary-missing-schema). Boundary validation is structurally meaningless without a schema to validate against, soreg-eventraises anex-infofrom the registrar rather than waiting until first dispatch in production builds to surface the misconfiguration. There is no warn-and-accept fallback; the registrar polices the contract uniformly across dev and prod. Presence of the:schemaKEY is what is required, not a truthy value:{:schema nil :boundary? true}registers, and thenilis handed to the validator as an opaque token.
One check, one site, one value. Both builds validate the ORIGINAL dispatched event vector, before the interceptor chain runs, against the already-resolved handler's own :schema. Dev and production therefore cannot disagree about what was validated, no interceptor can transform the event out from under the check, and no :interceptor-overrides map can remove it — it is not a chain entry.
Failures flow through the same :rf.error/schema-validation-failure :where :event path as dev-mode step-1 failures (:rf.error/malformed-schema when the validator throws), and the recovery is identical: the handler is not invoked, the payload never reaches app-db, and the downstream queue drains as usual. That much holds on every build. The check is ungated — it does not sit behind re-frame.interop/debug-enabled?, which is the whole point of it — so the rejection is exactly as real in a release build as in dev.
The report is a separate axis from the check, and this arm carries both. The check is ungated, and the report is always-on. Under :advanced + goog.DEBUG=false a boundary rejection fans one always-on :rf.error/schema-validation-failure record from the router's pipeline tail — :source :boundary, :where :event — and settles that dispatch's always-on :events record at :outcome :rejected, rather than the :ok a handler-less run would otherwise report. The record is projection-eligible, so on a server frame SSR's default projector answers the 400 the rejection deserves (per 009 §Error event catalogue and 011 §Server error projection). What still elides is the diagnosis sitting above the record: the trace/emit-error! emit carrying :value, :received, :explain and the interpolated :reason is gated on debug-enabled? like the rest of the trace surface, and so is building it, so a release rejection calls the validator once and never calls the explainer. So what differs between builds is neither whether the check ran nor whether the failure was reported, but how much detail the report carries — dev keeps the offending payload; production learns which event, in which frame, failed which schema. That is deliberate rather than a shortfall: a boundary payload is attacker-controlled by definition, so the always-on record is built from identifiers the framework already holds rather than filtered down from the diagnostic tags, and no key-wise redaction rule has to be trusted over it. Surviving and reporting remain two claims and not one — the distinction is C-000.35's, and this arm is that rule's worked instance: an ordinary registration diagnostic elides, a check the framework relies on to keep a promise of its own holds in every build, and a promise kept only in dev is not a promise.
Without the schemas artefact the check refuses. The production check calls the registered validator through the schemas artefact's seam (§The validation seam), and an app that never loaded the artefact has no seam to call. A :boundary? true handler is then not run, in a dev build as in a release one: the dispatch throws :rf.error/schemas-artefact-missing before the handler, whatever the payload, because a pass would run it on exactly the unchecked input the flag exists to gate. A dev build refuses too so that the refusal is met during development, not first in production; an unflagged handler is unaffected, and its :schema simply goes unchecked. A recordable coeffect whose reg-cofx declares a :schema refuses the same way, in every build: its injection throws :rf.error/schemas-artefact-missing and the handler does not run, rather than fold a value nothing checked (§Per-step recovery row 2). Managed HTTP refuses a schema :decode it cannot run for the same reason (014 §JSON decoder hardening). An installed nil :validate is a different state, and both checks pass under it: the artefact is loaded, and the app has turned validation off on purpose (§Locked rules).
Production builds in this configuration: 99% of code has zero validation overhead; the few system-boundary handlers validate every incoming payload.
Schemas as a tooling and agent surface¶
Handler schemas are queryable via the public registrar query API (002 §The public registrar query API), which lives on the re-frame.core façade. The app-db schema query surface is not on the façade: app-schemas / app-schema-meta / app-schemas-digest live on the owning re-frame.schemas namespace and are reached by requiring it directly (per API §Schemas — only the reg-app-schema / reg-app-schemas registration macros stay central on re-frame.core).
ONE FRAME SPELLING. Each read takes a single opts map whose :frame is REQUIRED. There is no ambient default — a schema read is a tooling question ABOUT a named frame, not an operation inside one — and no bare-frame-id or trailing frame-target sugar, because a live frame VALUE is itself a map and a type-sniffing positional argument could never be read locally (Principles §Name over place). A frameless or non-map call raises the catalogued :rf.error/no-frame-context naming the {:frame f} spelling.
(require '[re-frame.schemas :as schemas])
(rf/handler-meta {:kind :event :id :auth/login})
;; → {:doc "..." :schema [:cat ...] :ns ... :line ... :file ...}
(schemas/app-schemas {:frame frame-id})
;; → {[:user] {:path [:user] :schema UserSchema :ns ... :line ...}
;; [:todos] …}
;; the WHOLE app-db schema registration map the frame resolves, keyed by
;; path — what (rf/registrations {:frame frame-id :kind :app-schema}) answers
(update-vals (schemas/app-schemas {:frame frame-id}) :schema)
;; → {[:user] UserSchema, [:todos] TodosSchema, [:auth] AuthSchema, [] WholeAppDbSchema}
;; the schema values alone, in whatever language the registered validator
;; interprets — Malli on the CLJS reference
(schemas/app-schema-meta {:frame frame-id :path [:user]})
;; → that one path's registration metadata, or nil
(schemas/app-schemas {:frame frame-id}) is the surface pair-shaped tools (per Tool-Pair §How AI tools attach) call to reflect on the schemas a given frame resolves. Its {path → registration-metadata} result carries every reg-app-schema registration that frame resolves; app-schemas-digest hashes the {path → schema} projection of it, so the digest bytes do not depend on the metadata riding alongside.
Tools and agents read these to:
- Render shape information in 10x's panel.
- Validate intent before dispatching (an agent simulating "what would happen if I dispatch [:auth/login {…}]?" can pre-check against the spec).
- Generate test data via the schema language's generators (e.g. Malli's
mg/generateon the CLJS reference; Zod'sfakerintegrations on a TS port). - Generate JSON Schema or OpenAPI from registered schemas — useful for cross-platform contracts.
- Diff schemas across versions to detect breaking shape changes in app-db structure.
Tooling surface — Xray attachment¶
The Xray Epoch panel attaches each schema violation to its owning pipeline step rather than collecting them in a trailing footnote. A :rf.error/schema-validation-failure attaches by its :where: :event to DISPATCH; :app-db to the SIDE EFFECTS step's :db row (the handler's app-db write) and :machine-data to that step's :rf.db/runtime row; :fx-args to the matching SIDE EFFECTS row; and :sub-return to the matching SUBSCRIPTIONS row. Any other :where, and any violation whose owning step is absent from the cascade, lands on HANDLER, so no violation vanishes. Registration-time drift (the :rf.schema/violation op, which has no owning pipeline step) attaches to no step and surfaces in Xray's issues ribbon only. (Recordable-coeffect schema failures surface as the :rf.error/cofx-value-invalid hard error, not as a :rf.error/schema-validation-failure; Xray attaches that to the cascade's first COEFFECT step as an error, not as a violation.) When an :app-db or :machine-data violation carries :rollback? true, every step after SIDE EFFECTS renders muted. SIDE EFFECTS itself stays unmuted, because the rejected write's row reads ✗ and carries the violation under an Aborted chip, so the operator reads the blast radius at a glance. See tools/xray/spec/021-Dynamic-Panel-Designs.md §9.1.10.3 Violation attachment contract for the pipeline-side rendering details. The attachment is tool-side presentation only: the substrate-side contract (the two trace ops and their tag shapes) is the one this spec defines.
Humanize-hook — operator-readable explain payload¶
Consumed by Xray's violation sub-block (see tools/xray/spec/021 §violation-prose-template); may be consumed by any tool that subscribes to
:rf.error/schema-validation-failure.
The raw :explain value the registered explainer produces is structurally precise but operator-hostile — Malli's m/explain returns a nested {:errors [{:schema … :path … :value …} …] :value …} map that requires Malli familiarity to read at a glance. The validator port therefore carries an optional humanizer beside the explainer, so each port can supply a port-specific humanizer that transforms the raw explanation into an operator-readable shape (the CLJS reference's default bundle carries malli.error/humanize, returning a path-shaped map of natural-language strings keyed at the failing slots).
The humanizer is the port's :humanize key, installed through set-schema-fns! like :validate / :explain / :print:
- Port key:
:humanize, a(fn [explanation] readable). The schemas artefact consumes it; each port supplies it. The CLJS reference's default bundle (re-frame.schemas.malli/bundle) carriesmalli.error/humanizein development builds only: the reference sits behindinterop/debug-enabled?, the same gate as its sole reader, and:humanizeis nil in production.
When a humanizer is installed, §The validation seam builds :explain-humanized alongside :explain into every failure it reports, so every :rf.error/schema-validation-failure trace carries it whichever surface found the failure, and so do the :rf.error/cofx-value-invalid error and the failure data a route or resource param check throws. Consumers read :explain-humanized when present and fall back to :explain otherwise — a port without a humanizer ships raw :explain only, and tools must degrade gracefully.
Production elision. The seam builds :explain-humanized only when interop/debug-enabled?, and the default bundle's reference to the humanizer sits behind the same gate, so in production :humanize is nil and nothing reads it — including the checks that run in a release build (:boundary? true, a recordable coeffect, route and resource params). :advanced + goog.DEBUG=false builds DCE the augmentation and malli.error/humanize with it; the schemas-bundle gate asserts the humanizer is absent from the matched probe, and §Bundle cost records what Closure keeps of malli.error regardless, and why.
Composition with :sensitive?. When the failing slot carries :sensitive? true (per §:sensitive? — privacy in schema-validation error traces), the substrate redacts BOTH :explain and :explain-humanized to :rf/redacted — Malli's humanizer carries the failing value verbatim under its path-shaped output, and skipping the humanized slot would leave a redacted-raw / leaked-humanized inconsistency. Redaction is symmetric.
Other ports. Any port whose validator language carries an operator-readable error-decomposition surface (Zod's flatten() / format(); Pydantic's errors(); dry-rb's messages) can install its own humanizer as :humanize. The consumer-side contract (:tags :explain-humanized, fall back to :tags :explain) is port-independent.
Per-slot metadata vocabulary¶
Inside the schema value passed to reg-app-schema, individual slots may carry per-slot metadata maps — the {...} properties map Malli accepts on every slot. The reserved per-slot key vocabulary is catalogued normatively in Spec-Schemas §:rf/app-schema-meta; the reserved set is fixed-and-additive. The reserved keys are :large?, :hint, and :sensitive? — all three ship (:sensitive? drives the validation-failure trace redaction described in §:sensitive? — privacy in schema-validation error traces below).
:large? — schema-driven size-elision nomination¶
Slots marked :large? true declare, on a schema, that the value at the slot is large — a structural fact about the data's shape, for use by the owner-local size-elision classifiers that consume a schema (catalogued at 009 §Size elision in traces). As normative in 015 §What is removed and what is kept, a reg-app-schema {:large? true} slot prop is NOT a route into durable app-db classification: that is a reg-classification beside the feature (015 §Static app-db classification), and the re-frame.elision/elide-wire-value wire-boundary walker consults the frame's classification policy only. Schemas describe shape and validation; reg-classification owns durable public egress.
The walker that extracts a schema's :large? paths — re-frame.schemas/extract-large-paths-from-schema, published through the late-bind hook table as :schemas/extract-large-paths-from-schema so re-frame.core reaches it without statically requiring the schemas artefact — has exactly two live consumers:
- Owner-local egress classifiers. The http resource and
reg-pullresource surfaces each consult their own:data-schema/:params-schema's:large?slots to size-elide their own egress products (re-frame.http.privacy-body,re-frame.resources.classification). The elision is owner-local — it scopes to that owner's egress value (a transient wire product), not to a shared app-db registry. (The machine:datasurface is NOT an owner-local schema classifier: durable machine:datais runtime-db state inside a frame, so it is classified frame-side like every other durable path.) - The validation-failure size-safety arm (immediately below) — the schema's own egress product, the
:rf.error/schema-validation-failuretrace.
(rf/reg-app-schema
[:user]
[:map
[:profile [:map [:name :string] [:email :string]]]
[:uploaded-pdf {:large? true :hint "Upload preview blob"} :string]])
;; `extract-large-paths-from-schema` yields the schema's large-path
;; declarations for an owner-local classifier to consume:
;; {[:user :uploaded-pdf] {:large? true
;; :source :schema
;; :hint "Upload preview blob"}}
;;
;; This does NOT classify the durable app-db path. To size-elide an app-db
;; path on the wire, declare it with reg-classification beside the feature:
;; (rf/reg-classification :user.pdf
;; {:large [[:user :uploaded-pdf]]})
The :large? flag may live in two structural positions inside the schema:
-
Slot-level props — the per-slot properties map of a
Path is:mapchild entry:(conj base-path :uploaded-pdf). -
Container-level props — the schema's own properties map when the schema is registered at the path directly:
Path is thereg-app-schemapath itself.
Both yield the same registry entry; the walker handles both forms. The :hint string, when present on the same props map, is propagated verbatim into the registry entry and from there into the wire marker's :hint slot — orienting AI consumers without forcing a drill-down.
Nesting works as expected — :large? on a deeply-nested slot resolves to the full path:
(rf/reg-app-schema
[:root]
[:map
[:a [:map [:b [:map [:c {:large? true :hint "deep"} :string]]]]]])
;; ⇒ {[:root :a :b :c] {:large? true :source :schema :hint "deep"}}
Combinators (:or, :and, :maybe, :tuple, :multi, :vector, :set) descend at the parent path — these ops don't introduce a new app-db path segment. :multi branch slot-level props apply to the dispatched-value's path (the :multi's own path); the inner branch schema's name slots add further sub-paths.
Registration is a single atomic write. A reg-app-schema is one registrar write of the :app-schema kind and nothing more — schemas do not feed any app-db egress registry, so there is no second schema→elision/sensitive population step and no lock guarding one. The extract-large-paths-from-schema walker is invoked only by the owner-local classifiers and the validation-failure arm, at their own emit/registration sites, against the schema directly.
Idempotency. The extract-large-paths-from-schema walker is pure data — re-running it against the same schema produces the same large-path declarations, so an owner-local classifier that re-derives them on re-registration sees a stable result.
Other ports. The :large? mechanism is portable in spirit: any port whose schema language carries per-slot properties (Zod's .describe / refinements; Pydantic's Field's arbitrary kwargs; dry-rb's metadata) can plug the same predicate into the same walker shape. The CLJS reference's walker lives in the schemas artefact (re-frame.schemas/extract-large-paths-from-schema) and is published through the late-bind hook table — its consumers (the owner-local classifiers, per the §:large? consumers list above) call it without statically requiring the schemas artefact (per the per-feature artefact split).
:large? in validation-failure traces — the size-safety arm. A :rf.error/schema-validation-failure trace carries the whole checked value verbatim in its value-bearing slots (:value / :received / :explain, plus the per-surface :rf.fx/args / :rf.sub/query-v). A validation error is an egress surface like any other, so a :large?-flagged blob inside the failing value would ride the whole payload into the trace bus / epoch / MCP / log sinks unless the emit-site elides it. Symmetric with the :sensitive? redaction below: when the registered schema declares any :large? slot, the validation emit-site substitutes the canonical :rf.size/large-elided marker (per Spec-Schemas §:rf/elision-marker) for each value-bearing slot that carries the checked value, and stamps :tags :large? true. The decision is schema-wide, exactly as redaction is, and each marker describes the value its slot carries: on the app-db surface, whose :value is narrowed to the failing leaf, that slot's marker is built from the leaf, while :explain / :explain-humanized carry the marker of the whole registered value. :rf.sub/query-v is never replaced by a marker — it is the subscription's lookup key, not the checked value. The marker is built by the canonical re-frame.elision/->marker and carries :reason :classification (the classification-provenance default of the marker's :reason enum — schemas do not carry a distinct :reason :schema egress vocabulary; the validation-failure provenance is already on the enclosing trace's :operation / :where / :tags :large?) plus the required :hint slot. Sensitive wins over large — see §Composition with :large? — a both-flagged or sensitive slot redacts to :rf/redacted instead (the size marker itself would leak a secret's :path / :bytes signature). A non-:large?, non-:sensitive? failure rides verbatim — the elision is precise, not a blanket marker. The CLJS reference's whole-schema predicate is re-frame.schemas/schema-has-large? (the mirror of schema-has-sensitive?).
:sensitive? — privacy in schema-validation error traces¶
Cross-reference: see Security.md §Privacy / secret handling for the framework-wide pattern-level posture — per-slot schema
:sensitive?is the canonical path-level privacy declaration. Sensitivity is path-targeted: it is a property of the data value at a path, not of the handler that touched it. There is no handler-meta:sensitive?coarse fallback at validation sites (per 009 §:sensitive?registration metadata key); the validation site consults the per-slot schema declaration only.
Per 009 §Privacy / sensitive data in traces, the :sensitive? flag is the framework's declarative privacy marker. The schema-validation hot path MUST honour it before emitting :rf.error/schema-validation-failure trace events — those events carry the failing value verbatim by default (Malli's standard behaviour), and a sensitive credential / PII slot whose post-handler app-db value fails its schema would leak through the trace surface to every registered listener (including off-box error monitors and pair-tool forwarders).
The source of sensitivity the validation site MUST consult depends on what the checked value is. Sensitivity is path-targeted — a property of the data value at a path, not of the handler that touched it — and there is no second, handler-scope source: no registration-meta :sensitive? coarse fallback (per 009 §:sensitive? registration metadata key).
An app-db path is declared once, by classification. A durable app-db path is classified sensitive the one way 015 teaches — a reg-classification beside the feature, a flow's own output marks, or a subsystem declaration — and that one declaration covers the path's validation-failure traces as well as its egress. The app-db site (whose validate-app-schema! call is keyed by frame, not by a single handler's registration) reads that frame's classification policy, which holds before any event runs, so a declared path governs the trace of the very event that first writes a failing value there. The narrowed :value is walked at the failing leaf's path, as a :rf.sub/run or :rf.flow/computed value is at emit. :explain / :explain-humanized re-ship the whole registered value, so they are redacted whole, with :sensitive? true stamped, when the policy classifies anything in that value sensitive, and replaced by a :rf.size/large-elided marker when it classifies anything there large. The walk fails closed: a frame it cannot resolve redacts the whole value. A :sensitive? prop on an app-db schema is legal and still redacts that schema's failures, but it is not how an app-db path is declared sensitive.
A transient value is declared by its schema. A value no path declaration can reach — an event vector, a recordable coeffect, effect args, a sub's return value, a machine's :data or completion output, a route or resource param, an HTTP body — is classified by per-slot :sensitive? on the schema it is checked against. A slot or container whose Malli props carry :sensitive? true declares the slot's value sensitive — parallel to :large? (per §:large? — schema-driven size-elision nomination above). Two structural positions are accepted, exactly as for :large?:
;; (a) slot-level — the schema slot's per-slot props
(rf/reg-event :auth/login
{:schema [:cat [:= :auth/login] [:map [:password {:sensitive? true} :string]]]}
handle-login)
;; ⇒ the event's [:password] payload slot is sensitive
;; (b) container-level — the schema's own props
(rf/reg-cofx :auth/session-token
{:recordable? true :provided? true :schema [:string {:sensitive? true}]})
;; ⇒ the whole coeffect value is sensitive
Redaction shape. When the schema declares any slot sensitive, the trace event MUST, whichever slot failed (the decision is schema-wide: a conforming sensitive sibling of the failing slot rides inside every whole-payload slot, and the app-db surface's narrowed :value takes the same one rule):
- Replace
:value(the failing value) and:received(if present) with the framework-reserved sentinel keyword:rf/redacted(per 015 §The display contract — sentinels — same sentinel, same reserved-keyword guarantee). - Replace
:explainand:explain-humanizedwith:rf/redacted— the Malli explainer output carries the failing value verbatim under:value/:errors[].valueand re-leaks it, and so does the humanized decomposition of it. Tools that want a structural error description without the value reach for the path (:tags :path) and the schema's id (:tags :schema-id). - Replace
:rf.fx/argswith:rf/redactedon:where :fx-argsemissions only — this slot is a per-surface doubled-id name for the failing value (semantically equivalent to:receivedon the fx surface; see Spec-Schemas:rf.fx/handled). Without redaction the fx-args slot would re-leak the value the:value/:receivedredactions just scrubbed. - Replace
:rf.sub/query-vwith:rf/redactedon:where :sub-returnand:where :sub-overrideemissions only — this slot is the caller-supplied subscription query vector. On:sensitive?-marked subs the lookup key (the(rest query-v)payload) typically carries the same secret material the registered schema is gating — user ids, auth tokens, document ids. Without redaction the failure trace re-leaks the lookup-key payload alongside the failing return value the other clauses just scrubbed. - Stamp
:sensitive? truein the trace event's:tagsmap. The runtime promotes:tags :sensitive?to the top-level:sensitive?slot per 009 §Trace-event field::sensitive?at the top level (build-eventstamps the top-level flag) — so consumers read the top-level:sensitive?.
Path-of-failure (:tags :path), failing handler id (:tags :failing-id), schema id (:tags :schema-id), and the human-readable :reason string remain unredacted — these are structural / categorical signals that do not carry user data, and consumers need them to locate the broken slot. Only the value-bearing slots (:value, :received, :explain, :explain-humanized, plus :rf.fx/args on the fx surface and :rf.sub/query-v on the sub surfaces) are redacted.
Uniform across every validation :where surface (invariant). This redaction is a class invariant, not a per-site feature: EVERY framework-side report of a validation failure builds its value-bearing slots through §The validation seam, which redacts them against the schema before the report leaves it — :where :event, :fx-args, :sub-return, :machine-data and :machine-output (§Per-step recovery), and :sub-override (a :sub-overrides pin that fails the sub's own output schema) included, plus the production boundary check (per §Production builds), the recordable-coeffect :rf.error/cofx-value-invalid hard error (the live cofx schema surface, per §Validation order step 2), and the failure data a route or resource param check throws. The :app-db surface narrows :value to the failing leaf and redacts it, with the rest of its tags, under the same schema-wide decision. A conformance invariant drives every validation kind with a :sensitive?-marked schema and asserts no value-bearing slot ships the failing value verbatim. A new emission site that does not report through the seam is a redaction-class regression, not a new bug class.
One carve-out — :path past the declared keys. The :path is structural and unredacted because its segments are normally locators. On the app-db surface it carries Malli's :in, whose segments are not all locators: a :set failure reports the failing element value itself (a set has no positional index), so it carries every conforming sibling of that element; a :map-of failure reports the caller's key; and a closed map's extra-key failure reports the extra key. Any of them can be a secret. So on a sensitive app-db failure the runtime keeps the registered root and the longest leading run of :in segments that are keys the schema declares on a :map (looking through :maybe), and replaces every later segment with the :rf/redacted sentinel — [:user :age] beside a sensitive [:user :secret], [:members :rf/redacted :rf/redacted] for a failure inside a :set element. That one rule covers a :set element, a :map-of key, a closed-map extra key, a collection index and a branch tail. :reason is built from the same path. A non-sensitive failure keeps its raw :path.
;; [:auth :token] is classified sensitive beside the feature:
(rf/reg-classification :auth
{:sensitive [[:auth :token]]})
(rf/reg-app-schema [:auth] [:map [:token :string]])
(rf/dispatch [:auth/init-bad]) ;; candidate {:auth {:token 42}} — int, not string
;; The schema-validation-failure trace is shaped:
{:operation :rf.error/schema-validation-failure
:op-type :error
:recovery :no-recovery ;; dispatch failed; no auto-replacement
:sensitive? true ;; consumers route on this
:tags {:where :app-db
:path [:auth :token] ;; structural — kept
:frame :rf/default
:value :rf/redacted ;; value redacted (was 42)
:explain :rf/redacted ;; Malli explanation redacted (re-leaks)
:reason "App-db at path [:auth :token] failed schema ..."
:failing-id :auth/init-bad
:rollback? true}} ;; transaction REJECTED — the candidate
;; was never installed; app-db keeps its
;; pre-handler value
Composition with :large? (per 009 §Unified wire-elision surface). A slot carrying both :sensitive? true and :large? true redacts on sensitivity — the schema-validation emit site never produces a :rf.size/large-elided marker for a sensitive value (the marker itself would leak :path / :bytes / :digest). The validation emit-site mirrors the re-frame.elision/elide-wire-value walker's composition rule.
Composition with the complementary privacy sites. Independent. The other declaration site — registration-owned :sensitive payload classification on reg-event / reg-sub / reg-flow (per 015 §Registration-owned transient classification) — scrubs event-payload paths on the event/error trace surface, where the emit sites apply a registration's own :sensitive paths; the per-slot schema metadata described here redacts the specific value-bearing schema-validation fields. (There is no handler-meta :sensitive? true whole-handler stamp and no redact-interceptor — sensitivity is path-targeted and registration-owned, not handler-scoped or interceptor-placement-dependent.)
Production elision. A check that only traces builds no failure tags in a production build, so its redaction DCEs with the trace it would have shaped (per §Production builds). A check that reports in every build — a recordable coeffect's always-on error, a route or resource param's thrown error — redacts in every build, because that report egresses there.
Walker. The CLJS reference ships re-frame.schemas/extract-sensitive-paths-from-schema (parallel to extract-large-paths-from-schema) — a pure-data Malli-EDN walker that returns {path declaration} entries for every :sensitive? true slot in a registered schema. Each declaration carries {:sensitive? true :source :schema} plus an optional :hint propagated verbatim from the slot's props (apps reuse the same :hint key as :large? so a slot can be annotated once for both flags). The validation emit-site walks the failing path's schema with this helper to decide whether to redact.
Compiled / opaque schemas fail closed. The pure-data walker introspects the vector-form Malli EDN only (the framework MUST NOT call into the registered validator to introspect structure — see §Where schemas attach, "The :schema value is opaque to re-frame"). A schema registered as a compiled m/schema value (or any other form whose per-slot flags the pure-data walk cannot reach — a non-vector, non-keyword value at the root, but equally a vector form that embeds a compiled value at a nested schema position, or one carrying a local {:registry …} whose keyword references the walk resolves nowhere, or one that is — or embeds — an explicit [:ref …] reference form, whose target the walk likewise resolves nowhere) is an opaque leaf the walker cannot see into — yet the validator (Malli) does honour the {:sensitive? true} props inside it. So the validation-failure redaction fails closed for an opaque schema: it redacts the value-bearing slots and stamps :sensitive? exactly as if a sensitive slot were declared, because the walker cannot prove the value is non-sensitive and the opaque value may carry a {:sensitive? true} slot the validator just acted on. Without this, an opaque schema's failure leaks the value verbatim while the equivalent vector form redacts — an asymmetry that turns the documented "register the vector form so per-slot flags are visible" nudge into a silent privacy hole. A bare keyword schema (:int / :string / a registry ref) is NOT treated as opaque here — a primitive keyword provably carries no per-slot props, so failing closed on every keyword would over-redact every plain-scalar failure for the rare registry-ref true positive (the same false-positive tradeoff the :rf.warning/schema-walker-opaque registration nudge makes by staying silent on keywords). An explicit [:ref …] gets no such carve-out — it is unmistakably a reference, so it fails closed like a local registry, while the bare keyword stays walkable only because it cannot be told from a primitive. A qualified keyword is recognisably a registry reference, but whether its target carries a flag is unknowable without a registry consult (Malli's own :time/instant is a flag-free scalar) and qualified composition is Malli's idiomatic spelling, so validation surfaces keep it walkable too, while the off-box HTTP export, which must fail closed where classification is unknown, omits a body whose :decode names one (Spec 014 §Response-body classification). An unanchored :map-of — a keyed collection the walk reaches before a declaration's first named slot ([:map-of :string [:map [:token {:sensitive? true} :string]]], an id-keyed value) — splits the same way. Validation surfaces keep it walkable, because they decide schema-wide, so every mark below it counts. The extracted path omits that key, and an index-free match rides a map key only after a path's first named segment, so the off-box walks cannot place such a mark: the HTTP export fails closed on it through the :schemas/schema-has-opaque-child? walker hook, which answers re-frame.schemas.walker/schema-has-unanchored-map-of-mark? beside opacity (Spec 014 rule 3), the resources artefact's invalid-params redaction redacts the whole :params slot, and a resource's trace egress walks the value under its owner's declared slot, whose name anchors the key. The CLJS reference predicates are re-frame.schemas/schema-opaque? for the registered ROOT value and re-frame.schemas/schema-has-opaque-child? for the recursive class above, which is what the fail-closed surfaces consult; the supported route to make per-slot flags visible (and avoid the coarse whole-value fail-closed redaction) is registering the vector form. This is a class invariant across every validation :where surface and §The validation seam.
No app-db classification feeder. A reg-app-schema :sensitive? / :large? slot prop does NOT feed the frame's classification policy. Durable app-db classification is a reg-classification beside the feature, and runtime-db instance data (machine snapshots included) is classified by its subsystem's projection-relative declarations (015 §Static app-db classification, 015 §Subsystem projection-relative classification); re-frame.elision/elide-wire-value consults that policy. The extract-sensitive-paths-from-schema walker serves only its owner-local consumers (resource :data-schema / :params-schema, HTTP-body :decode) and the validation-failure-trace redaction described above.
Non-sensitive failures ride verbatim. Validation failures with no :sensitive? declaration carry :value, :received, and :explain on the trace verbatim. A listener that reads :tags :value directly sees the value for non-sensitive traces and the sentinel keyword :rf/redacted for sensitive ones; the sentinel is a normal EDN value the consumer can pattern-match on.
Schemas resolve through the frame's images¶
An app-db schema is a program registration, like an event handler: reg-app-schema registers the registrar kind :app-schema under the canonical path, at the top level, and never reads a frame. Every frame validates against the schemas it resolves, exactly as it resolves every other kind (002 §Image resolution and composition): live from the registration store for a frame made without :images, through its sealed generation for a frame made with them. So one registration applies to every frame that resolves it — the app frame, an SSR per-request frame, a test frame made with (rf/make-frame {}), a Story variant built from the app image — with no per-frame registration and no frame in scope.
A frame that should validate differently resolves a different schema: register it in a namespace a later image selects, and that image's frames resolve it in place of the one it shadows, while frames without that image keep the original. A :frame on reg-app-schema or reg-app-schemas is a loud :rf.error/app-schema-frame-target, because honouring it would make one registration mean different things to different frames, and ignoring it would install a program-wide schema where the author asked for a frame-local one.
A path registered from more than one namespace is ambiguous, and a frame made without :images refuses it as it refuses any ambiguous id: the reads throw :rf.error/ambiguous-registration, and a candidate that puts a value at that path is rejected — the :rf.error/malformed-schema trace names the ambiguity — rather than validated without its schema. A candidate leaving the path absent checks nothing there, so it installs.
The reads (app-schemas, app-schema-meta, app-schemas-digest) take one opts map whose :frame is REQUIRED: they never resolve an ambient frame, and a frameless or non-map call raises :rf.error/no-frame-context. Each answers from the named frame's own resolution, the set (rf/registrations {:frame f :kind :app-schema}) reads. The frame need not be LIVE: an id naming no frame answers each path's latest registration, as the registrar query API does, because Story computes a variant's identity before it allocates the variant's frame.
Frame TARGETS, not just keyword ids. The :frame slot of the reads app-schemas / app-schema-meta / app-schemas-digest takes a frame-id keyword OR a frame value (rf/make-frame's return token), the same target shapes the registrar query API accepts, and a frame value answers exactly as its id does. An explicit :frame resolving to a non-keyword target (a string, a non-frame map, a vector) fails loud with :rf.error/app-schemas-bad-arg rather than silently reading nothing.
;; The app registers its schema once, at the top level of its own namespace.
(ns app.schema (:require [re-frame.core :as rf]))
(rf/reg-app-schema [:user] UserSchema)
;; A story that needs a narrower contract registers it in its own namespace…
(ns stories.login-form (:require [re-frame.core :as rf]))
(rf/reg-app-schema [:user] StoryUserSchema)
;; …and its variant frame selects that namespace in an image after the app's.
(rf/make-frame {:id :story.auth.login-form/empty
:images [(rf/image {:id :app/main :select-ns {:include ["app.**"]}})
(rf/image {:id :stories/login-form
:select-ns {:include ["stories.login-form"]}})]})
;; Public query API takes ONE map with a REQUIRED :frame. It lives on the
;; owning re-frame.schemas namespace, NOT the re-frame.core front porch.
(require '[re-frame.schemas :as schemas])
(schemas/app-schema-meta {:frame :production :path [:user]}) ;; → that path's registration meta
(:schema (schemas/app-schema-meta {:frame :story.auth.login-form/empty
:path [:user]})) ;; → StoryUserSchema
(schemas/app-schemas {:frame :production}) ;; → {[:user] … [:todos] …} that frame resolves
Why images: stories, multi-instance widgets, and per-test fixtures need shape-flexibility — a stripped-down schema for a story variant must not bleed into the production frame's contract. An image already gives every other kind exactly that, so a schema varies the way a handler does, and the default is inheritance: a frame validates against the app's schemas unless its images say otherwise. Tools query "what schema applies at path P in frame F?" and get the frame's own answer.
Schema digest: the schema set each frame resolves has a stable digest (a hash of its [path, schema] pairs in canonical order). Tools and the SSR hydration handshake use the digest for client/server divergence detection: the server's request frame and the client's frame each digest the set they resolve, so the two compare like with like — see §Schema digest below and 011 §The :rf/hydrate event.
Schema digest¶
Every frame exposes a stable digest of the schema set it resolves. Like the other schema query surfaces it lives on re-frame.schemas, not the re-frame.core façade:
(require '[re-frame.schemas :as schemas])
(schemas/app-schemas-digest {:frame :rf/default}) ;; → "sha256:abc1234567890def" for that frame
(schemas/app-schemas-digest {:frame :production}) ;; → "sha256:..." for the named frame
Used by:
- SSR hydration (011 §The
:rf/hydrateevent) — the server includes its digest in the hydration payload; the client compares its own digest on hydrate and emits a:rf.ssr/schema-digest-mismatchtrace event on divergence. Catches deploy drift in the schema declarations (server bundle has newer schemas than the client's active bundle); a change hidden behind a function token or a registry reference moves no digest (per §Digest algorithm). - Pair tools — the runtime pair tool can warn when an attached REPL session is talking to a runtime whose schema set has shifted under it.
- Cross-host conformance — a TS client talking to a CLJS server can record digests for replay/snapshot regression.
The digest is derived data, not part of the registration shape. Implementations that don't ship a runtime schema layer (some statically typed hosts) may compute it from the type system's structural fingerprint or omit the feature. The digest's output shape and input ordering are normative (so cross-host comparisons stay meaningful); the per-schema serialisation is determined by the registered validator's schema-print companion fn.
Digest algorithm (normative)¶
The digest must be cross-runtime reproducible — a CLJS server and a CLJS client running the same schema set produce the same digest, byte-for-byte. The algorithm below is normative; ports that ship a digest must implement exactly this procedure.
Inputs. The app-db schema set the frame resolves, as a map {path → schema-value} where path is a concrete :rf/path (a vector of portable EDN identity segments — keywords, strings, symbols, safe-range integers, booleans, UUIDs, instants, or nil — per Conventions §The :rf/path algebra; or the empty vector for the root schema) and schema-value is the registered schema in whatever data form the registered validator interprets (a Malli EDN form on the CLJS reference; another shape on other ports — see §Default validator and the validator-fn extension point). Schema registration routes the path through the shared concrete-segment boundary, so a composite / function / host / float / unsafe-integer segment is rejected at reg-app-schema rather than reaching the digest.
Procedure.
- Serialise each schema value to a stable byte sequence. The serialisation fn (
schema-print) is supplied alongside the validator fn (per §Default validator and the validator-fn extension point) and must be deterministic — the same schema value always produces the same bytes. The CLJS reference's default usespr-strover the Malli EDN form with map-key ordering normalised (keys sorted by(compare (pr-str a) (pr-str b))— comparing thepr-strprojection of each key, which is a total order over EDN values and produces identical bytes on every host; printed without metadata). UTF-8 encoded. - Hash each schema independently. Compute
SHA-256(schema-print(schema-value))for every entry, producing a 32-byte digest per schema. - Build the per-entry record. For each
(path, schema-value), emit the line<path-key> <hex-of-sha256-bytes>\nwhere:path-keyis the path encoded through the shared CEDN-1 canonical byte encoding (Conventions §Canonical EDN identity) — the path vector'scanonical-bytestoken stream (e.g.v[k::user],v[k::auth k::credentials],v[]for the root). Conventions §Canonical EDN identity lists schema digest path keys among the canonical-identity surfaces;pr-stris not a valid identity contract (it is host-divergent for the non-keyword segments — UUIDs, instants, nil, integers — a concrete path may carry), so the path key uses the same cross-host canonical bytes every other identity-sensitive surface does.hex-of-sha256-bytesis the 64-character lowercase hex encoding of the SHA-256 bytes from step 2.- The trailing
\nis a literal newline byte (0x0A).
- Sort the lines lexicographically as byte sequences (UTF-8). Lexicographic byte order is well-defined and identical across hosts — no locale or collation involvement.
- Concatenate the sorted lines into a single byte sequence (already terminated with
\nper line; no separator added between lines). - Hash the concatenation with SHA-256 to produce the final 32-byte digest.
- Encode the output as
"sha256:" + first-16-hex-chars-of-digest(lowercase). The 16-char prefix is sufficient for collision detection across the relatively small space of registered schema sets; full 64-char hex is acceptable for tools that want maximum strictness, but the canonical wire form is the 16-char-prefix variant.
Output. A string of the form "sha256:abc1234567890def" — the literal prefix sha256: followed by 16 lowercase hex characters. Two frames produce equal digests iff their {path → schema-value} maps serialise byte-for-byte identically.
What the cross-runtime promise covers, and the one schema shape it does not. The byte-for-byte guarantee above is a promise about schema values that are EDN data. Two scalar kinds do not survive a host's pr-str unchanged, and step 1's canonicalisation normalises them before printing:
-
Whole-number floating-point values are emitted as the integer they denote, inside the ECMAScript safe-integer range
[-(2^53-1), 2^53-1].pr-strof1.0is"1.0"on the JVM and"1"on a single-numeric-type host such as CLJS, so[:int {:min 1.0}]would otherwise digest differently on the two hosts. Normalising towards the integer is the only direction that can agree — a host that does not distinguish1from1.0cannot be asked to print a.0suffix — and the consequence is that[:int {:min 1.0}]and[:int {:min 1}]are one schema to the digest. Any other floating-point value (outside the safe-integer range, or not whole) carries no cross-runtime guarantee: it is printed as the host prints it, and hosts disagree at their exponential-notation thresholds (1.0E21against1e+21). This matches Conventions §Canonical EDN identity, which places floating-point values outside the portable identity domain altogether; express a constraint that must digest identically as a portable integer or a string. -
A function is emitted as a stable token derived from the host's own name for it — the class name on the JVM, the function's
nameproperty on CLJS — never the host's default print, which on the JVM embeds an identity hash that is a fresh value in every process. Without this rule a schema carrying a bare predicate ([:map [:n pos-int?]]) would digest differently on every process start, so the SSR hydration handshake (011 §The:rf/hydrateevent) would report:rf.ssr/schema-digest-mismatch— "Deploy drift" — against a client running byte-identical code. The token is name-derived rather than constant so that swapping one predicate for another still moves the digest, which is the drift detection this surface exists for.
A fn-bearing schema is therefore process-stable, not cross-runtime reproducible, and that is a deliberate limit rather than an omission. The token is whatever the host calls the function, and the two hosts do not agree: a JVM runtime answers clojure.core$pos_int_QMARK_ where a CLJS runtime answers cljs$core$pos_int_QMARK_, and an :advanced-optimised CLJS build munges that name again into something specific to the build. No normalisation can close that gap — one that worked in development and stopped working under :advanced would produce exactly the silent false-drift warning this rule prevents. So the guarantees are:
| schema content | within one host, across processes | across hosts |
|---|---|---|
| EDN data only | reproducible | reproducible |
| whole-number floats in safe-integer range | reproducible | reproducible |
| other floating-point values | reproducible | not guaranteed |
| a function (bare predicate) | reproducible | not guaranteed |
A schema set whose digest must agree across hosts must be expressible as EDN — [:int {:min 1}] rather than [:fn #(>= % 1)], and a registered named schema rather than an inline predicate. A port MAY reject fn-bearing schemas at registration instead; the reference does not, because a bare predicate is an ordinary and convenient thing to write when both sides of a comparison are the same host.
Within one host, equal digests mean equal serialised declarations, not equal validation behaviour. The digest hashes what each schema value prints as, and two kinds of change leave that print untouched. A function token names the function, not what it does, so two closures from one function site that capture different values — (fn [x] (>= x lo)) built once with lo 1 and once with lo 5 — print the same token. A registry reference prints as its keyword, so redefining what a keyword names in the validator's registry leaves every schema that refers to it byte-identical. Either change alters validation without moving the digest, so a constraint the digest must see belongs in the declaration itself, as EDN data.
Why this shape. Per-schema hashing in step 2 means a single schema change perturbs exactly one line; the per-entry record in step 3 binds path to schema-hash so two schemas swapping paths produce different digests; the byte-lexicographic sort in step 4 is the same on every host (no Unicode-collation-rule dependence); SHA-256 is universally available; the 16-char hex prefix is short enough to ship in trace events without bloat. SHA-256 is preferred over FNV-1a for cryptographic-strength collision resistance and ubiquity (every JVM, every browser via Web Crypto; every JS-cross-compile target consumes the same Web Crypto on the client and the host's native primitive on the server).
Memoising the per-schema serialisation (implementation note, non-normative). Step 1's schema-print(schema-value) is pure (same schema value → byte-identical output), so a port MAY memoise it keyed by the schema value, provided an entry lives no longer than the schema it describes. A schema value is fresh whenever a re-registration replaces it, a clear or the hot-reload sweep drops it, or it is built per call (a closure-bearing schema, a managed-HTTP :decode carrying a request literal per 014), so a memo that is never evicted keeps one entry for every such value it has seen. The CLJS reference memoises neither the serialisation nor the per-slot :sensitive? walk: the digest prints each registered schema on every call, and the redaction path walks a schema on each validation failure, and both are cold paths.
Test vector. A frame resolving two registrations:
After the procedure above (using the CLJS reference's pr-str serialisation for Malli forms; a port substituting a different validator will produce a different digest for the same {path → schema-value} map iff its schema-print produces different bytes — that's the intended cross-port distinction), the digest is deterministic. Conformance fixtures pin a small number of schema sets to expected digest values so port implementations can self-check their digest pipeline.
Non-schema-layer hosts. A host whose type system supplies the shape information (TypeScript, Kotlin) and ships no runtime schema layer may compute the digest from a structural fingerprint of its types — but if it does ship a digest, the output shape ("sha256:" + 16 hex chars) and the input ordering (sorted-by-path) must match so cross-host comparisons remain meaningful. Hosts that omit the digest entirely return nil from app-schemas-digest, and :rf.ssr/schema-digest-mismatch is suppressed when either side returns nil.
Default validator and the validator-fn extension point¶
This section is the portable normative core of the schemas surface. Every re-frame2 port — CLJS reference, TypeScript, Python, Rust, whatever — implements these four claims; the rest of the section's prose (and the rest of this Spec) reads in the same shape regardless of which schema language the port's default validator interprets.
The four normative claims¶
-
Apps register schemas via
reg-app-schema(path-scoped, per §app-dbschemas — path-based) and via the:schemametadata key onreg-*(per §On everyreg-*). These two surfaces are the portable contract every port supplies; both pass the registered schema value through opaquely. -
Validation is pluggable via
set-schema-fns!(which installs the validator and its companion explainer together). The runtime never inspects:schemadirectly; every validation site routes through the registered validator fn. Substituting a different validator is a single registration call — the rest of this Spec (when validation runs, what happens on failure, how digests are computed) applies whichever validator is registered. -
The default validator is implementation-defined. Each port picks a default appropriate to its host: Malli on the CLJS reference, Zod on a TypeScript port, Pydantic on a Python port, dry-rb on a Ruby port, the host's structural-typecheck wrapper on a statically typed port — etc. Ports document their default's schema-language choice in their
README/ implementation-notes; the Spec does not mandate any particular library. -
Dependency-absent behaviour is implementation-defined, with a recommended soft-pass default. When the default validator's underlying library is not present on the classpath / module graph / runtime, the recommended behaviour is to soft-pass (treat the value as conforming) so new users aren't blocked by a missing optional dep. Apps that want a hard fail on a missing dep register a stricter validator via
set-schema-fns!. The soft-pass is a recommendation, not a mandate — a port may choose a different default and document it.
How the surface works¶
Validation always goes through a registered validator fn. The CLJS reference splits the surface into two fns — a fast pass/fail check on the hot path and an explainer used only on the failure branch — so the dev-mode validation site stays cheap:
;; The validator: pass/fail check on the hot path.
(fn validate [schema value] truthy?)
;; truthy — the value conforms
;; falsey — the value fails the schema
;; The explainer: invoked only when validate returns falsey, to
;; populate the failure trace's :explain key.
(fn explain [schema value] explanation-or-nil)
Both fns are registered at boot, before the first reg-app-schema or :schema-bearing reg-* lands. The port is a value and set-schema-fns! is the one door: it takes any subset of the bundle, so the same call installs a whole port or swaps a single fn, and a port's validator/explainer/printer never drift mid-boot:
;; The port lives on re-frame.schemas, NOT the re-frame.core front
;; porch — reach it through the owning namespace.
(require '[re-frame.schemas :as schemas])
;; (1) Install a whole port: validator, explainer, registration-time
;; compiler and digest-printer in one call so they never drift
;; mid-boot.
(schemas/set-schema-fns! {:validate my-validator-fn
:explain my-explainer-fn
:compile my-compile-fn
:print my-printer-fn})
;; (2) Swap ONE fn: an omitted key leaves its existing registration in
;; place, so a one-key install IS the single-purpose call.
(schemas/set-schema-fns! {:validate my-validator-fn})
(schemas/set-schema-fns! {:explain my-explainer-fn})
;; (3) The schema-print companion the digest pipeline hashes (see
;; §Schema digest below). Non-Malli ports register their own
;; serialiser so the digest reflects the port's own validation
;; contract. Passing nil here reinstalls the default EDN
;; canonicaliser, so the digest is never undefined for a present
;; schema set.
(schemas/set-schema-fns! {:print my-printer-fn})
;; (4) Hard no-op: an explicit nil :validate disables validation
;; everywhere. Every validate-*! site short-circuits without
;; inspecting the schema, and registration compiles nothing (the
;; install clears :compile). Apps that want zero validation surface
;; (and zero schema-library bundle cost) install nil at boot.
(schemas/set-schema-fns! {:validate nil})
;; (5) Read back what is installed, and restore the framework defaults.
(schemas/schema-fns) ;; => {:validate … :explain … :humanize … :compile … :print …}
(schemas/set-schema-fns! schemas/default-schema-fns)
Note the distinction (2) rests on: an omitted key leaves the live registration alone, while an explicit nil writes nil and disables that fn. Collapsing the two would make a partial install unsafe.
Registration-time compile. The optional :compile key, (fn [schema] _), is called with each schema a development-build reg-app-schema or reg-app-schemas declares, before anything registers, so a malformed schema throws at the call that wrote it rather than at its first validation. Its return is ignored, and production builds never call it. The CLJS reference's default compiles into the same cache :validate reads, so registering also warms the validator. A port installing :validate without :compile runs with no registration-time compile, because a compiler belongs to the validator it compiles for: set-schema-fns! clears :compile when the same call installs :validate and names none. With no :compile a malformed schema registers and fails closed at its first validation (:rf.error/malformed-schema, per §Malformed schema VALUE).
set-schema-fns!, schema-fns and default-schema-fns are each rowed in API.md §Schemas, and together they are the whole public validator-surface seam. They let a port (or an app) swap out Malli wholesale: validator + explainer + humanizer + compiler + printer = the entire schema-language surface the framework consults.
Per-port default¶
Per claim 3 above, each port picks the default validator/explainer pair appropriate to its host. The CLJS reference's default delegates to Malli (malli.core/validate + malli.core/explain); a TypeScript port might default to Zod; a Python port to Pydantic; a Ruby port to dry-rb. The port's README (or implementation-notes file) is the authoritative source for its default's identity.
Substituting a different validator — clojure.spec instead of Malli, a JSON-Schema validator, the host's structural-typecheck wrapper — is a single registration call; the rest of this Spec (when validation runs, what happens on failure, how digests are computed) applies whichever validator is registered.
Recommended soft-pass when the default validator's library is absent¶
When the default validator's underlying library is not present (Malli is not on the CLJS classpath; Zod is not in the TS module graph; etc.), the recommended behaviour is soft-pass: every validate-*! site returns true (the value is treated as conforming) and no failure trace is emitted. The motivation is new-user friendliness — first-time users who haven't yet decided whether they want runtime validation should not be blocked by a missing optional dependency.
Apps that want a hard fail when the default library is absent (a stricter posture suitable for production deploys where a missing dep means a misconfigured bundle) register a stricter validator via set-schema-fns!. The hard-fail validator's body is a single throw — the override surface is the same regardless of the failure mode the app prefers.
This recommendation is normative-soft: ports that ship a different default-absent behaviour document the divergence in their README, and apps that depend on the soft-pass behaviour pin it explicitly with their own registered validator.
CLJS reference note. The CLJS reference has no default-absent state: its default bundle is Malli itself, which the schemas artefact depends on (per §Schema implies validation on CLJS), so a registered schema always validates. A substitute validator answers for itself. The divergence is deliberate: "schema implies validation" is a stronger guarantee than the cross-port recommended soft-pass, chosen so the common case ("I registered a schema") is never a silent no-op.
Locked rules¶
- One validator fn per process is in effect at any time. Last-write-wins on re-registration. The validator is for the schema language, not per-app-instance — Malli, Zod, or a custom validator is a process-global choice.
- The validator fn is pure — same
(schema, value)returns the same result. Implementations may memoise but tests must not depend on memoisation. - The validator fn must be production-elidable alongside the host's debug-enabled flag (
re-frame.interop/debug-enabled?on CLJS; the equivalent on other ports) — calls to it disappear in prod builds (subject to the boundary-validation override per §Production builds). - Schema digests (§Schema digest) are computed from the schema values as serialised by the registered validator's
schema-printcompanion fn (see §Schema digest) — not from the validator. Two ports using different validators against the same schema-language-EDN-form produce the same digest iff theirschema-printfns produce identical bytes; two ports using different schema languages produce different digests by construction. nilvalidator means no validation, not "every value fails". Setting validator to nil is the documented opt-out — everyvalidate-*!site short-circuits totrue(pass). The schemas mandate stays unchanged at the framework level (apps still attach:schemaandreg-app-schema); only the runtime check is disabled.
What the extension point does NOT cover: a mix of validators in one process. The runtime resolves one validator and uses it for every :schema everywhere; a hybrid setup (one schema language for app schemas, a different one for boundary handlers) requires the user to register a composite validator that dispatches internally on schema shape.
Test-support: capture and reinstate the validator bundle¶
Because the port is a value, a test (or fixture) captures the live bundle, installs a custom one, and reinstates the prior bundle with the same two names an app uses — no dedicated snapshot or restore verb, and without reaching the framework-internal validator atoms. schema-fns reads, set-schema-fns! installs, and the two round-trip, so the whole pattern is an ordinary let + finally over a value:
(require '[re-frame.schemas :as schemas])
(let [installed (schemas/schema-fns)] ;; the live {:validate :explain :humanize :compile :print}
(try
(schemas/set-schema-fns! {:validate stub-validate :explain stub-explain})
;; ... exercise the validation path against the stub ...
(finally
(schemas/set-schema-fns! installed)))) ;; reinstate; a nil :print would
;; coerce to the default printer
Restoring the framework defaults specifically — rather than an arbitrary captured bundle — is (schemas/set-schema-fns! schemas/default-schema-fns).
This is the bundle-level counterpart to the registrar snapshot re-frame.test-support takes and restores (snapshot-registrar / restore-registrar!, and every make-reset-runtime-fixture): app-db schemas are registrar state, so that snapshot restores them after a test, and the reset clears every one for the test's length, so a test validates only against the schemas it registers. The registry side captures which schemas are registered; the bundle side captures which validator/explainer/printer is installed, and needs no dedicated pair, because its state is the value set-schema-fns! installs. The two compose: capturing and reinstating both puts the whole schema runtime back through public API. The bundle names are rowed in API.md §Schemas — not part of the re-frame.core front-porch facade.
The raw atoms are private (encapsulated-only contract). The atoms behind the pluggable
validator-fn/explainer-fn/humanizer-fn/compiler-fn/printer-fnbundle are not re-exported as public Vars onre-frame.schemas. The supported surface isschema-fns/set-schema-fns!above; tests and fixtures capture-and-restore through it rather than reaching the atoms. This is deliberate: theprinter-fnatom must never be set tonil(the setters coercenil → default-edn-printso the digest path's never-nil invariant holds without a read-site guard — a raw(reset! printer-fn nil)would defeat it). A port should expose the encapsulated read/install surface and keep the underlying atoms internal to its schemas module.
Schema implies validation on CLJS (Malli wired by the artefact)¶
On the CLJS reference, requiring the schemas artefact wires the default Malli validator automatically — there is nothing extra to opt into. The re-frame.schemas facade :requires the re-frame.schemas.malli adapter ns, whose bundle is the default port: :validate and :explain are Malli's, and :humanize is malli.error/humanize in development builds (per §Humanize-hook). :validate compiles each schema once — malli.core/validate builds a fresh validator on every call, which costs more than the check — and keeps the compiled validator in a cache keyed weakly on the schema value and owned by the bundle: an entry lives only while something else, normally the registration, holds its schema, and installing another :validate drops the cache with the bundle. There is nothing to configure or clear:
(ns my-app.core
(:require [re-frame.core :as rf]
[re-frame.schemas])) ;; loads the artefact ⇒ Malli is wired ⇒ schemas validate
The rule is "I registered a schema" ⇒ "it validates". The schemas artefact owns its default validator's wiring because an adapter that had to be required separately at app boot would be a footgun: an app that loaded re-frame.schemas but forgot [re-frame.schemas.malli] would register schemas nothing checks, so registration would NOT imply validation.
The default bundle references Malli statically rather than resolving it at runtime because CLJS has no runtime resolve: (resolve 'malli.core/validate) returns nil on CLJS, so a default validator built on it would pass every value even with Malli on the classpath.
Substitute validators. An app that wants a different schema language (a clojure.spec bridge, a custom validator) installs it via set-schema-fns! at boot; what it does with a schema it cannot evaluate is its own contract.
On the JVM the same wiring applies — loading re-frame.schemas loads the adapter, so JVM apps validate against Malli without a separate require. The contract is symmetric across runtimes.
Worked example — installing a no-op validator at boot (CLJS reference)¶
An app that uses schemas as inert data — surfaced via app-schemas / app-schemas-digest for tooling, but never validated at runtime — installs a no-op validator at boot. This disables every validation call site (the dev-mode hot path AND the boundary interceptor):
(ns my-app.core
(:require [re-frame.core :as rf]
[re-frame.schemas :as schemas])) ;; loads the artefact ⇒ Malli is wired
;; Install the no-op BEFORE the first reg-app-schema / :schema metadata.
;; The setter lives on re-frame.schemas, not the re-frame.core facade.
;; Any (fn [schema value] truthy?) that returns true unconditionally
;; passes every value; nil disables the call site even faster. The
;; install also clears :compile, so registration compiles nothing.
(schemas/set-schema-fns! {:validate nil})
;; Schemas attach as usual — they're inert data the framework still
;; surfaces via app-schemas / app-schemas-digest, but no validate
;; call ever runs against them.
(rf/reg-app-schema [:user] [:map [:id :uuid]])
(rf/reg-event :auth/login
{:schema [:cat [:= :auth/login] [:map [:email :string]]]}
...)
A nil :validate disables validation behaviour, not Malli's bundle cost. Under static CLJS compilation, requiring re-frame.schemas loads the Malli adapter at module-init, so Malli's body is in the bundle regardless of the nil validator. The nil opt-out is the right tool when you want schemas-as-data with zero validation overhead, but it is not a Malli-bundle-cost opt-out. The only Malli-free posture on the CLJS reference is not requiring the schemas artefact at all — an app that needs neither reg-app-schema nor :schema metadata pays nothing (the no-feature counter reference app, pinned by the counter bundle-isolation gate). This is a deliberate tradeoff: "schema implies validation" is worth the bounded Malli surface for apps that use schemas; apps that don't use schemas are unaffected.
The validation seam¶
Every validating surface reports a failure through one seam, so a failure carries one shape whichever surface found it, and the programmer and the AI reading traces over the pair tools read one shape. The CLJS reference's seam is re-frame.schemas.validate/check!, published as the :schemas/check! late-bind hook; the artefact's own event, effect and sub validation call it, and so does every surface outside the artefact: the :boundary? true check, recordable coeffect values, Story sub overrides, machine :data and completion output, route and resource params, epoch restore, and Story's :db-seed and args checks. It routes through the same atoms set-schema-fns! writes, so a substituted validator reaches every surface with one call, the production surfaces included.
The seam takes the surface's :where, the failing :id, the :schema, the :value, the :frame, the :recovery the caller will apply, the surface's own structural :extra-tags, and a development-only :reason. It answers nothing when the value conforms (or a nil validator is installed), and otherwise the failure's category and its tags:
- Category.
:rf.error/schema-validation-failure, or:rf.error/malformed-schemawhen the validator threw (§Per-step recovery). - Tags.
:where,:failing-id,:frame,:recovery, the value-bearing:value/:received/:explain, in development:explain-humanized(§Humanize-hook) and:reason, and the surface's extra tags — redacted per §:sensitive?and size-elided per §:large?against the schema. A malformed schema's tags carry:schemaand no value-bearing slot.
The seam emits the trace itself unless the caller asks for the tags only: a surface that reports through its own always-on error (a recordable coeffect), throws (route and resource params, Story's :db-seed), only wants a verdict (epoch restore, Story's args panel), or must order an always-on record before the trace (machine :data) takes the tags and reports them its own way. Each caller keeps its own recovery — abort, skip, observe or throw. In a production build an emitting check builds no tags, because the trace it would carry is elided, so a release :boundary? true rejection calls the validator once and nothing else.
A loaded artefact always publishes the seam, and a nil validator passes every value through it. The seam is unbound only when the artefact is absent, and then the two checks a release build keeps — :boundary? true and a recordable coeffect's :schema — refuse with :rf.error/schemas-artefact-missing instead of passing (per §Production builds).
Notes¶
Why Malli (CLJS reference's default validator)¶
Per claim 3 in §The four normative claims, each port picks its own default. The CLJS reference picks Malli (over clojure.spec) for these reasons:
- Data-first. Schemas are EDN data, not function calls. Inspectable, transmittable, AI-readable, queryable.
- Decomposable. Schemas compose by reference; sub-schemas can be named and reused.
- Performant. Validation is fast; schema-to-validator compilation is cheap.
- Multi-format generation. Malli generates JSON Schema, OpenAPI, type signatures, generators for property-based testing.
- Modern feature set. Open/closed maps, regex schemas, function schemas, ref support, transformers.
The :schema value is opaque to re-frame; only the registered validator function is invoked. A user wishing to use clojure.spec or another library registers the appropriate validator. Malli is the documented and supported default for the CLJS reference; other ports document their own defaults.
For the bundle-cost tradeoffs of the CLJS reference's Malli default and how to opt out, see §Bundle cost below.
Bundle cost¶
The CLJS reference's Malli mandate adds a bounded gzipped cost to a typical re-frame2 production bundle. Schema implies validation: requiring re-frame.schemas pulls Malli automatically — the facade :requires the re-frame.schemas.malli adapter, so there is no "schemas required, no Malli" posture: any schemas consumer pays the Malli surface. The scenario figures come from a representative-scenario harness — a Reagent/React counter app — compiled :advanced with :closure-defines {goog.DEBUG false}:
| Scenario | gzipped | Δ vs baseline |
|---|---|---|
| Baseline counter (no schemas, no Malli) | 91.7 KB | — |
[re-frame.schemas] required ⇒ Malli wired |
120.8 KB | +29.1 KB |
| Heavy: validate + explain + decode + transform + generator | 156.1 KB | +64.5 KB |
The budget governs the marginal cost, not an absolute bundle size. The gate measures that margin as a matched A/B — a control build requiring [re-frame.core] alone against a probe requiring [re-frame.core] plus [re-frame.schemas], compiled identically, so everything the two share cancels:
| Matched A/B build | gzipped | Δ |
|---|---|---|
schemas-bundle-control — [re-frame.core] only |
84.8 KB | — |
schemas-bundle-probe — plus [re-frame.schemas] ⇒ Malli wired |
124.7 KB | +39.9 KB |
malli.core dominates that margin but does not exhaust it: by post-Closure optimized bytes it is Malli's 120.0 KB (malli.core 88.6, malli.impl.regex 16.9, malli.error 10.5, malli.registry 2.0, malli.sci 1.3, malli.impl.util 0.7) plus borkdude.dynaload 6.6 KB, the schemas artefact's own 14.3 KB, and ~43 KB of re-frame.core / goog that the schemas path roots and a bare control DCEs away. That last term makes +39.9 KB an upper bound: an app that already roots those core paths pays them once, not twice.
The two tables above measure different things, and their numbers must never be swapped. The harness rows price schemas inside a whole Reagent/React app; the A/B rows price it against a bare core control. Transcribing one table's figures onto the other's builds gives the gate a threshold nobody measured. A probe's absolute size is dominated by cljs.core and re-frame.core, which grow with unrelated framework work and which this budget does not govern — so compare a build against its matched control, never one build's absolute figure against a representative-app row.
Validation calls are not in this cost — every validate-*! body is gated on re-frame.interop/debug-enabled? and Closure DCE eliminates the call sites in production (per §Production builds and the strict-elision contract). The cost is Malli's library code, not validation activity.
The schemas-bundle gate (
scripts/check-schemas-bundle.cjs, run bynpm run test:schemas-bundle) builds the two A/B arms above and asserts their gzipped margin two-sided: at most 44 KB, at least 20 KB. The ceiling catches a namespace off the restrict-to-dev/test list below reaching the production path. The floor is the non-vacuity guard, and the bundle-shaped echo of schema implies validation: remove the facade's adapter require and Malli leaves the probe, dropping the margin to 9.7 KB. It also asserts that the humanizer is absent from the probe — the:malli/errorkeyword literal, which onlymalli.error/humanizereaches, never survives into a production build — while the:malli.core/invalid-schemaliteral of the schema compiler every:validatecall reaches is present, which is what proves the blob inspected is the probe's. There is no absolute ceiling — a probe's absolute size is mostlycljs.coreandre-frame.core, which the perf-bundle and bundle-isolation gates own. The invariant's primary owner is the behavioural testschemas/test/re_frame/schemas_implies_validation_test.clj.
Inter-namespace DCE works; intra-namespace DCE does not. Closure prunes malli.transform, malli.generator, malli.util etc. from a typical bundle because nothing reaches them — a bare :require alone is not enough to retain a namespace, and adding one to the probe moves its margin by 0.2 KB. Inside malli.core, Closure cannot prove the data-driven dispatch internals dead, so the full namespace stays. The practical rule is: require what you need at the namespace boundary; nothing more.
malli.error is the measured residue. The adapter references malli.error/humanize only in development, as the bundle's :humanize (per §Humanize-hook), so no production code path reaches it — but the adapter still :requires the namespace, and Closure keeps any top-level def whose initialiser it cannot prove pure: malli.error's message table (default-errors, built through PersistentHashMap.fromArrays) and its negation prefix survive at 10.5 KB optimized, against 12.0 KB with the humanizer reachable. Measured on the same control: gating the humanizer saves 0.7 KB gzipped (40.6 → 39.9 KB), and deleting the :require outright would save 3.2 KB (→ 37.4 KB) and empty every malli.error literal from the probe. That last posture needs the namespace out of the build's module graph, which the library cannot do for a consumer without a build-side knob (a dev-only preload, a reader feature, an ns-alias), so the ~2.5 KB is priced inside the +39.9 KB and recorded here rather than asserted away.
Safe-in-production list — namespaces it is OK to require directly from production code paths:
malli.core— the default validator and explainer route through it; the ~24 KB cost is paid once when any code path requires it.re-frame.schemas— re-frame2's schemas artefact; ~5.6 KB on top ofmalli.core.
Restrict to dev / test / 10x tiers — namespaces that bill per-namespace gzip and should NOT be required from production code:
malli.error— humanise + path-walk; ~6 KB gzipped. Use in dev panels and tests. The schemas adapter itself reaches it only in development.malli.transform— JSON transformer + decoders; ~9 KB gzipped. An app never requires it for managed HTTP: a schema:decodecoerces throughmalli.transform/json-transformer, andre-frame.schemasalready requires the namespace on CLJS, so requiringre-frame.schemasis enough. A build that never reaches the schema decoder drops it.malli.generator— test.check integration; ~15 KB gzipped (carries test.check transitively). Restrict to test code and property-based-test panels.malli.registry— composite-registry helpers; ~3 KB gzipped (most lives inmalli.core).malli.dev,malli.dev.pretty,malli.experimental,malli.instrument,malli.json-schema,malli.swagger,malli.provider,malli.util— dev-only tooling; never bundle into production code.
Disabling validation behaviour — a nil :validate. Apps that want schemas-as-inert-data with zero runtime validation overhead install {:validate nil} via set-schema-fns! (per §Default validator and the validator-fn extension point). This is the documented hard-no-op: every validate-*! site short-circuits to true and no validate call ever runs.
;; Apps that want zero runtime validation surface (schemas are inert data)
;; The installer lives on re-frame.schemas, not the re-frame.core facade.
(schemas/set-schema-fns! {:validate nil})
nil does NOT remove Malli from the bundle. Under static CLJS compilation, requiring re-frame.schemas loads the Malli adapter at module-init regardless of the runtime validator value, so Malli's body is in the bundle. The nil opt-out disables validation behaviour, not Malli's bundle cost. The only Malli-free posture on the CLJS reference is not requiring the schemas artefact at all — an app that needs neither reg-app-schema nor :schema metadata pays nothing for schemas or Malli (verified by the counter bundle-isolation gate). This is a deliberate tradeoff: making "schema implies validation" airtight is worth the bounded Malli surface for the apps that actually use schemas.
Boundary-validation path — keep Malli on the production path for untrusted-source events only. Apps that want Malli's bundle but only run validation at system boundaries declare :boundary? true (per §Production builds) on specific event handlers. The step-1 validation site runs the registered validator against the handler's :schema regardless of the global elision flag — boundary handlers validate every payload while 99% of code has zero validation overhead.
Reframing the "Malli is hard to DCE" intuition. The intuition is half-right. Closure cannot DCE inside malli.core (the dynamic-dispatch internals defeat dataflow analysis). But Closure CAN DCE between Malli namespaces (typical apps carry malli.core, plus the malli.error residue described above — the humanize hook reaches it only in development — not the transform / generator subset), and the mandate-cost is bounded by what those namespaces weigh gzipped. The heavy-decode scenario (which pulls malli.transform + malli.generator on top) is worst-case; the typical schemas-consumer cost is a fraction of that. The cost is paid by any app that requires the schemas artefact (schema implies validation); the way to pay zero is to not require the schemas artefact at all.
What schemas don't do¶
- They don't enforce non-shape invariants. Schemas describe shapes (this is a string of length ≥ 8; this is a vector of TodoItems). Higher-level invariants (this user's email matches their account; this request's signature is valid) live in handlers, not schemas.
- They don't replace tests. Schemas catch shape violations; tests catch behavioural correctness. Both are needed.
- They don't make
app-dbrigid. Open-map schemas are the default; teams opt into closed-map semantics where they want strict typo-prevention.
Open questions¶
SA-4 classification. Per SPEC-AUTHORING §SA-4: "Schema-driven generative tests" is a post-v1, untracked note — it is a cross-link into the property-based-testing pattern whose home is 008 §Property-based testing integration; the schema-driven variant is one shape of that pattern, not a separate deferred surface, and no tracking bead is filed yet (so it is not
:post-v1 tracked, which requires arf2-<id>). "Boundary-validation interceptor naming" is resolved (see §Resolved decisions) and lives under## Resolved decisionsbelow. "Schema versioning" is a post-v1, untracked note — additive:versionkey with a concrete reconsideration trigger (see §Schema versioning below); no bead filed yet.
Schema-driven generative tests (post-v1)¶
Most schema libraries ship generators that produce values matching a schema (Malli on CLJS, Zod with faker integrations on TS, Hypothesis on Python, etc.). A natural pattern: "for every event with a :schema, generate inputs and run the handler against a fixture frame, asserting app-db schemas hold."
This is one shape of the property-based-testing pattern, not a second deferred surface — its home is 008 §Property-based testing integration, which owns the whole property-based pattern (schema-driven generation is the case where the generators come from :schema registrations rather than hand-written). The forward-pointer lives there, so the two docs do not defer to each other; 008 is the single home, this section is the schemas-side cross-link into it. No tracking bead is filed yet.
Schema versioning (post-v1)¶
Apps evolve; app-db shapes evolve; schemas evolve. Whether re-frame2 ships a versioning convention (e.g., (reg-app-schema [:user] {:version 3} UserSchema)) for schema-aware migration tooling is post-v1. Untracked note — no bead filed yet; the reconsideration trigger below is what files one.
Post-v1 Tracking¶
- Foundation in v1.
reg-app-schemaalready accepts an opts map (per §The four normative claims); adding a:version <pos-int>key is additive — current registrations stay valid. - Scope deferred. The convention itself (canonical key name, default semantics when absent, comparison rule on hot-reload, migration-helper signature) is the post-v1 design surface. v1 ships the validator-pluggability primitive without locking the versioning grammar.
- Reconsideration trigger. Either (a) a concrete app reports schema-evolution bugs that the hot-reload
:rf.schema/violationtrace (per §Schema migration on hot-reload) cannot diagnose, or (b) a tool (story, xray, re-frame2-pair) needs to assert a known shape-revision across runs. - Out of scope when it lands. App-level migration runner (sequenced
db -> db'transforms keyed on version delta) is library territory, not framework — outside whatever bead the trigger files.
Resolved decisions¶
Boundary-validation interceptor naming¶
Decision: :boundary? true (a reg-event registration-metadata key), not an interceptor id. No interceptor-id spelling fits, because the mechanism is not an interceptor: it validates the original event vector at step 1, before the chain runs, and an interceptor would instead validate at its chain position, pass the context through untouched in dev, and be removable by :interceptor-overrides in production only (per §Production builds). "Boundary" names where the check applies; strict would not say where, and always would misstate an opt-in per-handler check as an always-on global. :boundary? follows the ?-boolean precedent already in the registration vocabulary (:recordable?, :provided?, :large?), and is preferred to the longer :at-boundary?.
Schema migration on hot-reload¶
When a sub-path schema changes during dev (file save re-evaluates reg-app-schema with a different schema for the same path), the live app-db value at that path may now violate the new schema; so may a value already present when a path is registered for the first time. The runtime emits a :rf.schema/violation trace event (:op-type :warning) so dev panels highlight the stale slice; the live app continues running. The trace event's :tags carry :path, :pre-reload-schema, :post-reload-schema, :mismatching-value, and :frame — enumerated authoritatively in Spec 009 §Error event catalogue (row :rf.schema/violation). Default recovery is :logged-and-skipped — app-db is not auto-cleared or rewound. An event that leaves the stale slice as installed commits normally, because a commit checks only the registered slices it writes (§When schemas are checked). An event that writes the slice is checked against the new schema, and is rejected as itself until the value it writes conforms.
"Changed" compares two DECLARATIONS; it is not a truthiness test on the stored token. A path counts as previously registered when the registry held an entry for it — this document's opening statement makes schema values opaque to re-frame's validation, so nil and false are legitimate tokens a substituted validator may interpret, and a prior registration carrying a falsey token is a prior registration like any other. Inferring "was registered" from the stored token's truthiness makes a registered nil indistinguishable from an absent declaration, so an unchanged re-eval of it would be checked as a first registration; the same declaration-presence rule already governs the validation seams. The first registration of a path checks a live value present at the path, in each frame that resolves the registration, and emits the same trace when it fails, without :pre-reload-schema, because there is no prior declaration. A path absent from the live app-db emits nothing on any registration, and a re-eval whose token is unchanged — nil to nil included — is a silent no-op.
Pluggable validator and implementation-defined default¶
The four normative claims in §The four normative claims are the portable contract: apps register via reg-app-schema + :schema; validation is pluggable via set-schema-fns!; the default is implementation-defined; dependency-absent behaviour is implementation-defined with a recommended soft-pass.
The CLJS reference's expression of these claims: (schemas/set-schema-fns! {:validate validate-fn}), (schemas/set-schema-fns! {:explain explain-fn}), and the whole-port form (schemas/set-schema-fns! {:validate ... :explain ... :print ...}) all live on re-frame.schemas — they are NOT re-exported into the re-frame.core front porch (only the reg-app-schema / reg-app-schemas registration macros are; per API.md §Schemas and the §Conventions front-porch boundary). The CLJS reference's chosen default is Malli's validate / explain, which the schemas artefact depends on, so there is no Malli-absent state; set-schema-fns! installing a nil :validate is the hard no-op. Other ports document their own defaults in their READMEs. The schemas mandate at the framework level (every reg-* may attach :schema; reg-app-schema registers path schemas) is independent of which validator is registered.