EP-0037: Route Planning and Activation Ownership¶
Status: accepted Type: standards-track Created: 2026-07-24
This EP makes routing one inspectable planning boundary. A route address is resolved once into a target, an active branch, managed resource requirements, and navigation policy; every navigation door executes that plan. Routing owns navigation and resource activation, but it does not own views, application data, a second cache, or the asynchronous tail of arbitrary events. On graduation the public contracts live in Specs 011, 012, 016,
Spec-Schemas.md, and their instrumentation/API indexes; this EP remains the design record.
Abstract¶
re-frame2 routing plans where the frame is going and which managed reads that destination requires. It does not become a React-shaped application framework. Views remain derivative projections of frame state, events remain the causal boundary, and the Resources artefact remains the owner of remote-read identity, work, caching, freshness, cancellation, and errors.
This EP defines one route-planning pipeline shared by links, programmatic
navigation, Back/Forward, initial load, and SSR. It introduces one canonical
plain-map RouteAddress, composes route :resources from the active
parent-to-leaf branch, adds resource-only intent prefetch, and makes route
readiness an honest projection of managed blocking resources.
It also removes contracts that obscure ownership. :on-match becomes explicitly
fire-and-forget activation work rather than a loader whose arbitrary asynchronous
tail the router pretends to await. Entry policy remains declarative but becomes a
terminal allow/deny decision rather than a resumable navigation. Cross-route
query carry becomes explicit at the call site; destination-local query schemas
and defaults and in-place query edits remain.
The result is deliberately smaller than the feature sets of full-stack routers: no outlet runtime, component loader data, route-owned cache, route actions, generic search middleware, file-route authority, or prefetch mode matrix. The useful ideas are adopted at re-frame2's existing seams instead of importing another framework's ownership model.
Motivation¶
The existing router has the right foundation: routes are registry data, navigation is an event, the active route is frame state, URLs are derivable, and SSR uses the same event/runtime model. Several later additions, however, now pull that foundation in different directions:
route-link,route-url, navigation, redirects, and future prefetch all describe the same destination but do not yet share a named, schema-backed address value.:parentdescribes the active layout branch, while route resources are planned only from the leaf. Shared shell reads are therefore duplicated in children or hidden behind application helpers that tooling cannot see.:on-matchdispatches ordinary events, but route transition/error machinery tries to treat those events as awaited loaders. A synchronous drain can observe that an event handler ran; it cannot own or reliably correlate the later HTTP, timer, callback, or application event graph that handler starts.- both leave and entry guards currently use one resumable pending-navigation protocol. Leaving a dirty form genuinely requires pause, UI, and a user decision. Entry authorization normally requires an immediate allow or deny, followed, if appropriate, by a fresh redirect/navigation after application state changes.
:query-retainchanges a destination URL using ambient current-route state that is absent from the navigation request. The final address is therefore harder for a programmer, test, trace, or AI to infer from the value being reviewed.- the Resources artefact already has ownerless, cause-only
ensure, but the router has no route-plan verb that can warm all destination resources without duplicating the plan at every link.
These are not independent conveniences. They are symptoms of one unclear boundary: whether the router merely plans a transition or owns all work and UI associated with a page. A bead-sized patch to any one symptom would preserve contradictory answers elsewhere. The decision warrants one programme EP because the route request grammar, guard protocol, transition state, resource ownership, SSR wait point, schemas, traces, tests, and guides must converge on the same answer.
Specification¶
Scope and authority¶
This EP owns the route-planning and activation boundary:
navigation intent
-> validated address or in-place edit
-> resolved target and active branch
-> leave/entry decisions
-> effective managed-resource plan
-> route/URL commit and activation effects
It covers the public values and runtime semantics needed to make that boundary one path across browser navigation, programmatic navigation, initial load, and SSR. It does not own route pattern syntax or ranking, resource cache semantics, HTTP transport, view rendering, application mutations, forms, head emission, or the host history implementation except where their existing owners consume the plan.
The canonical contract homes on graduation are:
| Home | Contract owned there |
|---|---|
spec/012-Routing.md |
address/request grammar, the one planning path, activation events, guards, readiness projection, prefetch, query policy, link behavior |
spec/016-Resources.md |
branch-plan composition, identity dedupe, owner handoff, warm ensures, blocking-resource status |
spec/011-SSR.md |
initial/SSR use of the same plan, blocking wait, denied-entry behavior, hydration |
spec/Spec-Schemas.md |
:rf/route-address, the address/raw-URL destination union, navigation request, route metadata, route read shape, leave-only pending navigation |
spec/009-Instrumentation.md |
plan, prefetch, denial, planning-failure, and stale-suppression traces/errors |
spec/API.md, spec/Ownership.md, and relevant indexes |
inventory and ownership references only; never a second definition |
Governing laws¶
- Views are derivative projections. Rendering may read props, local pure values, and subscriptions. It does not fetch, ensure a resource, navigate, or dispatch merely because rendering occurred. A user or host interaction may dispatch an event; render itself is not a causal boundary.
- The router plans; existing owners execute. Routing owns target resolution, branch selection, navigation decisions, resource requirements, and commit order. Events own application state changes. Resources own managed remote reads. Views project the resulting state.
- One intent takes one path. Link clicks,
:rf.route/navigate, Back/Forward, initial load, and SSR differ in cause and history/scroll policy, not in target, entry, resource, or readiness semantics. - One destination has one data shape. Registered destinations use the same
RouteAddressfields wherever an address is stored, printed, linked, navigated to, denied, returned to, or prefetched. - The active branch contributes requirements, not rendering. Declaring
:parentopts the child into the parent's branch-wide managed resources. Parent routes do not acquire an outlet, component, context-provider, or implicit view lifecycle. - Only managed work affects managed readiness. Arbitrary events may start arbitrary work, but only work whose lifecycle the runtime actually owns may make route readiness loading or error.
- Leaving may pause; entering decides.
:can-leavemay create durable pending state for confirmation.:can-enterterminates the current attempt with allow or deny; a redirect or post-login return is a new navigation. - Address transformations are visible. Destination-local validation and defaults are declarative route facts. Carrying current URL state to a different route is an explicit application operation.
- Correctness does not depend on cancellation. Nav tokens, resource generations, and current-plan identity suppress stale completion. Cancellation remains an optimisation.
- Start with the smallest powerful surface. Parent resource composition, intent prefetch, and terminal entry are included. Generic metadata inheritance, route caches, render/viewport prefetch modes, timing knobs, action/form protocols, and code-loading policy are not.
Terms¶
RouteAddressis caller-authored intent for one registered named destination: the closed{:to :params :query :fragment}map. Policy, link props, and in-place edits are never part of this value.RouteDestinationis the closed replay union of aRouteAddressand the raw-URL escape. Pending-leave and entry-denial payloads use it when they must preserve intent that may not have a named route spelling. It never absorbs navigation policy; a pending leave stores explicit policy beside it.ResolvedTargetis planner output: canonical route id, params, query, fragment, and URL after matching, defaults, and validation. It is fact, not another accepted input spelling.
Canonical RouteAddress¶
A registered destination is represented by one closed, ordinary EDN map:
The public schema id is :rf/route-address:
[:map {:closed true}
[:to :keyword]
[:params {:optional true} :map]
[:query {:optional true} :map]
[:fragment {:optional true} [:maybe :string]]]
:to is required. Omitted :params and :query normalize to {}; an omitted
or nil :fragment normalizes to no fragment. Route param/query validation,
canonical EDN identity, query defaults, URL encoding, and not-found behavior
continue to be owned by Spec 012. Unknown address keys fail at the authoring or
event boundary in every build.
RouteAddress is deliberately a map plus one schema. There is no record,
constructor hierarchy, fluent builder, relative-address language, or
type-specific redirect object. Applications may name address constants and pure
functions using ordinary Clojure values.
The same address fields are consumed by:
rf.routing/route-url;rf/route-linkandv/route-link(as control fields inside the larger props map);- the destination branch of
:rf.route/navigate; - the named destination carried by entry-denied and pending-leave data;
- application redirect/return-to state; and
:rf.route/prefetch.
Public call sites remain flat:
(rf/dispatch
[:rf.route/navigate
{:to :route/article
:params {:slug slug}
:replace? true}])
[v/route-link
{:to :route/article
:params {:slug slug}
:prefetch :intent
:class "article-link"}
title]
The first map contains an address plus navigation policy. The second contains an
address plus link behavior and ordinary DOM attributes. Implementations extract
and validate the address through the same code; they do not pretend the whole
map is an address. A nested public envelope such as
{:address {...} :policy {...}} is not introduced solely to encode this
conceptual separation.
Normative extraction from flat public maps¶
The shared extractor operates on closed key classes:
address keys #{:to :params :query :fragment}
policy keys #{:replace? :scroll :bypass-leave?}
edit keys #{:query :query-merge :fragment}
link behavior keys #{:prefetch}
Presence chooses the request branch before validation:
- When
:tois present, the extractor selects exactly the address keys and validates that extracted map against:rf/route-address.:queryand:fragmentare destination facts in this branch. - When
:urlis present, the extractor selects the raw destination and validates it against the raw arm of:rf/route-destination.:to,:params,:query, and:query-mergeare forbidden beside it; an explicit:fragmentoverrides the URL-embedded fragment under the existing rule. - When neither
:tonor:urlis present,:query,:query-merge, and:fragmentare in-place edits. They are never validated or stored as aRouteAddress. A:paramskey in this branch retains the existing named:rf.error/navigate-bad-requestreason:params-requires-destination; changing path params always requires a destination.
Policy keys are extracted and validated separately. Closed control-map boundaries such as navigation, URL generation, prefetch, and stored-destination replay validate their whole accepted-key roster before extraction, so extraction cannot silently discard a misspelled address, policy, or edit key.
route-link is deliberately different because its props map is also an open
DOM-attribute map. It validates the recognized routing controls, including
:prefetch, strips the exact address and behavior keys before DOM emission, and
passes the remaining attributes and children to the view substrate. It does not
pretend that routing can distinguish every misspelled control from a
caller-authored DOM attribute; host/view DOM diagnostics own unknown attributes.
A caller's :on-click and ordinary anchor attributes are view props, not
destination or routing-policy keys. Thus the schema is closed over the
extracted address, not over the convenient flat props map accepted by a link.
Raw URL escape¶
{:url "/partner/supplied/path"} remains a stringly escape accepted by
navigation doors that must handle an already-authored URL. It is not a
RouteAddress, cannot be combined with :params or :query, and is not accepted
by route-url or the first prefetch surface. If it matches a registered route,
the planner immediately produces the same resolved target and canonical named
address it would have produced from :to. If it does not, the existing
same-origin/not-found rules apply.
Where runtime state must replay either form, RouteDestination (schema id
:rf/route-destination) means the closed union of RouteAddress and a raw map
with required string :url plus optional string-or-nil :fragment. A matching
raw URL normalizes to the named branch; only a destination that cannot be
reified without changing the requested URL remains raw. This union is used in
pending/denial payloads so continuing a dirty-form navigation to an unmatched
in-app URL still preserves that URL. It does not give route-url or prefetch a
second spelling.
This distinction keeps the common value enumerable and refactorable without removing deep-link and integration use cases. A plain external or intentionally browser-owned link remains an ordinary anchor.
Navigation policy and in-place edits¶
Navigation policy is not destination identity:
The proposed policy keys are :replace?, :scroll, and the leave-only
:bypass-leave? recommended in Open Issue 3. The current set-valued
:bypass-guards? #{:leave :enter} is retired: entry has no resumable/bypass
protocol, and a set is needless machinery for the one remaining case.
Existing route metadata :scroll is unchanged. A per-navigation :scroll
policy value remains its explicit override; parent resource composition does
not inherit or merge route scroll metadata.
In-place edits remain a separate branch of :rf.route/navigate:
[:rf.route/navigate {:query-merge {:page 2 :sort nil}}]
[:rf.route/navigate {:query {:tab "history"}}]
[:rf.route/navigate {:fragment "errors"}]
They patch the current resolved location under the existing presence-sensitive
rules. :query-merge is an operation, not part of RouteAddress, and remains
in-place only. The address, policy, and edit key rosters are documented
together, but their schemas and internal functions stay distinct so a future
feature cannot accidentally make an edit serializable as a destination.
Resolved target and route plan¶
The address is caller intent. Planning produces resolved facts:
{:route-id :route/article
:params {:slug "routing-as-data"}
:query {:tab :comments}
:fragment "reply-42"
:url "/articles/routing-as-data?tab=comments#reply-42"}
This is a ResolvedTarget, not another accepted input spelling. Facts say
:route-id; intent says :to. It is passed to guard subscriptions, recorded in
diagnostics, and used to derive the parent chain.
An internal route plan is plain data containing at least:
- the source address or raw-URL request;
- the resolved target;
- the cause (
:link,:navigate,:popstate,:initial, or:ssr); - the parent-to-leaf branch;
- the effective resource requirements and their contributors;
- the transition kind (
:full,:fragment-only, or:no-op); - history and scroll policy; and
- the old/new resource identity diff needed for ownership handoff.
Transition kind is derived from resolved facts, not from the request spelling:
- full when there is no current target or
:route-id,:params, or:querydiffers; - fragment-only when those three facts are equal and only
:fragmentdiffers; and - no-op when all four facts are equal.
Consequently, a changed in-place :query or :query-merge request is a full,
data-bearing activation. “In-place” describes how caller intent is resolved; it
does not create a weaker activation or guard path.
This EP does not require a public RoutePlan constructor or promise-returning
router object. The plan is an implementation and tooling seam. Its observable
laws are public; its diagnostic projection is visible in trace/Xray.
One planning pipeline for every door¶
Every navigation door must lower to the same ordered stages:
| Stage | Result | Failure/short-circuit |
|---|---|---|
| 1. Validate intent | accepted address/raw URL/in-place edit plus policy | malformed request rejects before any guard or history effect |
| 2. Resolve target | canonical target, URL, named address when possible, parent branch | schema/URL failures follow the existing not-found or caller-error contract |
| 3. Classify transition | full, fragment-only, or no-op | no-op terminates before guards, pending state, history, scroll, or activation |
| 4. Decide leave | allow, or a leave-only pending navigation | full and fragment-only transitions consult the current route; pending leave preserves it and popstate restores its URL |
| 5. Decide entry | allow, or terminal entry denial | full and fragment-only transitions consult the target route; denial commits no target and creates no pending entry |
| 6. Build activation plan | for a full transition, effective branch resource plan and old/new identity diff | fragment-only skips activation planning; planning failure produces a failed plan and no partial resource plan executes |
| 7. Commit and activate | route facts/plan ownership, history/scroll effects, resource ensures, activation events | stale completions remain fenced by token/generation |
Guard coverage is therefore explicit. An exact no-op evaluates neither guard:
nothing is being left or entered, and a redundant request cannot create pending
state. A full transition evaluates :can-leave and then :can-enter, including
a same-route activation whose canonical params or query changed. Query is
data-bearing and may change resource identity or discard route-local workflow,
so the in-place request form is not an implicit guard bypass; a route that wants
to allow a safe subset of query edits can compare the resolved target in its
guard subscription. A fragment-only transition continues to evaluate both
guards, preserving Spec 012's existing fragment-check contract; it skips
resource planning and activation only after they allow it. Initial load has no
current leave guard but evaluates target entry policy normally.
The programmatic, link, URL-change, initial-load, and SSR handlers may remain separate public events for cause-specific tests and host integration. They must call the same resolver, decisions, planner, and commit assembler. No door may reimplement guard coverage or leaf-only resource planning.
The existing state-first ordering remains: the frame's committed route facts and next plan ownership are established before a client history effect and before activation events. For a URL-driven door the browser has already moved, so no push occurs. Scroll remains cause/policy-specific. A blocked or denied popstate restores the current route's URL by replace. Initial load and SSR use the same decision and planning path without browser effects.
A resource-planning failure is a committed failed activation, not a return to
the prior page: the target and URL commit, readiness projects :error, and no
resource ensure from the invalid plan runs. The target's :on-match events do
not run. Committing target facts makes the failure addressable and gives
client and SSR error views stable route context; it is not a successful
activation boundary for analytics, host notification, seeding, or other
application work. The planning error and trace are the causal facts an
application observes. A retry is a fresh navigation and dispatches :on-match
only after its plan forms successfully.
Plan execution is atomic at this boundary. A failed plan contributes an empty next ownership set: after the failed target/error facts are installed, every owner held only by the previous route plan is released, no partially planned next owner is attached, and no partial ensure is dispatched. Keeping previous route owners after committing a different failed target would leak liveness and misrepresent the active plan.
A full activation allocates a fresh nav-token. A fragment-only transition and a no-op do not. Prefetch is not activation and allocates none.
Effective parent-chain resource plans¶
For an allowed full activation, the planner follows :parent from the matched
leaf and obtains the existing root-most-first route chain. It then composes only
the :resources declarations from each contributor, parent to leaf:
(rf/reg-route :route/account
{:resources
[{:id :viewer
:resource :account/viewer
:scope {:from-db :app/session}
:blocking? true}]}
"/account")
(rf/reg-route :route/account.settings
{:parent :route/account
:resources
[{:id :settings
:resource :account/settings
:scope {:from-db :app/session}
:blocking? true}]}
"/account/settings")
Activating :route/account.settings plans both requirements. The parent declares
shared shell data once; the child declares leaf data once. Views still render the
route chain with ordinary data composition. :on-match, :scroll, :head,
:tags, guards, and arbitrary metadata are not generically inherited or merged
by this feature.
This inheritance is deliberate and automatic. :parent already declares that
the child participates in the parent's active branch; it is therefore the
opt-in to the parent's branch-wide resource requirements. A second
:inherit-resources? true marker on every child would duplicate that fact,
permit an omitted marker to make SSR or prefetch silently incomplete, and grow
more fragile with each descendant level. A read needed by only one leaf belongs
on that leaf, not on its parent. This is a pre-alpha semantic expansion for
existing :parent users and requires an explicit acceptance ruling (Open Issue
4), not an accidental inference from implementation.
Planning follows these rules:
- Resolve the branch parent first. Existing unknown-parent and parent-cycle errors remain fail-loud.
- Within each contributor, validate local
:id/:afteredges exactly as Spec 016 requires, against the whole declared vector before:whenfilters occurrences. An:afteredge may name only an id declared by the same route; a missing target or local cycle fails the plan. Parent and child local ids never share a namespace. Declaration order is the stable tie-breaker among otherwise-ready occurrences. - Evaluate
:when, then resolve params and scope against the target/frame and validate them. Every contributor's route function receives the resolved leaf target as itsrouteargument; the planner records the contributor separately. The leaf target is the one canonical, fully resolved address for this activation and carries the path/query facts available to the whole branch; synthesising a second ancestor-shaped target would create competing target truths. A failure aborts the whole resource plan before any ensure is dispatched, then commits the failed activation described above. The error identifies both the contributor route and resource declaration. - Materialize a requirement containing contributor route id, local id,
declaration index, resolved scoped resource identity,
:blocking?, and:keep-previous?. - Form an occurrence graph over materialized requirements. It contains the
admitted local
:afteredges and the branch-order constraint that every ancestor contributor precedes every descendant contributor. Then collapse occurrences by canonical scoped resource identity: union each identity group's incoming/outgoing edges, discard edges whose two ends collapsed into the same group, and stable-topologically order the grouped graph. Dispatch oneensureper identity. This is identity dedupe, not a generic route-metadata merge. - If identity collapse makes the grouped graph cyclic, fail the whole plan with
the canonical
:rf.error/resource-route-planerror and dispatch no ensures. The diagnostic names the cyclic identity groups and contributor/local ids. Silently dropping an:afteror branch-order edge to keep an arbitrary occurrence is forbidden.
The occurrence graph and identity collapse are internal planning mechanics. A collapse-created cycle is only a resource-plan failure; it does not introduce a public graph API, a public graph value, or a new router abstraction.
When more than one requirement resolves to the same identity:
- the grouped graph fixes dispatch position; the earliest parent-to-leaf/declaration occurrence is only the stable tie-breaker when no dependency constrains the group;
- the work is blocking when any contributor marks it blocking;
:keep-previous?is true when any contributor requests it; and- every contributor remains present in the diagnostic plan, so dedupe does not erase why the resource is needed.
For example, if B declares :after #{A}, C declares :after #{B}, and
A and C resolve to the same identity, collapse produces both
identity(A/C) -> B and B -> identity(A/C). The plan fails. Dispatching the
identity at A and forgetting C's edge would lie about :after.
When a child redundantly declares an identity already contributed by an
ancestor, the existing :rf.resource/route-plan trace carries an advisory that
names both declarations. Dedupe remains valid and the plan remains executable
when its grouped graph is acyclic; the advisory merely makes the redundant
copy mechanically discoverable so the child declaration can be removed.
These are the only cross-requirement combination rules. Other metadata does not gain an implicit merge policy.
:after remains ensure-dispatch ordering, not a data waterfall. Every params,
scope, and condition function is evaluated synchronously while the plan is
formed; a child requirement cannot read data that a parent requirement has not
loaded yet.
Plan diff and owner handoff¶
Activation compares the old and next sets of scoped resource identities:
- kept identities remain owned and are not re-ensured solely because a leaf sibling changed;
- added identities acquire the next route-plan owner and run the ordinary resource ensure/freshness path; and
- removed identities release the prior route-plan owner after the next ownership set is attached.
Attach-before-release is required. An identity needed by both plans must never pass through an ownerless instant that aborts useful in-flight work or makes the entry GC-eligible. The implementation may retain the existing nav-token-shaped owner and perform an atomic handoff; this EP does not require a new stable ancestor-owner identity.
The navigation itself does not revalidate a kept, unchanged ancestor requirement. Resource invalidation, focus/reconnect policy, polling, or an actual identity/requirement change may still revalidate it through Spec 016. This is the partial-revalidation law: moving between child routes does not turn every parent requirement into a new page load.
Resource identity, freshness, cache reuse, error envelopes, generations, cancellation, GC, scope isolation, and hydration remain exactly one Resources contract. The router does not copy resource data into route state and does not add a route-loader cache.
Honest activation and readiness¶
:on-match is activation work¶
:on-match remains a vector of ordinary event vectors dispatched in order after
a successful full activation. It runs when there is no prior target, the
canonical route id changes, or the canonical resolved :params or :query
value changes. Repeating the same route id/params/query does not run it, and a
fragment-only change does not run it. This is the exact re-fire key set; request
spelling and policy keys are irrelevant. The events are useful for synchronous
seeding, analytics/host notification, and application-owned work that naturally
begins at activation.
Its contract is fire-and-forget:
- it runs only after the effective resource plan forms successfully; a committed planning-failure target dispatches none of its events;
- the router guarantees dispatch order and normal run-to-completion event semantics;
- it does not await asynchronous effects started by those events;
- it does not infer when their transitive work is finished;
- it does not correlate later global error records back to the route; and
- it does not make route readiness loading or error.
A synchronous handler throw remains visible through the ordinary Spec 009 event error channel. It is attributed to the event that threw, not rewritten into route-loader state.
The route :on-error metadata key and the internal
:rf.route.internal/settle-transition /
:rf.route.internal/on-match-error machinery are retired. Applications that
need managed page-read readiness declare :resources. Applications that start
other asynchronous work from :on-match own its status and error state in the
same event/effect subsystem that owns the work.
SSR continues to dispatch :on-match events through the request-local frame, so
synchronous event effects remain symmetric. SSR does not claim to wait for an
arbitrary asynchronous tail. Work that must settle before server render is a
blocking route resource.
Route readiness is a resource projection¶
The public :rf.route/transition and :rf.route/error reads remain, but their
meaning becomes:
| Effective-plan state | :rf.route/transition |
:rf.route/error |
|---|---|---|
| plan could not be formed | :error |
structured planning error |
| any blocking first load is pending and none has failed | :loading |
nil |
| a blocking first load failed | :error |
deterministic first failure in effective plan order |
| all blocking requirements have usable data or there are none | :idle |
nil |
| Resources artefact absent | :idle |
nil |
Erratum — 2026-07-26 (rf2-8yx7g, recording the rf2-kqxe6.17 ruling). The failed-load row above says the pick is the deterministic first failure "in effective plan order." It graduated as the deterministic first failure in canonical CEDN-1 resource-key identity order — not plan order, and not hash order. The blocking slot written under the nav-token is a set of scoped resource keys, so effective plan order is not recoverable at reconcile time; persisting it was considered and rejected when rf2-kqxe6.17 was ruled, because turning the set into an ordered structure threaded through commit / settle / hydration / restore is machinery bought only to make an arbitrary-but-deterministic tie-break match a sentence. Identity order is the stronger guarantee in practice: it is stable across settles that prune siblings, where plan order is not. The property the row exists to promise — that the reported failure is deterministic rather than incidental — is unchanged, and every other row is unchanged. Normative home: spec/012 §Route readiness is a resource projection. Provenance: ruled on rf2-kqxe6.17, recorded on rf2-kqxe6.18, prose landed on PR #6916.
The table uses Spec 016 terms, not router-local guesses:
- a blocking requirement has usable data when its active resource identity
projects
:rf.resource/has-data? true; - a blocking first load is pending when that identity has no usable data and
is absent/
:idleor projects:rf.resource/loading? true; - a blocking first load failed when the identity has no usable data and its
resource status is
:error; and :fetchingwith usable data is a background refresh, including a stale revalidation. It never makes the route:loading, and a refresh failure stays on the resource's:refresh-errorchannel.
Previous-data projection for a newly keyed requirement may keep useful pixels
on screen, but it does not make the new identity's first load complete. A
non-blocking first load, prefetch, and arbitrary :on-match work likewise do not
change route transition. Their honest status remains on their owning
resource/application read model.
This projection must have one pure implementation used by subscriptions,
SSR wait/render decisions, Xray, and any cached route-slice fields. An
implementation may cache the projection in runtime-db for efficient updates, but
the cache is not independent authority and must be reconstructible from the
active plan plus managed-resource/planning state. Hydration and epoch restore
must not preserve a route :loading value that the restored resource state
contradicts.
R1 applies this projector to R0's behavior-preserving leaf-only resource plan. R2 replaces that input with the effective parent-to-leaf branch plan; it does not change the readiness table or fork the projector. This staging lets activation honesty land before branch composition without creating an interim readiness contract.
The recommended acceptance ruling keeps :transition and :error in the
public :rf/route read for ergonomic whole-route reads and derives them through
that projector. Runtime-db may cache the projected values, but only as a
reconstructible, non-authoritative cache. Hydration, epoch restore, and every
resource settle must reconcile the cache through the same projector. Acceptance
must lock this shape before R0/R1 beads are cut; Open Issue 2 records the
alternative, not permission to discover the schema mid-implementation.
Supersession, cancellation, and SSR¶
A committed full activation still allocates a monotonic nav-token. Plan callbacks and route-owned resource work carry enough nav-token/generation evidence that a superseded result cannot alter readiness for the new plan. Cancellation is attempted only after owner handoff shows no live owner still needs the work.
Server handling resolves the address, applies entry decisions, builds the same effective parent-chain plan, ensures its resources, and waits only for blocking requirements of the current plan/token. It then renders and serializes the existing allowed runtime/resource projection. The server does not perform a separate “prefetch”; activation itself uses the plan.
Hydration installs the route/resource projection and recomputes readiness through the same projector. Fresh hydrated identities are reused. A client does not immediately duplicate an SSR ensure merely because the branch was reconstructed.
Resource-only intent prefetch¶
The routing artefact adds one public event:
It accepts a named RouteAddress, resolves the destination, builds the same
effective parent-chain resource plan, and runs each unique requirement through
the Resources artefact in warm mode.
Warm mode has a deliberately narrow contract:
- every ensure is ownerless, carries cause
[:route-prefetch <destination-route-id>], and is scoped to the frame that received the prefetch event; - warmed entries remain governed by ordinary resource freshness, dedupe, and GC;
:blocking?does not block anything;- no route state, nav-token, URL, history, scroll, focus, or pending navigation changes;
- no
:can-leave,:can-enter, or:on-matchruns; - resource failures remain resource failures and traces; they do not become route errors; and
- a later activation reuses fresh/in-flight work and attaches its real owner before normal activation proceeds.
A prefetch never warms or attaches work in a sibling frame merely because that frame resolves the same address or scoped resource identity. The carried-frame invariant applies to planning, trace attribution, cache entries, and teardown.
An invalid prefetch address rejects before planning with the operation-specific
:rf.error/prefetch-bad-address. A resource-planning failure emits the ordinary
structured route/resource planning diagnostic with :plan-cause :prefetch,
dispatches no partial ensures, and does not alter current route readiness.
When the Resources artefact is absent, the effective warm plan is empty and the
event performs no work; prefetch does not make Resources a mandatory routing
dependency.
Prefetch is a performance hint, not an authorization boundary. Resource requests
must already enforce scope and server authorization. Running entry guards during
warmup would make prefetch a partial navigation and would still not be a security
boundary. Prefetching a destination whose :can-enter would later deny is
therefore permitted: it may warm an already-authorized resource cache and means
nothing more. Activation still evaluates and may deny entry.
route-link and v/route-link accept one behavior value:
:intent warms on credible user intent such as focus, pointer hover, or touch
intent. It never fires merely because a view rendered. A caller may dispatch the
event directly for non-link intent (for example, after a search result becomes
the keyboard selection). On both link surfaces, the installed intent handlers
compose with rather than replace caller-supplied handlers and dispatch to the
render-time-captured frame, exactly as the delayed click handler does.
:prefetch is a route-link control key and is stripped before DOM emission,
like :to/:params/:query/:fragment; it never appears as an unknown anchor
attribute.
The first surface has no global default, render mode, viewport mode, hover-delay option, separate preload cache, or prefetch-stale clock. Resource freshness and dedupe already answer whether work is useful. If measured applications later need another trigger, that trigger can be proposed without changing warm-mode semantics.
Raw URLs, external links, guards, :on-match, and route-driven code chunks are
not prefetched in this slice. A future code-loading feature may consume the plan
through a late-bound seam after a real bundler/consumer proves the contract.
Spec 012's post-v1 :load passage remains an unshipped seam note, not a reserved
public metadata key. R1 must reconcile that note with this EP by removing its
dependency on the retired :on-match loading/:on-error machinery; any future
code-loading proposal must define its own managed ordering and failure contract.
Leave confirmation and terminal entry¶
Leave remains resumable¶
:can-leave remains a route-owned subscription decision because leaving a dirty
workflow may genuinely require confirmation UI. It receives the resolved target
and must return a boolean. false creates one leave-only pending-navigation
value, dispatches :rf.route/navigation-blocked, and leaves the current route
unchanged.
The pending value stores structured intent rather than requiring consumers to reparse an original event vector:
{:id "opaque-id"
:destination {:to :route/home}
:target {:route-id :route/home
:params {}
:query {}
:fragment nil
:url "/"}
:cause :navigate
:policy {:replace? true
:scroll :preserve}
:requested-url "/"
:rejecting-route :route/editor
:rejecting-guard :editor/can-leave?
:url-restored? false}
:policy is a normalized map of the caller-authored :replace? and :scroll
overrides; it is {} when neither was supplied. It never stores
:bypass-leave?: a request carrying a true bypass could not have entered the
pending slot, and continuation owns its own one-shot internal bypass.
:rf.route/continue and :rf.route/cancel apply only to this leave slot.
Continue clears the slot and executes the stored RouteDestination plus stored
policy through the normal pipeline with a one-shot leave bypass; entry is still
evaluated normally. If a blocked URL-driven transition restored the prior URL,
continue first moves the host URL back to :requested-url, preserving that
door's no-extra-push semantics. Cancel clears the slot and stays.
A non-boolean leave result fails closed and emits the existing structured
programmer error. This proposal recommends the public :bypass-leave? true
policy as an explicit trusted-programmer escape for the rare direct-navigation
case; the ordinary confirmation path uses continue (Open Issue 3).
Hard reload/cross-origin beforeunload integration remains a separate host
concern. This EP neither claims that an SPA pending value can stop the browser
from unloading nor adds a second confirmation API.
Entry is terminal¶
:can-enter remains a route-owned subscription id consulted after target
resolution through every door. It receives the resolved target and returns a
boolean:
trueallows the current plan to proceed;falseterminates this navigation attempt; and- any non-boolean value emits
:rf.error/can-enter-non-booleanand is treated as denial.
Denial:
- commits no target route, resource owner, URL push, scroll, or activation event;
- creates no pending-navigation value;
- dispatches
:rf.route/entry-deniedonce; - emits the corresponding trace; and
- when the browser already moved because of popstate, restores the current URL by replace.
The denial event receives:
{:destination {:to :route/account
:params {}
:query {}
:fragment nil}
:target {:route-id :route/account
:params {}
:query {}
:fragment nil
:url "/account"}
:cause :link
:requested-url "/account"
:guard :auth/signed-in?}
The routing artefact registers a framework no-op default handler for
:rf.route/entry-denied, matching the existing blocked-navigation convention.
An application may replace it with an auth redirect or other policy handler; it
is not required to register a handler merely to make denial safe. With the
default, client denial is a hard deny: the current route and URL remain in
place (or the URL is restored after popstate), and no protected activation work
runs.
For a matching raw-URL request, :destination is the canonical named address
recovered from the resolved target and :requested-url preserves the input. An
unmatched in-app raw URL remains the raw destination because rewriting it as the
registered not-found route's address would change the URL on replay.
An authentication flow stores the named :destination, navigates freshly to
login (normally with :replace? true), and after successful sign-in dispatches
a fresh :rf.route/navigate with the stored destination. The guard re-evaluates
because this is a normal new attempt:
(rf/reg-event :rf.route/entry-denied
(fn [{:keys [db]} [_ {:keys [destination]}]]
{:db (assoc-in db [:auth :return-to] destination)
:fx [[:dispatch
[:rf.route/navigate
{:to :route/login :replace? true}]]]}))
;; after a successful sign-in:
[:rf.route/navigate (get-in db [:auth :return-to])]
The example shows the client arm. On a server frame the application handler
normally emits Spec 011's canonical
:rf.server/redirect effect to the login
URL. Its redirect-precedence
contract truncates HTML and replaces the default 403 response; merely
rendering the login route inside the same server frame intentionally keeps the
403 unless the handler also changes the response.
For a group of protected routes, application code may use a small route-metadata
helper that associates the same :can-enter subscription with each
registration. The subscription receives the resolved target and may inspect its
route id/tags. This is the canonical cross-route recipe; the router does not add
a middleware chain merely to avoid an ordinary map helper. General registered
interceptors remain available for application-wide event policy, but routing
correctness never depends on attaching equivalent logic separately to every
navigation door.
Initial load and SSR take the same denial path. On a server frame, denial stamps
a default 403 response before dispatching the denial event. Because re-frame
drains that handler before render, an application may replace the result with
the canonical :rf.server/redirect response (normally to login) or explicitly
set another status under Spec 011's
standard response effects and
multiple-status policy. If no
replacement navigation or redirect is established, the protected route remains
uncommitted and the host renders the ordinary application shell under 403
against the unchanged route projection (absent on a first request). No
resource or hydration data for the denied target is produced. R4 must graduate
this hard-deny arm into Spec 011 rather than leaving HTTP behavior to adapter
guesswork.
The current :rf.route/entry-blocked event, enter-shaped pending state,
:enter-attempts, enter-resume loop ceiling, and :bypass-guards? #{:enter}
surface are removed. The router no longer models login as a paused transition
that can be resumed out of its original causal context.
Explicit query-state flow¶
Query values remain first-class typed route state:
- route
:queryschemas validate/coerce input; - route
:query-defaultsremain destination-local declaration; - a
RouteAddressmay carry a complete destination query; - in-place
:queryreplaces the current query; and - in-place
:query-mergeapplies explicit deltas, withnilremoving a key.
Route metadata :query-retain is removed. A destination address no longer gains
hidden values from whichever route happened to be current.
Applications that deliberately carry global URL state use an ordinary named pure function, making the policy visible and testable:
(defn with-shell-query [current-query address]
(update address :query
(fn [destination-query]
(merge (select-keys current-query [:locale :tenant])
(or destination-query {})))))
The explicit destination query wins in the example. An app may apply the helper
at selected call sites or in its own event/interceptor when the policy is truly
application-wide. The framework does not add generic query middleware,
functional query updaters, or a second update-query event. The existing
in-place edit is already the causal primitive.
Deleting :query-retain does not change the query parser, defaults, validation,
+ semantics, or fragment behavior. A key previously promoted to a keyword
solely because :query-retain named it now remains a string unless the route's
:query schema or :query-defaults declares it. R5 must migrate those
declarations explicitly rather than preserving keyword promotion as hidden
residue. The same cut removes :query-retain from the promotion vocabulary
used by the :rf.warning/route-classification-query-key-unpromoted advisory and
updates its fixtures/advice text to name only :query and :query-defaults.
View and link consequences¶
The root view continues to select and compose views from :rf.route/id and
:rf.route/chain. Parent-chain resource planning does not imply parent-chain
render ownership. There is no <Outlet>, route component slot, provider tree,
loader-data hook, suspense boundary, or route-local error component.
The passive-view law is practical rather than ceremonial:
- reading
:rf.route/*and:rf.resource/*subscriptions in render is correct; - pure formatting and local calculation in render are correct;
- dispatching navigation from a click/keyboard/host handler is correct;
route-link :prefetch :intentmay dispatch from that interaction;- calling
fetch, resourceensure, navigation, or dispatch during render is not a supported pattern; and - no public router API is added whose only ergonomic use requires causal render.
Active navigation styling remains a projection: compare a link address/route id
with :rf.route/id or membership in :rf.route/chain, then pass ordinary
class/ARIA attributes to route-link. A short guide recipe is preferred over a
NavLink mini-language.
Background-location/modal navigation is likewise a state-model recipe: put shareable modal identity in query/path and keep non-shareable presentation context in app state. History-state masking is not added because it is not server-addressable and would create a second location truth.
Tooling and observability¶
The plan must be legible without executing view code. Xray/trace projection shows:
- source address and cause;
- resolved target and parent-to-leaf branch;
- each route's contributed resource requirements;
- local
:afteredges and plan order; - identity groups and dedupe;
- old/new kept/added/removed identities;
- blocking/readiness state;
- resource owners and activation/prefetch causes; and
- any leave block, entry denial, or planning failure.
This projection graduates with the planner. R0 exposes only the source, resolved target, cause, branch, and behavior-preserving leaf plan needed to prove the shared spine. R2 adds occurrence/dependency groups, contributor dedupe advisories, the grouped order, and the identity diff. R6 proves the integrated view. No slice is permission to build a second Xray graph or a general-purpose public plan debugger.
Erratum — 2026-07-27 (rf2-dlkou, recording the rf2-9sluz ruling). R2's identity diff ships as the identity partition on the existing activation
:rf.resource/route-planrow —:ensured-identities/:kept-identities/:removed-identitiesbeside the:ensured/:kept/:removedcounts, with:identitiescarrying the planner's grouped plan order. The vectors are named for what the runtime did, not for the diff: a retained-but-unusable identity takes the ordinary ensure path, so a vector named:addedwould disagree with the:ensuredcount beside it. Everything else in the bullet list above is deliberately not projected: occurrence/dependency groups, the per-contributor requirement mapping, and local:afteredges are internal planning mechanics, and Xray's static route/resource graph, the planning-failure evidence, and:redundant-childrenare the authorities for what a reader would otherwise want from them. No route-plan panel, no public plan value, and no eleventh conformance row — conformance for the correction is the enriched shape pinned in the R2 partial-activation fixture plus the shape-driven egress test. A future EP that graduates a plan projection owns its own row.Erratum — 2026-08-03 (rf2-dlkou, audit of the shipping PR). The partition is computed on resource identity — the canonical bytes of the whole scoped key — and not on host value equality. The two differ: canonical bytes preserve a collection's kind, so a vector-bearing and a list-bearing parameter are two cache entries that Clojure
=calls one. Computing the diff on=reported a removed identity as still present (:removed 0against a real removal) and could match a prior identity to its byte-distinct twin when deciding adoption. See Spec 016 §Plan diff and owner handoff. Two carriers either side of the planner still collapse the same pair — the occurrence dedupe that builds the plan's grouped order, and the set-shaped[:rf.runtime/routing :resource-plan]/:resource-blockinghandoff slots — so a single plan cannot yet contain both members and the live handoff cannot yet deliver both. Closing that moves a documented cross-feature slot shape and is tracked separately (rf2-btdl1); it is a dispatch question rather than a trace-contract one, and it does not reopen this erratum.
:rf.resource/route-plan is the existing Spec 016 route/resource graph
operation and is extended rather than replaced by a parallel trace. Prefetch
emits one :rf.route/prefetched summary trace; its plan trace carries
:plan-cause :prefetch, and its ensures carry
:cause [:route-prefetch <destination-route-id>]. The underlying ensures retain
their normal resource traces.
The Spec 009 graduation roster is explicit:
- new or renamed: event
:rf.route/prefetch, event/trace:rf.route/entry-denied, trace:rf.route/prefetched, and error:rf.error/prefetch-bad-address; - retained and, where needed, extended: errors
:rf.error/navigate-bad-request,:rf.error/route-url-validation,:rf.error/can-leave-non-boolean,:rf.error/can-enter-non-boolean,:rf.error/resource-route-plan,:rf.error/resource-route-blocking, and:rf.error/resource-ssr-blocking-timeout; event/trace:rf.route/navigation-blocked; and trace:rf.resource/route-plan; and - retired with no aliases: event/trace
:rf.route/entry-blocked, error:rf.error/route-guard-loop, internal events:rf.route.internal/settle-transitionand:rf.route.internal/on-match-error, the:rf.route/on-match-error-traplistener id, the:rf.route/on-match-id/:rf.route/on-match-frameattribution fields, and the internal:rf.route/enter-attemptsresume rider.
The retained :rf.error/resource-route-plan schema is widened deliberately for
the shared planner. Its tags require :route-id and :plan-cause (one of
:link, :navigate, :popstate, :initial, :ssr, or :prefetch);
:nav-token is present only for an activation attempt, and :resource-id is
present only when one declaration owns the failure. The existing :cause field
keeps its existing meaning—underlying failure/ex-data—and is never overloaded
with :prefetch; a plan-wide collapse cycle records its projected
identity-group and contributor/local-id evidence there. The
:rf.resource/route-plan trace uses the same :plan-cause vocabulary.
Erratum — 2026-07-26 (rf2-8yx7g).
:plan-causegraduated as an optional tag whose only member is:prefetch, on both the widened:rf.error/resource-route-planerror and the:rf.resource/route-plantrace. It is present on a warm-mode intent-preload row and absent on an activation row; the five activation causes (:link,:navigate,:popstate,:initial,:ssr) never appear on either. The paragraph above is corrected to that shape; the ratified requirement that the widened tags carry:route-id, that:nav-tokenride only an activation attempt, that:resource-idride only a single-declaration failure, and that:causenever be overloaded with:prefetch, all stand as written.Why the narrower shape is the right one.
:plan-causeis not a lossy subset of the door vocabulary — it is its complement. Every activation planning failure is preceded, in the same drain and the same commit branch, by the:rf.route/plannedtrace, which already carries the door under:causedrawn from exactly those five values;commit-navigationhas only two callers (routing/navigate.cljcandrouting/url_change.cljc) and both emit:rf.route/plannedimmediately before consulting the resource plan. Prefetch is the one planning path with no:rf.route/plannedtrace at all — warmup is not activation — so it is the one path whose cause is not otherwise on the bus. Carrying the door cause a second time on the activation rows would also require widening the:routing/on-route-entryhook contract to thread it: the hook hands the planner{:route-meta :route-id :params :query :fragment :nav-token :prev-id :prev-nav-token :ctx :app-db :runtime-db :branch :branch-error :prev-identities}and deliberately no door cause, because routing does not ask the Resources artefact to reason about which door it came through. One fact, one name, one place (Conventions.md§The naming rules).Normative home: spec/Spec-Schemas.md §
ResourceRoutePlanTagsand the spec/009 catalogue row. The 009 rows were brought into line with this shape by rf2-wsopx, for which this erratum is the prerequisite.
The stray :rf.error/resource-route-plan-failed spelling in Spec 012 is not a
second category; graduation replaces it with the existing canonical
:rf.error/resource-route-plan. Route metadata :on-error and
:query-retain, plus request policy :bypass-guards?, are retired input
surfaces rather than error ids.
All diagnostic values cross the existing projection/elision boundary. A trace must not turn a sensitive URL/query/resource declaration into a new egress leak.
Conformance obligations¶
Graduation requires executable proof of the following:
- Address parity (R0 foundation; R3 prefetch and R4 replay arms). One table
of valid/invalid
RouteAddressvalues drivesroute-url, navigation,rf/route-link,v/route-link, named entry/pending payloads, and prefetch on JVM and CLJS. Flat wrapper maps prove that only the extracted address reaches the closed schema. Closed request maps reject misspelled controls; both link surfaces strip the exact routing keys and preserve the remaining DOM props without asking routing to classify arbitrary attributes. The raw destination branch is tested separately. Unknown keys and malformed combinations on closed maps—including in-place:paramswith reason:params-requires-destination—fail identically. An in-place{:query ...}request proves the complementary rule: it follows the edit branch and never reaches the closed:rf/route-addressschema. - Door parity (R0 target/leaf-plan spine; R2 effective branch plan). Named address, matching raw URL, link, programmatic, popstate, initial, and SSR inputs resolve to the same target/branch/resource plan. Only cause-specific history and scroll effects differ.
- Passive render (R0; R3 adds the intent arm). Rendering either route-link or a routed view dispatches no navigation, prefetch, resource ensure, or application event. Intent interaction dispatches at most the expected prefetch/navigation events.
- Branch composition (R2). Automatic composition for every declared
:parent, parent-to-leaf constraints, local:afterscope,:when, params/scope failure, and contributor diagnostics are pinned. Same-route and parent/child occurrences that dynamically converge on one identity exercise grouped ordering: satisfiable graphs dispatch once without losing an edge, collapse-created cycles fail the whole plan, and a redundant child copy appears as an advisory on:rf.resource/route-plan. Every planning-failure arm attaches and ensures none of the partial next plan and releases owners held only by the previous plan after the failed target/error commit. - Partial activation (R2). Sibling-leaf navigation retains unchanged parent identities, attaches before release, does not abort shared in-flight work, ensures newly added identities (including a requirement whose resolved identity changed), and releases removed identities.
- Readiness honesty (R1 over the leaf plan; R2 over the branch plan).
No-resources, blocking first load, non-blocking load, cached fresh data,
:fetchingwith usable data, background-refresh failure, planning failure, first-load failure, previous-data projection, supersession, restore, and hydration all project the specified transition and error. Refresh never flips the route to:loading; projector reconciliation corrects any cached fields.:on-matchnever changes readiness and is not dispatched when planning fails. - Prefetch isolation (R3). Prefetch runs the full effective resource plan
ownerlessly in the dispatching frame, dedupes repeated intent, remains
GC-eligible, reuses work on activation, and runs no guard, URL, route-state,
scroll,
:on-match, sibling-frame, or SSR side effect. Both link surfaces preserve caller intent handlers and target their render-time-captured frame; prefetch planning failures carry:plan-cause :prefetchand no nav-token. - Guard parity (R4). Leave and entry decisions cover link, programmatic,
popstate, initial, and SSR doors. Exact no-ops run neither guard; full
transitions—including changed in-place query—run both in order;
fragment-only transitions preserve the existing both-guard contract. Leave
alone creates pending state. Entry denial never does. The framework default
denial handler is safe and no-op; client/popstate preserves or restores the
current URL; SSR hard denial with no replacement produces
403; and an application redirect supersedes that default. A post-login return is a fresh address navigation. Leave continuation preserves the stored destination, explicit replace/scroll policy, cause, and restored-popstate URL behavior. - Query explicitness (R5).
:query-retainis rejected at registration; defaults and in-place merge remain; every in-tree call site, fixture, promotion source, and classification-advisory reference is migrated; and every cross-route carried key appears in the authored address after the application's pure carry helper runs. - Frame isolation and teardown (established in R0, exercised by every slice, integrated in R6). Plans, owners, prefetch work, pending leave state, traces, and host listeners stay frame-scoped and release completely on frame destroy.
The normal repository JVM/CLJS conformance, unit, integration, SSR/hydration, browser-link, schema/status-sync, lint, and build gates must stay green. New fixtures belong beside the owning specs, not only in implementation tests.
Graduation slices¶
Implementation is delivered as thin vertical slices. Each slice includes its owning spec edits, schemas, implementation, fixtures/tests, traces, and affected guide/examples; there is no spec-only waterfall and no one giant router rewrite.
| Slice | User-visible outcome | Depends on |
|---|---|---|
| R0 — address and plan spine | :rf/route-address, shared address extraction/validation across navigation, route-url, rf/route-link, and v/route-link; one resolved-target/plan seam used by every door; the minimum source/target/cause/branch/leaf-plan diagnostic projection; passive-view governing-law docs and fixture; existing runtime behavior otherwise preserved |
accepted EP |
| R1 — honest activation | :on-match fire-and-forget only after a valid plan; resource-derived transition/error; removal of global on-match correlation and route :on-error; SSR wait semantics and the post-v1 :load seam note corrected |
R0 |
| R2 — branch resource plan | parent-to-leaf resource composition, constraint-preserving grouped identity dedupe and redundant-child advisory, plan diff, attach-before-release, partial revalidation, SSR/hydration parity | R0, R1 |
| R3 — intent prefetch | :rf.route/prefetch; :prefetch :intent on both rf/route-link and v/route-link; frame-scoped ownerless full-plan warm ensures; traces and reuse/GC proof |
R2 |
| R4 — terminal entry | leave-only, policy-preserving pending protocol; guard behavior for full, fragment-only, and no-op transitions; terminal :can-enter; :rf.route/entry-denied; default hard-deny/SSR 403; fresh auth return; all-door/SSR coverage; auth how-to and RealWorld update in the same cut; obsolete resume/loop machinery removed |
R0; serialized after R3 by default |
| R5 — explicit query carry and surface cleanup | remove :query-retain, preserve defaults/in-place edits, migrate every in-tree retain call site/fixture and every promotion/advisory dependency it supplied, ship the explicit carry recipe, reject obsolete metadata/policy keys, final API/schema/error inventory |
R0; serialized after R4 by default |
| R6 — integrated proof | all conformance rows and guides coherent; RealWorld or equivalent routed app proves parent shell reads, leaf reads, intent warmup, auth denial/return, SSR, and no render-caused work | R1–R5 |
The default landing order is R0 → R1 → R2 → R3 → R4 → R5 → R6. Every
contract slice touches the hot-zone spec/012-Routing.md, so those edits and
their branches are serialized even where the runtime concepts are otherwise
independent: at most one open PR may edit Spec 012. Work may proceed
concurrently only on fenced implementation or guide surfaces that do not touch
Spec 012 and only after the corresponding 012 ruling has landed. R3 waits for
the effective plan because prefetching a leaf-only approximation would make the
temporary behavior the wrong contract. R4's table position after R3 encodes
this hot-zone lane, not a logical dependency on prefetch; the operator may move
R4 earlier after R0 if terminal-entry/auth honesty is the higher priority,
provided the same single-PR Spec 012 fence is preserved.
Future beads should be cut smaller than these rows where useful, but every bead must name the vertical outcome, exact spec/conformance clauses, affected hosts, migration/deletion work, and commands/evidence that close it. Beads are created after this proposal is audited and accepted; this EP does not pre-create or pretend to resolve their implementation details.
A slice is complete only when the conformance arms mapped to it above are green
on their named hosts and its schema/API/error migrations land in the same cut.
The mapping is the EP's acceptance statement; approximate file ownership and
the eventual bead topology remain live-tree implementation planning in bd,
not a second plan frozen into this proposal.
Deliberate non-goals¶
This programme does not add:
- route-owned loader data, cache, freshness clocks, invalidation, mutations, or optimistic writes;
- component-scoped fetching,
useLoaderData, route error components, Suspense ownership, or render-driven ensures; <Outlet>, automatic layout rendering, provider/context inheritance, or generic parent metadata merge;- route actions, form/fetcher protocols, automatic mutation revalidation, or a server-function transport;
- file-system route authority, route code generation, or automatic code splitting;
- relative route-address semantics, pathless layout routes, or ranking surgery;
- query middleware, ambient cross-route retain, history-state masks, or a
NavLinkbehavior DSL; - render/viewport prefetch, prefetch delays, a preload cache, or SSR prefetch;
- a routing-specific guard middleware chain or a resumable entry protocol (general registered interceptors remain unchanged); or
- a public general-purpose route-plan executor.
These features are not forbidden forever. Each requires a concrete re-frame2 consumer that cannot be served elegantly by addresses, events, subscriptions, resources, ordinary view composition, or a small app helper.
Guide impact¶
docs/core/views.md, docs/core/where-state-lives.md, and
docs/core/derivations-and-algebra-views.md gain the explicit rule that views
are derivative projections and that interaction handlers—not render—cross the
causal boundary. The routing tutorial/concepts/examples/testing pages are
rewritten around RouteAddress, effective branch plans, honest readiness,
leave-only pending navigation, terminal entry, explicit query carry, active-link
projection, and intent prefetch. The auth and unsaved-change how-tos split the
fresh-entry and resumable-leave recipes. The unsaved-change how-to pairs the
current route's :can-leave decision with a host beforeunload listener over
the same dirty-state source for hard reload/cross-origin exits; that listener is
a host-adapter recipe, not a second router confirmation API. Resource guides
teach route plans as owner/cause declarations, and SSR guides teach blocking
resources as the only route-owned wait point. “Coming from” guides explain the
adopted capabilities without importing outlet, hook, cache, action, or
middleware ownership. R0 owns the three governing-law view-doc edits and their
passive-render fixture; they are not left to the final cleanup slice.
Rationale¶
Planning is the useful common denominator¶
A router is the earliest part of the application that knows a likely destination. That makes it the natural place to resolve an address, validate URL state, enumerate the active branch, decide whether the transition may proceed, and describe required reads. It does not follow that the router should own the read cache, application mutations, or component tree.
Making the plan explicit captures the leverage without creating a second framework. It also turns several correctness claims into one testable object: every door must produce the same target and requirements, prefetch must use the same requirements as activation, SSR must wait on the same blocking subset, and Xray can explain why work exists.
Managed resources are the honest readiness boundary¶
An ordinary event can emit any effect, including effects unknown to the routing artefact. Even if the initial handler runs synchronously, later callbacks may fork, retry, dispatch more events, elide payloads, or complete after another navigation. Correlating a global error record by event-vector equality cannot turn that open graph into owned work.
Resources already define identity, ownership, work ids, generations, freshness, first-load versus refresh errors, cancellation, GC, SSR projection, and passive reads. Letting route metadata declare resource requirements therefore produces a real wait/error boundary without inventing router promises or hiding app state inside a loader result.
:on-match is still valuable. Narrowing its promise makes it more useful, not
less: programmers can use a simple activation event without accidentally opting
into a fictional page-loader FSM.
A plan failure commits addressable target facts so an error projection can
explain where navigation failed, but it does not cross the successful activation
boundary. Suppressing :on-match in that case avoids false page-view analytics,
host notifications, and state seeding for requirements that never ran. The
structured planning error is already the honest observation seam.
Branch data composition need not imply branch rendering¶
Shared layouts commonly need shared data. Repeating the same resource declaration in every child is noisy for humans, hostile to mechanical review, and makes prefetch incomplete unless every copy stays aligned.
The existing :parent chain already names the relevant branch. Folding only
:resources along it yields the high-value behavior while preserving ordinary
Clojure view composition. A generic metadata fold would force merge laws for
head, scroll, tags, guards, events, and future keys that have different
semantics. The fixed resource-only fold avoids that abstraction trap.
Automatic resource composition follows from the meaning of :parent: a child
that renders within a parent shell participates in the branch whose shared reads
the parent declares. Requiring :inherit-resources? true on every child would
make the effective plan depend on duplicated acknowledgements, and a missed
acknowledgement would fail silently in navigation, prefetch, and SSR. The route
table remains readable as data because :parent points directly to the
contributor chain and tooling projects the effective plan. This convenience is a
deliberate semantic expansion, not generic metadata inheritance.
Entry and leave are different temporal problems¶
A dirty-form decision is about state that exists now: pause this attempt, show UI, then continue exactly once or cancel. Pending frame state is a good model.
An entry gate is about whether the target is allowed under current app state. If sign-in changes that state, the correct operation afterwards is a fresh navigation that re-resolves defaults, scope, route registration, and policy. Keeping the old attempt pending across a login introduces stale causal context, an enter-bypass vocabulary, attempt counters, and loop handling merely to recreate a new decision.
The proposed boolean gate plus a structured denial event is the smallest useful surface. A richer declarative redirect result is considered below, but it should not be added until it proves simpler than the ordinary event recipe across client and SSR hosts.
The server's no-handler 403 default says exactly what happened: a registered
target matched and policy denied entry. Applications that intentionally conceal
resource existence may replace it with their ordinary not-found/404 response;
auth flows normally replace it with a redirect. The router supplies a safe,
deterministic floor without trying to infer either application policy.
Explicit addresses improve human and AI ergonomics¶
A plain map can be stored, compared, generated, tested, traced, and passed through events without a router object or view context. Reusing it across links, navigation, return-to state, URL generation, and prefetch makes examples transferable and lets tools validate a destination before it is mounted in UI.
Keeping address, policy, and edit as conceptual/schema boundaries prevents
accidental serialization of :replace?, :scroll, or :query-merge as
location identity. Keeping the public call flat avoids ceremony at the common
dispatch/link sites.
Ambient query carry has the opposite property: the address under review is not the address produced. A five-line app helper is more honest and more adaptable than a router-wide middleware language. Destination defaults remain because they are stable facts of the named destination rather than accidental facts of the route being left.
Intent prefetch belongs above Resources and below views¶
Per-resource hover handlers make every call site reimplement target params, scope resolution, conditions, branch inheritance, and dedupe. View-render fetching is worse: it makes speculative rendering causal and cannot distinguish mere visibility from user intent.
The route planner already has the complete requirement set, and Resources already has the ownerless cause-only ensure needed to warm it. One intent event connects those existing responsibilities. Avoiding a mode/timing matrix keeps the feature a hint rather than a scheduling subsystem.
Alternatives considered¶
| Alternative | Disposition |
|---|---|
| Keep leaf-only resources and use app helpers for parents | rejected; hides the effective plan and duplicates declarations/prefetch logic |
Require :inherit-resources? true on each child |
not recommended; duplicates the meaning of :parent, creates silent incomplete-plan omissions, and becomes noisier down deep branches; acceptance must explicitly ratify automatic composition |
| Inherit all parent metadata | rejected; unrelated keys need incompatible merge/ordering rules |
| Sort occurrences, then dedupe by keeping the first identity | rejected; a later duplicate may carry an :after edge that first-occurrence selection silently erases; collapse the occurrence graph and fail closed on an incompatible cycle |
Make :on-match an awaited promise/loader API |
rejected; creates a second async/cache model and makes events return router-owned data |
| Keep global event-error correlation | rejected; event-vector coincidence is not causal ownership |
Remove :on-match entirely |
rejected; ordered activation events are a small, useful event-native seam |
Dispatch :on-match after resource planning fails |
rejected; the target is addressable for error projection but did not successfully activate, and application effects would report or seed a page whose requirements never ran |
| Put route data in a router cache | rejected; Resources is the one server-read owner |
| Trigger ensures from views | rejected; violates passive render and loses SSR/prefetch planning |
| Keep resumable entry and improve its loop handling | rejected; the machinery treats a fresh policy decision as continuation |
| Return a closed allow/redirect/deny value from entry policy | viable future refinement; see Open Issue 1 |
| Run guards before transition classification | rejected; an exact no-op could create pending/denied state even though no target fact changes |
| Let every in-place request bypass guards | rejected; changed params/query are data-bearing full activations regardless of request spelling, while exact no-op and fragment behavior are ruled explicitly |
| Run entry guards during prefetch | rejected; warmup is not activation or an authorization boundary |
| Add render/viewport/default prefetch modes now | deferred pending measured demand |
Keep :query-retain or add generic search middleware |
rejected; final addresses become ambient transformations |
| Add nested address/policy envelopes | rejected for the public paved path; internal schemas already preserve the distinction |
| Keep direct leave bypass runtime-internal | not recommended; it forces trusted workflow code to contort guard source state instead of naming the exceptional policy honestly |
| Add Outlet/NavLink/masking/file routes/actions for parity | rejected; each imports ownership that conflicts with ordinary subscriptions, events, resources, and view composition |
| Expose the whole plan as a public promise API | deferred; traces/tests need a data projection, applications do not yet need an executor |
Backwards Compatibility¶
The project is pre-alpha, so this is a direct contract migration with no deprecated aliases:
- existing named destination maps already use the future
RouteAddressfields; they gain a schema/name and shared validation rather than call-site ceremony; - old
:query-retainmetadata fails loudly; applications replace it with an explicit address helper or event/interceptor, and R5 removes every in-tree retain-dependent call site and fixture in the same cut; :query-defaults, query schemas, destination:query, in-place:query, and in-place:query-mergeremain;:on-matchdeclarations remain valid, but no longer drive route transition/error or SSR asynchronous waiting and do not dispatch for a failed resource plan;- route
:on-erroris removed; managed page-read errors move to resource projections, and application-owned work keeps application-owned error state; - declaring
:parentnow opts a route into automatic ancestor:resources. This is a deliberate semantic expansion for every existing parent chain, not a compatibility-preserving opt-in marker. Child copies resolving to the same identity dedupe when their collapsed constraint graph is valid, and the plan trace advises on the redundant child contributor; applications should delete those copies so the plan has one clear declaration; :rf.route/entry-blockedbecomes:rf.route/entry-denied; enter-shaped pending values,continue-after-login, attempt counters, loop errors, and enter bypass are removed;- pending navigation becomes leave-only and stores
:destination/:target/:causeplus normalized explicit:policyinstead of the original event as its replay description, so confirmation does not discard replace/scroll intent; - subject to the acceptance ruling in Open Issue 3,
:bypass-guards?becomes the public boolean:bypass-leave?; - route transition/error reads keep their names but change to managed-resource semantics; and
- prefetch is additive and opt-in.
Each vertical slice updates all in-tree registrations, RealWorld/example apps, conformance fixtures, errors, schemas, and guides that use the changed contract. Old route metadata and policy keys are rejected at registration/request validation so a stale example cannot silently keep donor semantics.
No compatibility shim may correlate :on-match errors in the background, copy
:query-retain into addresses, synthesize an enter pending value, or expose both
old and new entry events. Such shims would preserve the ambiguity this EP exists
to remove.
Resolved Decisions¶
Accepted by the operator on 2026-07-24. All four Open Issues below were resolved as recommended; their sections are retained for the design record.
- Entry decisions (OI-1, 2026-07-24).
:can-enterstays a boolean subscription over the resolved target, paired with the terminal:rf.route/entry-deniedevent and its framework no-op default handler. No closed decision-value algebra: the default hard-deny403, Spec 011 redirect supersession, and the ordinary redirect event cover the SSR cases. - Readiness fields (OI-2, 2026-07-24).
:transition/:errorremain in the public:rf/routeread, derived through one pure projector; runtime-db may cache the projected values only as a reconstructible, non-authoritative cache, reconciled through the projector on hydration, epoch restore, and every resource settlement. R0/R1 implement this shape; they do not revise it. - Leave bypass (OI-3, 2026-07-24). The public boolean
:bypass-leave? trueships as the explicit trusted-programmer escape;:rf.route/continueremains the ordinary confirmation path. The set-valued:bypass-guards?and every entry bypass are removed. - Branch resource opt-in (OI-4, 2026-07-24). Automatic parent-to-leaf
resource composition: declaring
:parentIS the opt-in — no:inherit-resources?marker. The effective-plan projection and the redundant-child advisory keep the inherited contribution and accidental child copies mechanically visible.
Acceptance also knowingly ratifies, per the Open Issues preamble: the closed
extracted :rf/route-address; the RouteDestination / :rf/route-destination
replay union; :rf.route/entry-blocked → :rf.route/entry-denied; the
leave-only pending value's :destination/:target/:cause/:policy shape;
:bypass-guards? → :bypass-leave?; the prefetch event/error/trace ids; the
shared planner's :plan-cause diagnostic vocabulary and the widened
:rf.error/resource-route-plan tags; and the exact retirement roster under
Tooling and observability.
Implementation programme: epic rf2-kqxe6 — A0 (this record), R0–R6, the
docs/examples/skills waves, and three independent review gates (correctness,
completeness, implementational quality).
Open Issues¶
Resolved at acceptance (2026-07-24) — every recommendation below was accepted as written; see Resolved Decisions above. The sections are retained as the design record of each choice and its rejected alternative.
Acceptance must also knowingly ratify the closed extracted
:rf/route-address; the RouteDestination/:rf/route-destination replay
union; :rf.route/entry-blocked → :rf.route/entry-denied; the
leave-only pending value's
:destination/:target/:cause/:policy shape;
:bypass-guards? → :bypass-leave?; the prefetch event/error/trace ids; the
shared planner's :plan-cause diagnostic vocabulary and widened
:rf.error/resource-route-plan tags; and the exact retirement roster under
Tooling and observability. These are not a later “cleanup” bundle or accidental
consequences of the headline features.
1. Boolean :can-enter plus denial event, or a closed terminal decision?¶
The recommendation is the specified boolean subscription plus
:rf.route/entry-denied. It preserves the current state-shaped guard, keeps
causal redirect work in an event, and is sufficient for auth, feature flags,
maintenance denial, client navigation, and SSR.
The alternative is to replace :can-enter with a pure closed result such as
:allow, {:redirect RouteAddress :replace? true}, or
{:deny reason}. It would make initial/SSR redirect terminal without relying on
an event handler, but adds a policy algebra, reason/redirect schemas, recursion
rules, and another place that causes navigation. The specified default no-op,
hard-deny 403, and ordinary redirect event cover the demonstrated SSR cases
without that algebra. The recommended acceptance ruling is therefore the
boolean plus denial event.
2. Where do the readiness projection fields live?¶
The observable transition/error table is not open. The recommended acceptance
ruling keeps the fields in the public :rf/route read, derives them through one
pure projector, and permits runtime-db caching only as a reconstructible,
non-authoritative cache. Hydration, epoch restore, and resource settlement must
reconcile through the projector.
The alternative removes them from stored :rf/route-slice and joins them only
at the subscription/tool boundary. That eliminates a cache but requires a
durable active-plan descriptor immediately. Acceptance must choose now; R0 and
R1 must not discover or revise the public/read schema while implementing it.
3. Should direct leave bypass remain public?¶
The recommendation is the explicit boolean :bypass-leave? true: re-frame2
trusts the programmer, and rare workflow code may know it has already handled
the consequence that :can-leave represents. :rf.route/continue remains the
normal confirmation path.
The smaller alternative makes bypass runtime-internal and requires application
code to change the guard's source state before navigating. The recommended
acceptance ruling keeps the public boolean. The set-valued :bypass-guards? and
any entry bypass are removed in either case.
4. Does :parent itself opt into ancestor resources?¶
The recommendation is automatic parent-to-leaf resource composition:
:parent declares membership in the active branch, so shared requirements
declared by that branch apply without a second marker. It keeps common
registrations terse and prevents a forgotten flag from making navigation,
prefetch, and SSR disagree with the rendered parent shell. The effective-plan
projection and the redundant-child advisory on :rf.resource/route-plan keep
the inherited contribution and accidental child copies mechanically visible.
The alternative requires :inherit-resources? true on each child. It preserves
the runtime behavior of existing :parent registrations and makes the choice
visible locally, but duplicates the branch relation and makes every descendant
an omission point. Acceptance must explicitly choose; this EP recommends that
the pre-alpha project take the coherent semantic expansion and treat :parent
as the opt-in.
References¶
- EP-0009 — The EP Process
- EP-0003 — Resource Queries
- EP-0016 — Resource Mutation Completion, Scoped Invalidation Targets, And Named Scope Resolution
spec/009-Instrumentation.mdspec/011-SSR.mdspec/012-Routing.mdspec/016-Resources.mdspec/Spec-Schemas.md- TanStack Router — data loading
- TanStack Router — external data loading
- TanStack Router — preloading
- TanStack Router — search params
- TanStack Router — link options
- React Router — routing
- React Router — data loading
- React Router — pending UI
- React Router — navigation blocking
- React Router —
Link - EP authoring record: bead
rf2-o0dc1. The implementation programme is intentionally not created until this proposal has been audited and accepted.