Spec 013 — Flows¶
Status: Drafting. v1-required. Builds on the registration grammar in 001-Registration, the drain in 002-Frames §Run-to-completion, and the reserved-namespace policy in Conventions.
The minimum claim: flows are registered, runtime-toggleable computed-state declarations that materialise their output into
app-db. They are the v2 incarnation of v1'son-changesinterceptor — same compute-on-input-change semantics — but registered in the runtime (not on individual events) and toggleable via two reserved fx-ids.Restraint. Flows are a convenience for a small number of small use-cases. They are not a replacement for subscriptions, not a new dataflow paradigm, not a substitute for state machines, and not the default for derived state. The architecture's main load-bearing pieces are events, subs, machines, and effects; flows sit alongside them as a focused tool for a narrow set of problems. When in doubt, use a subscription — flows pay an
app-dbwrite per recomputation and add a small piece of registered runtime; that cost is only worthwhile when the reasons in §When (and when not) to use a flow below apply.
:rf.flow/*is the internal-effect cousin of the managed external effects — per Managed-Effects, the surface MUST satisfy the eight properties applied to derived computation (effect-as-data via the registration map, framework-owned scheduler, structured failure taxonomy under:rf.flow/*, trace-bus observability,:sensitive?/:large?composition on flow outputs, runtime-toggleable retry / abort / teardown via:rf.fx/reg-flow/:rf.fx/clear-flow, in-flight flow registry, per-frame scoping).
Abstract¶
A flow is a registered rule that says: "when these app-db paths change, run this pure function and write the result to that app-db path." Flows are evaluated automatically on every event, immediately after the handler's interceptor chain (as the outermost :after interceptor — after the rest of the :after chain has reshaped the db effect, and before the :db effect installs), in topological order over their static input/output dependency graph. The flow walk transforms the handler's pending :db effect; see §Drain integration.
Flows differ from subscriptions in where the value lives. A sub's value lives in the per-frame sub-cache and is consumed by views. A flow's value lives in app-db at a known path, where it survives SSR / hydration / time-travel revert, is visible in the app-db inspector, can be read by downstream event handlers and other flows, and is covered by registered schemas. When the derived value is part of the application's state (as opposed to part of a view's render input), use a flow.
Flows are frame-scoped. A flow belongs to one frame: its registration, evaluation, output app-db path, and undo / time-travel boundaries are all frame-local. The same flow id can register against two different frames with two different :output functions and two different :path slots; clearing the flow on one frame leaves the other untouched. See §Frame-scoping for the rationale and API.
When (and when not) to use a flow¶
Flows are the right tool when all of the following apply:
- The derived value is part of the application's state, not just a view-render input.
- Other event handlers, machine actions, or schemas need to read the value as plain
app-dbdata. - The value should survive SSR hydration, time-travel revert, or app-db serialisation.
- The derivation is stable enough to be worth registering — it isn't a one-off computation inside a single handler.
Flows are the wrong tool when:
- The derived value is consumed only by views → use a subscription (lighter, sub-cache native, no
app-dbwrite). - The derivation has discrete states or lifecycle (entry/exit, transitions) → use a state machine (per 005).
- The value is only relevant inside one event handler → just compute it inline; no registration needed.
- "I want a reactive value somewhere" → almost always a sub.
The expected v1 deployment volume is small — a typical app has dozens of subscriptions and one to perhaps a handful of flows. If a codebase grows tens of flows, that is a smell that subscriptions or machines are being misused.
Why flows¶
Three use cases the reference implementation has hit repeatedly:
- Materialised computed state.
:areafrom:width × :height.:totalfrom:items.:can-submit?from form validity, network state, and feature flags. The derived value is part of the app's state and downstream code reads it as plainapp-db. - State that survives the wire. SSR hydration carries
app-db; computed values written by flows arrive on the client without re-computation. Sub-cache contents do not survive hydration. - Toggleable derivation. A wizard step, a feature gate, an "advanced mode" — a derivation that should only run while a feature is engaged. v1's
on-changesinterceptor cannot do this because interceptors are wired into specific events at registration time. Flows are runtime-registered and runtime-clearable.
The registration shape¶
(rf/reg-flow
{:id :rectangle/area
:inputs [[:width] [:height]] ;; vector of app-db paths
:output (fn [w h] (* w h)) ;; pure: (in-1, in-2, ...) → output
:path [:area] ;; where the result is written
:doc "Rectangle area computed from :width and :height."})
Required keys:
| Key | Meaning |
|---|---|
:id |
Unique flow identifier. Per Conventions §Feature-modularity prefix convention, namespace by feature. |
:inputs |
Vector of app-db paths. The order matches positional args to :output. |
:output |
Pure function. Receives input values positionally. Must be deterministic (same inputs → same output). |
:path |
App-db path to write the output to. |
Optional keys (per the 001-Registration §Registration grammar standard):
| Key | Meaning |
|---|---|
:doc |
One-sentence what-and-why; surfaces in tooling. |
:schema |
Malli schema for the output value, validated on every recompute in dev (see §Flow output validation). |
:ns, :line, :file |
Source coordinates (auto-captured by the registration macro per 001 §Source-coordinate capture). |
:inputs is a positional vector matching on-changes. The vector form is short for the common 2–4-input case and the destructure-by-position is straightforward. (A map-keyed alternative was considered — see §Open questions.)
reg-flow returns the flow's :id — the primary id under which the flow registers in the :flow kind — per the family-wide reg-* return-value convention. The id is carried by the flow-map rather than as a separate positional arg, but the return-value contract is the same as the rest of the reg-* family.
reg-flow accepts an optional second argument carrying a :frame opt — the frame the flow registers against. Default is (current-frame-id) (per 002 §How :frame gets attached, usually :rf/default unless the call sits inside a with-frame form or under a frame-providing context):
(rf/reg-flow flow) ;; defaults frame to (current-frame-id)
(rf/reg-flow flow {:frame :scratch}) ;; explicit frame
clear-flow mirrors the shape:
Frame-scoping¶
Flows are frame-scoped: registration, evaluation, and clear-flow's dissoc-in all belong to one frame. The runtime registry shape is:
Three consequences follow:
- Per-frame undo / time-travel boundaries. Time-travel is a frame-local primitive (per 002 §Frames). A flow's
:pathwrite is part of the owning frame'sapp-dbhistory; reverting frame:leftdoes not disturb flow outputs in frame:right. - Same flow-id, multiple frames, independent definitions. Registering
:computeagainst:leftwith(fn [x] (* 2 x))and against:rightwith(fn [x] (* 100 x))produces two independent flows. Each frame's flow walk (run-flows-on-db) visits only its own slot of the registry. clear-flowis frame-local.(clear-flow :compute {:frame :left})removes the flow's definition from frame:leftanddissoc-ins its:pathfrom:left'sapp-dbonly. Frame:right's:computeand its output keep working. The shared:flowregistrar slot is only unregistered when the last frame holding the id releases it (so hot-reload tracking survives multi-frame setups).
Registrar slot semantics under multi-frame registration. The :flow registrar kind (per 001-Registration §Registration grammar) is keyed by flow-id only — the same flow id registered against multiple frames shares one registrar slot. The per-frame runtime registry {frame-id {flow-id flow-map}} is the source of truth for evaluation; the registrar slot is metadata used for hot-reload tracking and tooling introspection. When the same flow id is registered against two frames with different :output fns and :path slots, the registrar slot carries the most-recently-registered frame's flow-map with :frame frame-id stamped into the metadata. Callers reading the slot via the registrar (e.g. a Xray flow panel that wants to enumerate "all flows in the runtime") observe last-registration-wins on the metadata view while every frame's flow walk (run-flows-on-db) continues to evaluate against its own slot of the runtime registry unaffected.
This asymmetry is intentional for v1: the runtime correctness (each frame's flows compute independently) is the load-bearing property; the registrar metadata is a tooling convenience. A future spec revision MAY introduce a frame-aware query surface (flow-meta / (flows {:frame ...})) or migrate the registrar slot to a frame-indexed shape, but doing so today would inflate the registrar API surface for a use case (tools enumerating cross-frame flows) that has not surfaced in v1. Apps targeting multi-tenant frames with shared flow ids and per-frame-different definitions should read the runtime registry through the public snapshot accessor re-frame.flows/flows-snapshot (returning the {frame-id {flow-id flow-map}} shape), not the private @re-frame.flows/flows atom and not the registrar slot, for full per-frame discovery. The flows-snapshot accessor is the encapsulation boundary: the per-frame registry atom is private (the facade re-exports only the read accessors flows-snapshot / last-inputs-snapshot and the reset fns), so consumers depending on the snapshot survive any future change to the atom's internal representation.
Frame defaulting matches the rest of the API: a bare (reg-flow flow) resolves the frame via (current-frame-id), picking up with-frame bindings or falling through to :rf/default. Tests and per-tenant runtimes that need an explicit frame pass {:frame ...} as the second arg.
Frame-destroy teardown¶
Flows are frame-scoped, so destroy-frame! is the boundary at which every per-frame piece of flow state MUST clear. This is a normative requirement — the frame-isolation contract from 002 §Destroy is only honoured if flow registrations, the dirty-check last-inputs cache, and any :flow registrar slot whose last owning frame was the destroyed one all release in lockstep with the frame. Without lockstep teardown a long-running JVM SSR host (per-request frame churn), a pair-tool time-travel cycle, or any make-frame ephemeral usage leaks flow definitions and cached input vectors indefinitely.
Three teardown invariants apply on (destroy-frame! frame-id):
- Per-frame flow registry.
(get @flows frame-id)clears in full — every flow registered againstframe-idis dropped. Sibling frames' slots are unaffected (per §Frame-scoping). - Dirty-check cache. Every
last-inputs[flow-id][frame-id]row for the destroyed frame is dissoc'd. The wholelast-inputs[flow-id]key is dropped when no other frame still holds an entry. Sibling frames' rows for the same flow id are preserved (a flow id registered against frames:leftand:right, with frame:leftdestroyed, keepslast-inputs[flow-id][:right]intact). :flowregistrar slot. The cross-kind registrar entry for each flow id the destroyed frame owned isunregister!'d iff no surviving frame still registers that id. When a sibling frame still holds the same flow id, the registrar slot survives (other frames need it for hot-reload tracking).
Teardown is idempotent against a frame the registry never recorded — a destroy-frame! on a freshly-reg-frame'd frame with no flows ever registered against it leaves the flow registry, last-inputs, and registrar entries unchanged.
The teardown contract is symmetric with the machines artefact's :machines/teardown-on-frame-destroy! hook and the schemas artefact's :schemas/on-frame-destroyed! hook — every per-feature artefact that holds frame-scoped state hangs its cleanup off the single normative destroy-frame! teardown boundary documented at 002 §Destroy. A new feature artefact MUST add its hook to the destroy cascade; a feature that holds frame-scoped state without one leaks on every destroy-frame!.
Drain integration¶
Flow evaluation happens immediately after the event handler's interceptor chain, transforming the pending :db effect — before the :db effect installs into app-db and before :fx walks. The flow walk runs the registered flows over the chain's final :db effect value and rewrites that pending effect; it does NOT mutate the already-installed app-db. Per 002 §Drain-loop pseudocode, the runtime realises this as the outermost :after interceptor — the flow transform fires after the rest of the :after chain has finished reshaping the :db effect into the complete app-db form:
process-event! (with flows as the outermost :after):
1. Resolve handler.
2. Run interceptor chain — :before steps in order, then handler,
then :after steps in REVERSE. The framework's flow-transform
:after is the OUTERMOST :after, so it fires LAST — after every
other :after (incl. a `(path :slice)` interceptor's :after that
splices the handler's slice back into the FULL db):
run-flows-on-db ctx ← OUTERMOST :after
;; (the reference fn is `run-flows-on-db`, which takes the pending
;; db VALUE; shown here against `ctx` for the surrounding flow)
pending-db ← (:db (:effects ctx)) ;; the FULL, fully-reshaped
;; :db effect, or the current
;; app-db value when no :db
;; effect was produced
Walk THIS FRAME'S registered flows in topologically-
sorted order — i.e. (get @flows frame-id) only;
sibling frames' flows are not visited.
For each flow in this frame's slot:
new-inputs ← read input paths from pending-db
if new-inputs ≠ last-inputs[[frame-id flow-id]]:
new-output ← (apply :output new-inputs) ;; MAY throw
pending-db ← (assoc-in pending-db (:path flow) new-output)
last-inputs[[frame-id flow-id]] ← new-inputs
(:effects ctx) ← assoc :db pending-db ;; flow-augmented :db effect
;; FAILURE — a flow's :output threw (atomicity contract):
;; DISCARD the pending :db effect (drop it from (:effects ctx))
;; and stash the throw under :rf/flow-error. The event ABORTS:
;; the single deferred install at step 3 installs nothing, so
;; no :rf.event/db-changed fires and :fx is skipped. NO partial
;; commit — neither the handler's :db nor any prior flow's write
;; lands. (See §Failure semantics.)
3. Install :db (the flow-augmented value) — ONLY when a :db effect is
present. Sub-cache invalidates. `:rf.event/db-changed` fires HERE —
after flows (per [009 §Canonical per-event trace sequence](009-Instrumentation.md#canonical-per-event-trace-sequence)).
On any pre-install throw (handler / interceptor :after / flow), no
:db effect is present (the flow-throw path discarded it; a handler /
interceptor throw never produced one), so this step installs nothing
and emits no :rf.event/db-changed.
4. Walk :fx in source order — every `:fx` entry reads the
flow-augmented app-db. SKIPPED when the event aborted (any
pre-install throw). :fx is the only post-install stage; an fx throw
does NOT wind back the installed :db.
Five properties this gives:
- Flows run on the FULL, fully-reshaped db. Because the flow transform is the outermost
:after, it runs after every db-reshaping:afterin the chain — in particular after a(path :slice)interceptor splices the handler's slice back into the completeapp-db. Flows read full-app-db:inputspaths, so they MUST see the reshaped full db; running them outermost is what guarantees that. (Running flows innermost would expose them to the unspliced path slice and mis-read their inputs.) :fxentries see flow outputs. An:fxentry that readsapp-dbafter install sees flow-computed values (flows transform the effect at step 2; install at step 3;:fxat step 4). This is what makes[:dispatch [:react-to-area-change]]work cleanly. (Preserved from the prior design.)- Single pass per event. Each flow runs at most once per drain. The topological order ensures multi-layer flows settle in one walk.
- Run-to-completion is preserved, with exactly one
app-dbwrite. The flow transform rewrites the pending:dbeffect; the single:dbinstall at step 3 is the onlyapp-dbwrite the cascade makes — the flow output is part of that one install, not a second mutation after it. Views never observe an intermediate state. - Frame isolation. An event dispatched on frame
:leftonly walks flows registered against:left. Flows on frame:rightare dormant from:left's perspective — they walk only when:right's drain runs its own flow transform. This is what makes multi-tenant frames safe to colocate without cross-talk in derived state.
Why a pending-:db-effect transform and not a post-install drain step. The prior design ran flows as a post-commit step: the full interceptor chain ran, :db committed to app-db, then run-flows! mutated the live app-db, then :fx walked. That split the app-db write into two mutations (handler commit, then a separate flow mutation), made the cascade install the db twice, and fired the :rf.event/db-changed trace before flows — so the trace did not reflect the flow output and tools could not place flows on the cascade timeline correctly. Moving flows to transform the pending :db effect (as the outermost :after) makes (a) the cascade perform exactly one app-db install — of the flow-augmented value, (b) the :rf.event/db-changed trace reflect the flow-augmented db and fire after :rf.flow/computed, and (c) the whole flow position observable on the trace stream (per 009 §Canonical per-event trace sequence). The :fx-sees-flow-output guarantee is preserved unchanged.
The (t1, t2) pending-:db snapshot pair — dev-only observability hook. The same outermost flows-after-interceptor that runs the flow transform also stamps two trace events bracketing the walk: :rf.event/db-pending (t1) BEFORE the walk and :rf.event/db-pending-post-flow (t2) AFTER (only when flows changed the value). Both carry the FULL :db value under :tags :rf.event/db — same posture as :rf.event/fx on :rf.fx/do-fx, no diff, no DEBUG gate; PDS structural sharing keeps the cost pointer-sized and the day8/de-dupe wire layer collapses repeated subtrees on egress. The pair lets the Xray Handler panel render the handler-returned :db value AND the t1→t2 flow reshape without a framework-precomputed diff. No production cost: both emits sit inside the shared interop/debug-enabled? gate so CLJS :advanced + goog.DEBUG=false DCEs the whole surface. See 009 §Canonical per-event trace sequence for the position in the trace stream.
Position note — outermost :after, not innermost. Conceptually flows run "right after the handler"; mechanically they run as the outermost :after so they observe the fully-reshaped :db effect. The distinction matters only when the chain contains a db-reshaping :after (the path std-interceptor): flows must run after that reshape. A consequence is that user :after interceptors run before the flow transform and therefore see the handler's pre-flow :db effect, not the flow-augmented one — observational :after interceptors that need flow outputs should read them from app-db (post-install) via a sub or a follow-up event, exactly as :fx does. (Reshaping the db effect from within an arbitrary user :after and then re-running flows would require interleaving the flow walk through the whole :after chain, which is neither necessary for any real use case nor compatible with the single-pass guarantee.)
Trace stream ordering on a flow throw¶
When a flow's :output fn throws during the flow-transform :after (step 2 of the drain integration pseudocode above), the runtime emits a strict trace sequence — observable contract for off-box monitors, Xray, Story, and any consumer that lifts a flow failure off the trace stream. A flow throw is a pre-install throw: the event ABORTS before the :db install, so the failure stream carries NO :rf.event/db-changed (per §Failure semantics — the atomicity contract). A conformant port MUST emit these events in this order:
:rf.flow/computedfor each flow that successfully computed before the throwing one — fires per prior flow as it rewrites the pending:dbeffect. (See §Failure semantics for the per-flow detail; these may be absent when the first flow throws.) Note: these traces fire even though the prior flows' writes are ultimately discarded by the abort — the trace records that the:outputran, not that the write was committed.:rf.flow/failed— the per-flow failure trace for the throwing flow, carrying:flow-id,:ex, and the elided:inputsread just before the throw. Rides the dev-only trace surface; DCEs in CLJS production.:rf.error/flow-eval-exception— the cascade-level error, emitted onto the always-on production error-emit substrate per §Failure semantics. Carries:where :flow-eval(distinguishing this path from:rf.error/handler-exception), the originating event under:rf.event/v, and:flow-idattribution stamped from the throwing flow's wrappedex-data. Attribution is the flow id alone — there is no real flow value to carry, so the contract carries:flow-idand nothing more. The dev-only trace surface emits the same op concurrently; the production-substrate path survives CLJS:advanced+goog.DEBUG=falseelision.- NO
:rf.event/db-changed. The event aborted before the install — neither the handler's:dbnor any prior flow's write committed (no partial commit).app-dbis unchanged, and the trace stream carries no:rf.event/db-changedfor this event. This is the same abort signature every other pre-install throw (cofx / handler / interceptor:after) produces. :fxis skipped — no:rf.fx/handledfrom this drain. The event aborted (per §Failure semantics); no:fxentry runs, no:dispatch-issued child events queue. The drain proceeds to the next event in the router queue on its normal cycle.
The contract is the ordering AND the gap — consumers can rely on "the flow failure (:rf.flow/failed → :rf.error/flow-eval-exception) fires, and NO :rf.event/db-changed follows: the event aborted, app-db is unchanged, and no :fx of this drain reached the outside world." Cascading work that would have run via :fx re-attempts naturally on a later drain — once a drain completes without the flow throwing.
Topological sort and cycle detection¶
Flows form a static dependency graph derivable from their :path and :inputs declarations. The graph is per-frame — flows in different frames cannot depend on each other (their inputs read different app-dbs). Each frame's topsort is computed independently over (get @flows frame-id).
Dependency rule. Flow B depends on flow A iff A's :path and any of B's :inputs share a path prefix in either direction:
- Exact match:
A.path = [:foo],B.inputs = [[:foo]]— B reads exactly what A writes. - A's path is a prefix of B's input:
A.path = [:foo],B.inputs = [[:foo :bar]]— B reads inside A's value. - B's input is a prefix of A's path:
A.path = [:foo :bar],B.inputs = [[:foo]]— A's write is part of B's input map.
The runtime topologically sorts the registry by this dependency relation. The sort is not memoised in v1 — the per-frame flow map is tiny (a handful of nodes) and Kahn's algorithm over it is cheaper than the bookkeeping a memo would need. An earlier sketch carried a memoised topsort with explicit invalidation on every reg-flow / clear-flow; the memo was removed once measurement confirmed the unmemoised call is the cheapest correct option at the per-frame node counts v1 targets. Implementations that observe a real bottleneck in topsort cost MAY add a core.memoize-style cache keyed on the flow-registry identity, but the contract is just: deterministic order over the dependency graph each drain.
Cycle detection. If A depends on B and B depends on A (any indirection), reg-flow throws :rf.error/flow-cycle at registration time. The thrown ex-info's ex-data carries :cycle — an ordered vector of flow ids with a closing repeat that names the offending chain (e.g. [:a :b :a] for the two-flow cycle :a → :b → :a). The error fires before any snapshot is created — caught at registration, not at runtime.
;; This will throw at registration:
(rf/reg-flow {:id :a :inputs [[:b]] :output identity :path [:a]})
(rf/reg-flow {:id :b :inputs [[:a]] :output identity :path [:b]})
;; → ex-info ":rf.error/flow-cycle" {:cycle [:a :b :a]}
The closing repeat is the contract: tools rendering the cycle (e.g. Xray) display the path verbatim. For an n-flow cycle the :cycle vector has (inc n) elements, with (first cycle) = (last cycle). The starting node is implementation-defined (deterministic but unspecified) — multiple cycles can yield any one of them as the reported chain.
Cycles can also form during flow registration if the new flow completes a cycle that was incomplete before it was registered. The detection runs every reg-flow call.
Dirty-check semantics¶
A flow recomputes only when its inputs change by =-equality since its last evaluation:
new-inputs ← (mapv #(get-in app-db %) (:inputs flow))
if new-inputs ≠ last-inputs[[frame-id flow-id]]:
recompute and write
The last-inputs table is keyed by [frame-id flow-id] so the same flow id registered against two frames maintains two independent dirty-check windows.
Three implications:
- No-op
app-dbwrites don't trigger. A handler that writes the same value back to:widthdoes not re-fire flows that depend on:width. - Path-overlap is sufficient, not necessary, for re-firing. A flow whose inputs sit at
[:user :profile :name]does not re-fire when an unrelated path like[:cart :items]changes. The dirty-check is per-flow, not per-app-db-change. - First evaluation always fires. A newly-registered flow's
last-inputsis uninitialised; its first walk recomputes unconditionally and produces the initial output value.
Failure semantics¶
The event-handling pipeline is atomic up to and including the :db install. The :db install is the single, deferred, all-or-nothing commit boundary. ANY throw before it — in cofx, the handler body, an interceptor :after, or the flow transform — aborts the entire event: the pending :db effect does NOT install, app-db is left unchanged, no :rf.event/db-changed is emitted, and no :fx run. A flow throw is just one of these pre-install throws, and behaves identically to every other. :fx is the only post-install stage; an fx throw surfaces an error but does NOT wind back the installed :db (its side effects — http / nav / dispatch — may already have fired and are irreversible).
When a flow's :output fn throws during the flow-transform :after, the runtime applies these rules atomically:
- No install —
app-dbis unchanged. There is no partial commit. The pending:dbeffect (the handler's write plus any prior successful flows' writes) is discarded: the flow-transform:afterdrops the:dbeffect from the chain context, so the single deferred install installs nothing. Neither the handler's:dbnor any earlier flow's output lands. The atomicity is free: because install was already deferred to one write, "wind back on a pre-install throw" is just "don't perform the one write" — no rollback machinery. - The failing flow's own output is not written, and
last-inputsis rolled back. The exception happened during:output; there is no usable new-output. The whole drain's dirty-check bookkeeping rolls back too: thelast-inputssnapshot taken before the walk is restored, so every flow — prior-successful and failing alike — re-attempts on the next drain. (Without this, a prior flow whoselast-inputsadvanced would wrongly suppress its recompute next drain even though its output never reachedapp-db— silently losing the write.) The rollback is scoped to the draining frame's own dirty-check bookkeeping. Onlylast-inputsrows for[frame-id …](the frame being drained) are snapshotted and restored — a sibling frame draining concurrently on another thread (frames have independent drain-locks per 002 §Rules rule 1; there is no global cross-frame serialization) has its dirty-check rows left untouched. A throwing-flow rollback can no more revert a sibling frame's just-advancedlast-inputsthan it can revert a sibling frame'sapp-db; the per-frame dirty-check window from §Dirty-check semantics holds under concurrent drains by construction. - The cascade halts. Downstream flows scheduled later in topo order do NOT run on this drain. They re-attempt naturally on a later drain — one that completes without the flow throwing. The
:dbinstall and the:fxwalk do NOT run for this drain. - The exception surfaces at the router as
:rf.error/flow-eval-exception(per 009 §Error contract). The cascade-level error is emitted onto the always-on production error-emit substrate (009 §Production builds) — the per-frame:on-errorpolicy fn fires, everyregister-error-listener!callback fires, and both fan-out paths are mutually isolated. The substrate is NOT gated byre-frame.interop/debug-enabled?, so:rf.error/flow-eval-exceptionsurvives CLJS:advanced+goog.DEBUG=falseelision: a flow-eval failure in a production build reaches every registered off-box error monitor (Sentry / Honeybadger / Rollbar / hosted observability) at full fidelity. The per-flow:rf.flow/failedtrace event ALSO fires first with the flow-attributed detail, but that trace rides the dev-only trace surface and DCEs in production — production attribution is preserved on the always-on path via the:flow-idslot stamped onto the cascade-level error's:tags. There is no real flow value to carry, so the prod-surviving attribution tag is:flow-idalone.
Worked example. Three flows in topo order — :A, :B, :C. Inputs change for all three. :B throws. After the drain:
app-dbis unchanged —:A's output is NOT written (rule 1, no partial commit), the handler's:dbdid NOT land,:B's:pathis unchanged,:Cdid not run.- All
last-inputsare unchanged from before the drain (rule 2)::A's advance was rolled back,:B's never advanced (it threw),:Cnever ran. All three re-attempt next drain. - Two flow traces fired in order:
:rf.flow/computedfor:A(it ran — the trace records the:outputcall, not a committed write), then:rf.flow/failedfor:B. Then the router emitted:rf.error/flow-eval-exception(rule 4). No:rf.event/db-changedfired (rule 1 — the event aborted before install; per §Trace stream ordering on a flow throw).
Rationale. The :db install is the atomic commit boundary, and atomicity must be uniform: a flow throw can no more leave a half-committed app-db than a handler throw or an interceptor-:after throw can. The earlier "preserve prior-flow writes" design committed a partial app-db on a flow throw — but that made flow throws behave differently from every other pre-install throw, and committed state from an event that the runtime simultaneously reported as failed. Discarding the whole pending write keeps one invariant true everywhere: an event either commits in full or not at all. Work that would have completed re-attempts on a later, clean drain; nothing half-done is ever observable in app-db.
Why this asymmetry? — :db is atomic, :fx is best-effort¶
The atomicity contract is asymmetric across the commit boundary: a pre-install throw aborts cleanly (no :db install, no :fx, app-db unchanged), but a post-install throw inside :fx does not wind app-db back — the install already happened, and any fx that already fired (an HTTP POST, a :dispatch, a :local-storage-set, a navigation) is not rolled back. A natural question: shouldn't the commit boundary sit past :fx, so the whole event is all-or-nothing? The answer rests on three load-bearing constraints.
-
Most
:fxare irreversible by construction.:http-xhrioPOST mutates a server.:dispatchof a downstream event enters the router queue and may already have settled by the time a sibling fx throws.:local-storage-setwrites through to disk. DOM mutations have been observed. Navigation has fired. The set of side effects that motivates putting work in:fxin the first place IS the set that cannot be undone after the fact. If the runtime ran the fx walk and then skipped the:dbinstall on a later throw, the world's state (the server, the URL bar, local storage, the dispatched-children queue) andapp-dbwould diverge — strictly worse than today, where both reflect what actually happened, even when one fx blew up. "All-or-nothing for:fx" is unimplementable for fx whose effects, by design, escape the runtime. -
Knowing "all
:fxsucceeded" requires going async — which breaks composition. Most:fxreturn immediately and report success/failure asynchronously (:http-xhriofires the request and resolves later via:on-success/:on-failure;:dispatchqueues an event whose drain hasn't run yet; a:dispatch-latertimer hasn't fired). To gate the:dbinstall on "every fx succeeded," the event itself would have to become asynchronous — which breaksdispatch-sync, breaks the run-to-completion event-queue composition (per 002 §Run-to-completion dispatch), and would force every fx-id to declare sync-vs-async success semantics so the runtime knows when to commit. The whole programming model collapses into a distributed-transaction coordinator. -
Three principled answers exist; re-frame chose the cheap one. This is the classic distributed-transaction problem: a local commit (
app-db) needs to compose with N external commits (:fx) that the local node doesn't fully control. The literature offers three answers — (a) optimistic local commit + compensating-action sagas (commitapp-dbimmediately; on async failure, dispatch a compensating event that reverts the slice and surfaces the failure to the user); (b) two-phase commit (every external fx exposes synchronousprepare/commitphases; the framework runsprepareon all of them, thencommiton all, rolling back on anypreparefailure — feasible only where the external system supports it, which most HTTP / DOM / navigation surfaces do not); (c) accept the asymmetry (commitapp-dbat the boundary;:fxis best-effort; surface failures as ordinary error events; let application code compose compensating sagas where rollback semantics matter). re-frame2 chose (c): it is simple, predictable, sync-friendly, and composes with the trace bus + the always-on error-emit substrate per 009 §Production builds without inventing a transaction coordinator.
Escape valve — application-level sagas. Apps that do need rollback semantics across an :fx boundary get them at the application layer, not from the framework. The pattern: an event handler writes its optimistic state into app-db (the local commit), dispatches the external fx with :on-failure pointing at a compensating event that reverts the slice and surfaces the failure to the UI, and (optionally) tracks the in-flight epoch in a state machine (Spec 005) so the UI can render "saving…" / "saved" / "save failed — reverting" states explicitly. Two pieces compose: re-frame2's atomic :db write gives the local commit; the framework's :on-failure reply addressing on managed fxs (per Managed-Effects §3 Structured failure taxonomy, 014 §Failure taxonomy) gives the compensating-event seam. The framework provides the primitives; the saga IS the application.
Worked example. The favorite-toggle in examples/reagent/realworld/favorites.cljs — :article/toggle-favorite optimistically flips :favorited and bumps :favoritesCount in app-db, dispatches :rf.http/managed with :on-failure [:article/favorite-rollback slug prior], and the rollback handler restores the prior slice if the POST/DELETE fails. The examples/reagent/realworld/ README catalogues three further compensating-event patterns (favorite toggle, comment delete, follow/unfollow). See README §Optimistic updates and favorites_test.cljs for the headless test that exercises the rollback path.
Flow tracing¶
Every flow lifecycle event emits a structured trace event under op-type :flow. The full taxonomy lives in 009 §Flow trace events; the summary:
:operation |
Fires when |
|---|---|
:rf.flow/registered |
reg-flow (or :rf.fx/reg-flow) successfully registers a flow against a frame, after cycle detection passes. |
:rf.flow/computed |
A flow's :output fn ran and the result was written to :path (dirty-check observed input value-difference). Carries :before (the value at :path immediately before this drain's write — nil when the slot had never been written) alongside :result, so consumers render the wrote-line "wrote [:path] <before> → <after>" without walking the surrounding epoch's :db-before snapshot. Both ride through elide-wire-value against the flow's :path. |
:rf.flow/skip |
The dirty-check found inputs =-equal to the previous run; the recompute was suppressed (§Dirty-check semantics above; value-equal recompute suppression). |
:rf.flow/cleared |
clear-flow (or :rf.fx/clear-flow) removed the flow from the per-frame registry and dissoc-in'd its output path. |
:rf.flow/failed |
A flow's :output fn threw during recompute. The exception is re-thrown after the trace fires; see §Failure semantics for the atomicity contract (the event aborts — no install, app-db unchanged, no :rf.event/db-changed, :fx skipped; last-inputs rolled back so every flow re-attempts; router emits :rf.error/flow-eval-exception per 009 §Error contract). |
Every event carries :flow-id and :frame under :tags. Pair-shaped tools, Xray's flow panel, and custom dashboards filter op-type :flow to subscribe to the whole flow stream — see Tool-Pair §How AI tools attach and 009 §Flow trace events for the consumer-side pattern.
:sensitive? inheritance. A flow's :output fn runs inside the after-interceptor of the surrounding handler scope; the dirty-check write and any thrown exception are framework-owned but the resolved input values and computed output ride from the handler whose event triggered the drain. The runtime therefore stamps :sensitive? true at the top level of every :rf.flow/* trace event when the in-scope handler's registration meta carries :sensitive? true — per the inheritance rule at 009 §The :sensitive? registration metadata key. The flow itself does not declare :sensitive? directly; the marker rides the cascade. An auth-handler dispatching [:auth/signed-in token] whose drain re-evaluates the :auth/derived-user flow emits a :rf.flow/computed carrying :sensitive? true, and the framework-published forwarders (Sentry / Honeybadger / re-frame2-pair / Xray-MCP) default-drop it. Apps that need finer-grained per-flow privacy reach for redact-interceptor on the surrounding handler or scrub the :output fn's return value at the source.
The whole flow trace surface, like the rest of trace, is compile-time eliminated in production builds (per 009 §Production builds).
Flow output validation¶
A flow's optional :schema key (per §The registration shape) declares a Malli schema for the output value. When present, the runtime validates the flow's computed :output against it on every recompute, during the flow-transform :after (after the handler body, before the :db install — per §Drain integration). This is the same dev-time, pluggable-validator mechanism the rest of 010 §Schemas uses; the flows artefact reaches the registered validator/explainer through the :schemas/validate-with-registered-fn / :schemas/explain-with-registered-fn late-bind hooks, so an app that omits the schemas artefact (or registers no validator) pays nothing and the check soft-passes.
Observational, not a rollback. A flow :schema violation does not throw and does not unwind the write. This is distinct from a flow :output throw (which aborts the whole event — §Failure semantics): a schema violation is a soft, dev-time diagnostic. The flow computed a value successfully; the value simply fails its declared shape. By the time a violation could be observed, downstream flows in the same drain may already have read the value as their input, so retroactively unwinding one flow's write mid-walk would leave an inconsistent pending :db effect. So the output is written into the pending effect and the cascade proceeds normally (the event still commits if no flow throws); the failure surfaces as a diagnostic :rf.error/schema-validation-failure error event with :where :flow-output (per 009 §Error event catalogue), carrying the failing :rf.flow/id, the flow's :path, the failing :value (size/sensitivity-elided like every wire-bearing trace slot), and the registered explainer's :explain output. :recovery is :no-recovery, matching the category's documented disposition — the check exists to surface a producer bug early, not to repair state.
(rf/reg-flow
{:id :cart/total
:inputs [[:cart :subtotal] [:cart :discount-rate]]
:output (fn [subtotal rate] (Math/round (* subtotal (- 1 (or rate 0)))))
:path [:cart :total]
:schema [:int {:min 0}]}) ;; output must be a non-negative integer
Like the rest of the validation surface, flow output validation is dev-only: it sits behind re-frame.interop/debug-enabled? and is compile-time eliminated in production builds.
Sub integration¶
Flows write to app-db; subs read app-db. Flows therefore publish zero framework subscriptions. This is a deliberate posture, not an oversight (audit Finding 4). The contract is: a flow's output value lives at its :path in the dispatching frame's app-db, and consumers read it through whatever sub registration they prefer — either a user-registered (rf/reg-sub :my-app/area (fn [db _] (get-in db [:my-app/area]))) over the path, or a derived sub that closes over it. Because the flow transform rewrites the pending :db effect (per §Drain integration), the flow-derived value reaches app-db through the cascade's single :db install — the one replace-container! + sub-cache invalidation the drain already performs — so reactivity is automatic and the cascade performs exactly one app-db write per event regardless of how many flows fired; there is no separate flow-output cache the substrate needs to track.
What this means¶
A flow named :my-app/derived-area with :path [:my-app/area] is observable through any of these patterns, none of them special-cased for flows:
;; (a) plain app-db read inside another handler
(rf/reg-event-db :event/use-area
(fn [db _]
(let [area (get-in db [:my-app/area])] ...)))
;; (b) user-registered sub over the flow's :path
(rf/reg-sub :my-app/area (fn [db _] (get-in db [:my-app/area])))
@(rf/subscribe [:my-app/area])
;; (c) derived sub that closes over the path implicitly
(rf/reg-sub :my-app/area-doubled
:<- [:my-app/area]
(fn [area _] (* 2 area)))
The flow's :path IS the contract surface. Consumers depend on the path, not on a :rf.flow/<flow-id> sub-id. This is the same shape as any other app-db value: a flow's output is "ordinary application state with a known producer" — exactly the framing at §When (and when not) to use a flow.
Asymmetry with routing¶
Routing publishes nine framework subs over its :rf/route slice (:rf/route, :rf.route/id, :rf.route/params, :rf.route/query, :rf.route/fragment, :rf.route/transition, :rf.route/error, :rf.route/chain, :rf/pending-navigation — per 012 §Subscriptions). Flows publish zero. The asymmetry is real but principled:
| Surface | Where output lives | Framework subs | Why |
|---|---|---|---|
| Routing | :rf/route slice in app-db |
nine (:rf/route + eight derived) |
The slice is a single named map with a fixed shape — every consumer wants :id, :params, :query, :transition as common destructures. Publishing the per-key subs once means every consumer reads the same canonical sub-id (:rf.route/id) rather than re-registering eight identically-shaped getters. |
| Flows | An arbitrary path in app-db per flow |
zero | Each flow's :path is user-chosen and shape-arbitrary (could be a number, a vector, a map of any shape, …). There is no canonical "every flow has these eight derived views" to publish. A consumer wanting (:items @(subscribe [:my-app/cart])) writes one sub over their cart's :path; the framework cannot do this for the user without knowing every flow's output shape. |
The asymmetry follows from the shape-uniformity difference: routing's slice has a fixed shape locked by §The :rf/route slice; a flow's output shape is whatever the :output fn returns. Routing's shape uniformity makes framework subs cheap (one registration table, every app sees the same sub-ids); flows' shape arbitrariness makes them impossible (the framework would need a sub-id per flow with a layer-1 fn parameterised on each flow's :path, doubling the per-flow sub-cache footprint and polluting the registered-sub namespace).
Observability consequence¶
A tool wanting to enumerate "which views/handlers depend on this route's id" reads the sub-graph via (sub-cache-consumers :rf.route/id) — the standard sub-topology surface gives the answer for free. A tool wanting to enumerate "which views/handlers depend on this flow's output" reads app-db path consumers, not flow-output consumers: the framework's sub-topology query surface (per 006 §Reference counting and disposal) returns subs whose layer-1 fn reads the flow's :path, not subs whose layer-1 fn reads "the flow with this id".
This is an observability asymmetry the doc names but does not paper over. Tools rendering "flow consumer" panels (Xray's flow tab, post-v1 dashboards) compute the answer from :path overlap, not from a framework sub-id. The two enumerations — (rf/registrations :flow) (which flows exist) plus (sub-cache-consumers-of-path [:my-app/area]) (which subs read this path) — together provide the full picture; neither is a framework sub family.
The audit's alternative — a framework sub family :rf.flow/<flow-id> whose layer-1 fn is (fn [db _] (get-in db (:path flow))) — was considered (audit Finding 4 §Direction). It is the wrong direction for v1: it doubles the sub-cache footprint per flow (every registered flow gets a registered sub even when no consumer reads through it); it pollutes the registered-sub namespace (one sub-id per registered flow); and it conflates two surfaces (flows write app-db; subs read app-db) that the design deliberately keeps separated (per the §When (and when not) to use a flow framing). No follow-on bead is filed; the zero-framework-sub posture is the locked v1 contract.
Dynamic toggle via fx¶
Two reserved fx-ids let event handlers register and clear flows during normal event processing:
| Fx-id | Args | Effect |
|---|---|---|
:rf.fx/reg-flow |
A flow map (same shape as reg-flow's argument) |
Register the flow against the dispatching frame. Next drain's topsort observes the new node (no cache to invalidate; per §Topological sort and cycle detection). |
:rf.fx/clear-flow |
A flow id | Clear the flow from the dispatching frame. dissoc-in on its :path in that frame's app-db. Next drain's topsort observes the removal. |
(rf/reg-event-fx :wizard/enter-step-2
(fn [_ _]
{:fx [[:rf.fx/reg-flow {:id :step-2/computed
:inputs [[:step-2 :foo] [:step-2 :bar]]
:output (fn [foo bar] (compute foo bar))
:path [:step-2 :result]}]]}))
(rf/reg-event-fx :wizard/leave-step-2
(fn [_ _]
{:fx [[:rf.fx/clear-flow :step-2/computed]]}))
Frame routing. Both fx run inside the standard :fx walk and receive the {:frame frame-id} cofx from the dispatching frame. They thread the frame through to reg-flow / clear-flow as the :frame opt — there is no explicit :frame to set in the fx args. A flow registered via :rf.fx/reg-flow from an event dispatched on frame :left is registered against :left; the same fx invoked from a :right dispatch routes to :right. This makes fx-driven flow lifecycle (wizard step in / out, feature gating) automatically frame-correct without ceremony.
Sequencing — the one-event lag¶
This is the single least-obvious thing about flows. Read it before you reach for
:rf.fx/reg-flow. A flow registered mid-event does not compute its initial output during that event — it first fires on the next drain on the same frame.
:rf.fx/reg-flow and :rf.fx/clear-flow run during the standard :fx walk (per 002 §:fx ordering and atomicity guarantees) — and the :fx walk is the last drain stage, after the flow-transform :after has already evaluated for the current event (per §Drain integration, step 4 runs after step 2). The newly-registered flow was not in the per-frame registry when the flow transform walked it, so it cannot have computed. Its initial output therefore appears one event after registration, on the next drain on the same frame.
This lag is a structural consequence of the §Drain integration contract, not an oversight. The flow transform rewrites the handler's pending :db effect as the outermost :after (step 2); the single deferred :db install (step 3) is the cascade's only app-db write; :fx walks last (step 4). Re-running the flow transform after :fx registered a new flow would require a second app-db install in the same event — breaking the "exactly one :db install per event" invariant (§Drain integration property 4), the pending-effect-transform model (§Resolved decisions §Flows transform the pending :db effect), and the atomic-commit contract (§Failure semantics). So the lag stands by design; closing it would mean either a post-install re-walk (the prior design, removed outright) or an async mid-event re-walk (deferred — see §Open questions §Synchronous re-walk after :rf.fx/reg-flow).
Working with the lag. In the common case the lag is invisible: you register a flow in :enter and the user's next interaction (which dispatches an event) materialises the output. When you genuinely need the initial value now, dispatch a follow-up event from the same handler whose only job is to re-trigger the drain — the flow computes on that drain:
(rf/reg-event-fx :wizard/enter-step-2
(fn [_ _]
{:fx [[:rf.fx/reg-flow {:id :step-2/computed
:inputs [[:step-2 :foo] [:step-2 :bar]]
:output (fn [foo bar] (compute foo bar))
:path [:step-2 :result]}]
;; The flow is in the registry by the time THIS dispatched event
;; drains — so the flow transform on :wizard/settle computes the
;; initial output. Without this, :step-2/result stays unset until
;; the user's next interaction.
[:dispatch [:wizard/settle]]]}))
(rf/reg-event-db :wizard/settle (fn [db _] db)) ;; no-op; exists only to drain
This is a deliberate, explicit step — not a hidden one. Most apps never need it.
clear-flow cleanup. Default behaviour is dissoc-in on the flow's :path in the owning frame's app-db — the slot is vacated when the flow goes away. Stale derived values left behind would confuse downstream consumers. Apps that want to preserve the value should copy it elsewhere before clearing. Sibling frames are unaffected.
Re-registration¶
reg-flow with an already-registered :id (against the same frame) performs a surgical update — same semantics as every other reg-* per 001-Registration §Hot-reload semantics. The new flow's definition replaces the old in (get @flows frame-id); last-inputs for [frame-id flow-id] is reset (the new flow re-evaluates on the next event regardless of input change); the next drain's topsort observes the new dependency edges automatically (per §Topological sort and cycle detection; v1 does not memoise the sort). In-flight events finish against the resolved handler at the time they entered the drain. Re-registering the same flow id against a different frame is not a replacement — it adds an independent definition to the second frame's slot.
What flows are NOT¶
Three near-neighbours flows are not:
| Concept | Difference |
|---|---|
| Subscription (006) | Subs live in the sub-cache; consumed by views. Flows live in app-db; consumed by everything (handlers, other flows, schemas, SSR payload). When the value is part of the application's state, use a flow; when it's part of view rendering only, use a sub. |
| State machine (005) | Machines have transitions, hierarchical states, :always/:after/:spawn, snapshots at [:rf/runtime :machines :snapshots <id>]. Flows have one pure function and one output path. Use a machine when there are discrete states; use a flow when the value is a pure function of inputs. |
on-changes interceptor (v1) |
on-changes is wired into specific events' interceptor chains. Flows are registered against a frame and toggleable via :rf.fx/reg-flow / :rf.fx/clear-flow. The compute-on-change semantics are identical; the registration shape and lifecycle are different. |
Flows are also explicitly not:
- A second runtime. Flows participate in the standard event drain via one after-interceptor implicit on every event; there is no parallel scheduler. Compare the v1 alpha bardo state machine, lifecycle policies, and per-flow
:fxmechanism — all gone. - A side-effect mechanism. Flows compute values; they don't fire fx. If a derived value's change should trigger an effect, dispatch a follow-up event whose handler reads the flow's output and emits the effect.
- A subscription replacement. Most derived values are still subs. Flows pay an
app-dbwrite per recomputation; the value is more visible but slightly more expensive than a sub-cache hit.
Migration from v1 alpha flows¶
| v1 alpha | v2 |
|---|---|
:id |
:id (unchanged) |
:inputs (map of keyword → path-or-flow<-) |
:inputs (vector of paths). Map-keyed inputs that referenced other flows via flow<- collapse to plain paths — the topological sort handles dependency ordering automatically. |
:output (function of resolved-inputs map) |
:output (function of positional inputs) |
:path |
:path (unchanged) |
:live?, :live-inputs |
Dropped. Use :rf.fx/clear-flow to toggle off; :rf.fx/reg-flow to toggle on. |
:cleanup |
Dropped. Default is dissoc-in on :path; opt-out is not provided. |
Per-flow :fx |
Dropped. Dispatch an event from a handler if you need fx on flow output change. |
Lifecycle policies (:safe, :no-cache, :reactive, :forever) |
Not applicable. Lifecycle policies are a sub-cache concern; flows have one cache state (registered-or-not). |
flow<- reified flow-to-flow input |
Dropped. Flow B reads flow A by listing A's :path in its :inputs. |
:reg-flow / :clear-flow (unprefixed fx-ids) |
Renamed to :rf.fx/reg-flow / :rf.fx/clear-flow per Conventions §Reserved namespaces. |
The migration agent rewrites mechanically; flow definitions that used :live? lift to a wrapping event-handler that calls :rf.fx/clear-flow when the predicate flips false.
Conformance fixtures (planned)¶
flow-basic.edn— single flow, two inputs, one output. Verify dirty-check on input change.flow-toggle.edn—:rf.fx/reg-flowand:rf.fx/clear-flowfrom event handlers. Verify lifecycle.flow-topsort.edn— multi-layer flows; verify A runs before B when B depends on A's output.flow-cycle.edn— registering a cycle throws:rf.error/flow-cycle.flow-no-recompute-equal.edn—app-dbwrite that produces=-equal value does not re-fire dependent flows.flow-frame-scoped.edn— same flow id registered against two frames with different:outputfns produces two independent results on the same input;clear-flowon one frame leaves the other intact.
Open questions¶
SA-4 classification. Both items below classify as
:post-v1 trackedper SPEC-AUTHORING §SA-4. They are deferred design work the corpus tolerates shipping without resolving (the v1 design is settled — vector:inputs, lag-on-register); the items mark candidate enhancements rather than blocking gaps. Items here track a real bead once one is filed.
Map-keyed :inputs instead of vector¶
The vector form (:inputs [[:width] [:height]] :output (fn [w h] ...)) matches on-changes and is short. A map-keyed alternative (:inputs {:w [:width] :h [:height]} :output (fn [{:keys [w h]}] ...)) matches Principles §Name over place. The vector is the v1 default; the map is the principled default. v2 ships the vector form for migration ergonomics; revisit if the map form proves preferable in practice. Status: :post-v1 tracked — file a bead before considering implementation.
Synchronous re-walk after :rf.fx/reg-flow¶
A flow registered mid-event first fires on the next event drain (one-event lag for the initial value — the structural consequence documented at §Sequencing — the one-event lag). An opt-in "register and run immediately" effect could close the lag at the cost of a second mid-event app-db install, which would break the one-install-per-event invariant (§Drain integration property 4) and the atomic-commit contract (§Failure semantics) — so it is genuinely deferred design work, not a quick toggle. Until then the lag is loudly signposted (spec §Sequencing, the :rf.fx/reg-flow fx-handler docstring, and docs/api/05-flows.md §The one-event lag) and worked around with an explicit follow-up :dispatch. Defer until a real use case forces it. Status: :post-v1 tracked — file a bead when a concrete use case surfaces.
Resolved decisions¶
Topological sort over registration order (RESOLVED)¶
Earlier sketch leaned on registration order; topological sort selected because dynamic registration via :rf.fx/reg-flow makes registration order dispatch-time-dependent and an unreliable contract. The dependency graph is statically derivable from each flow's :path and :inputs; recomputing the sort once per drain is cheap at v1's per-frame node counts (a handful of flows, Kahn's algorithm over them is cheaper than memo bookkeeping). The sort is not memoised — per §Topological sort and cycle detection; an earlier memoised variant was removed under after measurement.
One-pass evaluation, not fixed-point iteration (RESOLVED)¶
Topological sort lets every flow settle in one walk. Fixed-point iteration was considered as an alternative for cases where flows form mutual dependencies — but mutual dependencies are exactly cycles, which the topsort rejects at registration. With cycles forbidden, one pass suffices.
Vector :inputs, not map (RESOLVED for v1; revisit later)¶
Per §Open questions §Map-keyed :inputs, the vector form ships in v1 for migration ergonomics. The map-keyed alternative remains a design option for a future iteration.
clear-flow always dissoc-ins the output path (RESOLVED)¶
No opt-out. Stale derived values are confusing; vacating the slot is the natural toggle-off semantics. Apps that want to preserve the value should copy it elsewhere before clearing.
Frame-destroy teardown is mandatory (RESOLVED)¶
destroy-frame! MUST release every per-frame piece of flow state — the per-frame flow-registry slot, all last-inputs rows for the destroyed frame, and every :flow registrar entry the destroyed frame was the last owner of. Sibling frames' rows and shared-id registrar slots are preserved. Per §Frame-destroy teardown. Without this, long-running SSR JVM hosts (per-request frame churn), pair-tool time-travel, and make-frame ephemeral usage leak flow definitions and cached input vectors indefinitely. Symmetric with the machines / schemas / SSR teardown hooks the per-feature artefacts publish off the single normative destroy-frame! boundary at 002 §Destroy.
Flows transform the pending :db effect, as the outermost :after (RESOLVED)¶
The flow walk runs immediately after the handler's interceptor chain — as the outermost :after interceptor — and transforms the handler's pending :db effect in the chain context, before the single :db install and before :fx. This replaces the prior design (full interceptor chain → :db commits to app-db → run-flows! mutates the live app-db → :fx walks). Per §Drain integration and 002 §Drain-loop pseudocode.
The change makes three things true that the post-install design could not: (a) the cascade performs exactly one app-db install — of the flow-augmented value — rather than a handler commit followed by a separate flow mutation; (b) the :rf.event/db-changed trace fires AFTER flows, so it reflects the flow-augmented db, and :rf.flow/computed precedes :rf.event/db-changed on the trace stream (per 009 §Canonical per-event trace sequence) — making the flow position observable for Xray's Trace panel; (c) flows transform the pending effect rather than the live container, so the write is part of the cascade's single install. The :fx-sees-flow-output guarantee is preserved — :fx still walks after the install, so it reads the flow-derived app-db.
Outermost, not innermost. Flows run as the outermost :after (fired last) — NOT the innermost — because the path std-interceptor's :after reshapes the :db effect (splicing a slice back into the full db), and flows read full-app-db :inputs paths, so they must run after that reshape. The consequence is that user :after interceptors run before the flow transform and see the handler's pre-flow :db effect; observational interceptors that need flow output read it from app-db post-install (sub / follow-up event), as :fx does. This is the pre-alpha masterpiece choice: no back-compat shim, the prior post-install drain step is removed outright; correctness (flows read the full db) is the load-bearing constraint that fixes the placement.
A flow throw aborts the event — atomic commit boundary (RESOLVED, Mike 2026-05-24)¶
The :db install is the single, deferred, all-or-nothing commit boundary. ANY throw before it — cofx, handler, interceptor :after, or the flow transform — aborts the entire event: no install, app-db unchanged, no :rf.event/db-changed, no :fx. A flow throw is just one such pre-install throw and MUST behave identically to a handler / interceptor-:after throw. :fx is the only post-install stage; an fx throw does NOT wind back the installed :db (side effects may already have fired).
This replaces the earlier "prior-flow writes still commit on a flow throw" rule. That rule committed a partial app-db — making flow throws behave differently from every other pre-install throw, and committing state from an event the runtime simultaneously reported as failed. The atomicity rule is also free: because the install is already deferred to a single write, winding back on a pre-install throw is just not performing that write (the flow-transform :after discards the pending :db effect) — there is no rollback machinery, no partial-commit special-case. The dirty-check bookkeeping rolls back in lockstep: the last-inputs snapshot is restored on a throw so every flow re-attempts cleanly on a later, clean drain. The invariant the whole design now upholds: an event either commits in full or not at all. Per §Failure semantics.
:rf.error/flow-eval-exception rides the always-on error substrate (RESOLVED)¶
Flow evaluation failures MUST surface on the always-on production error-emit substrate (per 009 §Production builds), NOT on the dev-only trace surface alone. Both fan-out paths — the per-frame :on-error policy fn and the corpus-wide register-error-listener! registry — fire under CLJS :advanced + goog.DEBUG=false. The per-flow :rf.flow/failed trace still fires first with full flow-attributed detail, but it rides the dev-only trace surface and DCEs in production; flow attribution survives on the always-on path via the :flow-id slot stamped onto the cascade-level error's :tags. The attribution is the flow id alone — no real flow value exists to carry, so :flow-id is the whole prod-surviving contract. Without this routing, a production-build flow-eval failure was silently dropped — no :on-error fire, no off-box monitor record. Per §Failure semantics rule 4.
Cross-references¶
- 001-Registration — registration grammar (
reg-flowis a kind under:flow). - 002-Frames §Drain-loop pseudocode — where the flow after-interceptor sits.
- 002-Frames §Destroy — the normative teardown boundary
:rf.flow/*state hangs off; cross-referenced from §Frame-destroy teardown. - 006-ReactiveSubstrate — sub-cache invalidation; flows trigger sub-cache invalidation when they write.
- 009-Instrumentation §Error contract —
:rf.error/flow-cycleand:rf.error/flow-eval-exceptionnamespaces. - 009-Instrumentation §Production builds — the always-on error-emit substrate
:rf.error/flow-eval-exceptionrides; cross-referenced from §Failure semantics rule 4. - 009-Instrumentation §Flow trace events — full taxonomy and payloads for the
:rf.flow/*event vocabulary; cross-referenced from §Flow tracing above. - 009-Instrumentation §The
:sensitive?registration metadata key —:rf.flow/*trace events inherit:sensitive?from the in-scope handler at drain time; cross-referenced from §Flow tracing above. - Conventions —
:rf.fx/reg-flowand:rf.fx/clear-flowreserved fx-ids. - MIGRATION §M-19 — generic call-shape migration;
:inputsis positional vector matching the v1on-changesform.