Server State Without a Fetch in the UI — ADR-125
Server State Without a Fetch in the UI — ADR-125
One ruling from Phase 3’s second frontend workstream (2026-08-09, owner ruling). Building ADR-123’s Personal Vault surfaced a genuine contradiction between two normative rules that had never been exercised together, because until the play client’s UI existed there was no code that had to satisfy both. This record disposes of it.
It is not an audit-round finding and mints no
R*-F-*number. Those namespaces belong to registered review angles inAudit_Angles_Tracker.md; this was found by an implementer at the seam, which is the same route ADR-124 came by. The contradiction is stated in Context rather than filed, so it is still findable by the sentence that describes it.
ADR-125 — Server State Is Cached in the UI and Fetched in the Network Layer: queryFn Is an Inversion Point, Not an Exemption
Status: Accepted · Date: 2026-08-09 · Narrows §9.3 (which says where TanStack Query belongs, but not how it may reach the network) · Depends-on 053 (the worker topology and the main-thread network prohibition this preserves) · interacts with 029, 099, 123
Context
Two rules in frontend-guidelines.md are individually normative, individually enforced, and
appear to contradict:
- §8 (from §9.3): “Wrap all HTTP server state in TanStack Solid Query. Never hand-roll a fetch cache.” §9.3 states where it belongs so it is not re-proposed elsewhere — “the low-frequency HTTP surfaces — catalogue, library, account — which are ordinary server state.”
- §4 rule 2 (ADR-053, §9.6.1): “The main thread never touches the network. No
fetch,WebSocket,EventSource,WebTransportorsendBeaconoutsidesrc/network/.” This one is mechanised:tools/check-network-boundary.mjsfailspnpm run buildon a violation.
TanStack Query performs its request inside the queryFn the caller supplies, on the thread that
calls it. So the obvious way to satisfy §8 — a queryFn beside the component that consumes it —
puts a fetch in the UI layer and fails the build. The contradiction was latent for as long as
the client had no HTTP surface. The Personal Vault’s listing is precisely §9.3’s “library”, so
it is the first code that has to satisfy both, and it was written against a VaultSource
interface with the question recorded at the seam rather than answered by a skeleton with no
server to talk to.
Decision
Inversion of control. The cache lives in the UI; the I/O lives in the network layer; the
queryFn is the seam between them and performs no I/O itself.
- TanStack Solid Query is used in the UI layer, for what it is for: caching, deduplication, invalidation, retry and request state, integrated with the component tree. §8’s mandate is unchanged and hand-rolling a fetch cache remains forbidden.
- A
queryFnMUST NOT callfetchor construct any transport primitive. It calls a named function exported fromsrc/network/and does nothing else with the network. src/network/owns every request, and its exported functions return plain objects — the same rule §4 rule 3 already states for the wire path, for the same reason: a caller must not receive a view whose validity ends when a buffer is transferred.- A component names no endpoint, no transport and no wire type. It names a function.
The enforcement point is the one that already exists. check-network-boundary.mjs bans the
transport primitives everywhere outside src/network/, so clause 2 is not a rule anybody has to
remember — a queryFn that reaches for fetch fails the build wherever it is written. This
decision required no new check, which under P3 is the strongest form available: the boundary
was already structural and this says how to compose with it rather than carving a hole in it.
What this does NOT settle, stated rather than left to be assumed
It is a MODULE boundary, not a THREAD boundary, and reading it as the latter would be wrong.
check-network-boundary.mjs is syntactic: it proves no module outside src/network/ names a
transport primitive. It does not prove the request executes off the main thread. A function in
src/network/ that calls fetch when invoked from a component runs that fetch on the main
thread, and passes the check.
Whether low-frequency HTTP should additionally be routed through the Network Worker is a separate question and is open. The argument on each side is recorded here so that whoever decides it is not starting from nothing:
- For routing it through the worker: it is the literal reading of “the main thread never touches the network”, and it would make the boundary a thread boundary as well as a module one.
- Against: §5 makes the Network Worker the single arrival authority for a 20Hz snapshot stream, and ADR-053’s own argument is that work queued on that path drains the interpolation buffer. Putting catalogue and library requests into that worker’s message loop puts low-frequency, high-latency traffic behind — or ahead of — the ingest the whole topology exists to protect. §9.6.1’s cost argument is about the hot path, and a vault listing is not on it.
The second reading is the stronger one on the evidence available, and it is deliberately not ratified here, because it is a change to what ADR-053 means rather than a consequence of this decision, and nothing in the tree yet exercises it. When the first real HTTP surface lands, that choice gets its own record.
Alternatives Considered and Why Rejected
- Exempt
queryFnfrom the boundary check (by path, by filename suffix, or by aneslint-disable). Rejected: a mechanised boundary with a named exemption is a reviewed boundary, which is two rungs downP3’s ordering. Worse, the exemption would have to be spelled as a pattern, and every future “just this once” fits a pattern. The check’s value is that it has no exceptions. - Route the vault listing over the 20Hz SignalR stream. Rejected: it is request/response, not replication. §8’s own table says the snapshot path is none of the three offline/sync mechanisms, and §9.3 records that conflating any two of the three is how one ends up unimplemented. It would also put catalogue traffic in the arrival authority’s mailbox, which is the cost the section above declines to pay.
- Drop TanStack and hand-roll a cache inside
src/network/. Rejected by §8 in as many words, and independently: cache invalidation is driven by what the UI is showing, so this moves a UI concern into the transport layer and givessrc/network/a reason to know about routes and screens. - Put TanStack Query itself inside the Network Worker. Rejected, and this is the one a future
reader is most likely to re-propose because it sounds like the tidiest version. TanStack Solid
Query’s value is its reactive integration with the component tree — observers, suspense
boundaries,
isFetchingon the component that asked. Behind aMessagePortit is a cache with no subscribers, and everything that made it worth the dependency has to be rebuilt on the main thread anyway. The library would be paying its bundle cost for the one part of itself that crosses a worker boundary intact.
Consequences (including negative)
- Every HTTP surface needs a named function in
src/network/before a component can consume it. That is friction and it is deliberate — it is the same friction §14.6’s capability injection point imposes, and for the same reason: the alternative is discovering later that there were forty call sites. src/network/index.tsgains a second category of export besidecreateNetworkClient. Its header’s claim — the application imports from here and from nowhere else insidesrc/network— stays true and becomes load-bearing for a second reason.- The boundary check does not see every route to the network. It bans the named primitives; it
does not catch an
<img src>pointed at an API, a dynamicimport()of a remote URL, or a navigation. Stated because this decision leans on that check, and a reader should know the shape of what it covers rather than assume totality. - Nothing verifies that a
queryFnactually calls intosrc/network/. AqueryFnreturning a constant is legitimate —createFixtureVaultSourceis exactly that — so a check for it would have to distinguish a fixture from an omission, and it cannot. The rule is therefore enforced in the only direction that is mechanisable: not by proving the right call happens, but by making the wrong one impossible. - Interaction with ADR-123 clause 2, which is a trap worth naming. Presigned asset-read URLs
are minted per request with authority re-derived from the
RoomGrain, and a revoked grant must stop issuance immediately. TanStack Query caching a listing is correct and is what this decision is for. TanStack Query caching an issued URL would defeat clause 2 outright — a cached bearer capability outlives the membership it was minted against, which is the stale-authorisation shape ADR-085 exists to name. A URL is not server state to be cached; it is a capability to be spent.Q-089bounds the window, and a query cache would ignore it. - TanStack Solid Query is not installed. This binds the change that installs it rather than
describing the tree;
frontend-guidelines.md§2.2 already lists it as specified-not-built.
Enforcement
tools/check-network-boundary.mjsrule 2 — pre-existing, unchanged, and already apnpm run buildgate. AqueryFnthat callsfetch, or constructs aWebSocket,EventSource,WebTransportorsendBeaconoutsidesrc/network/, fails the build. This ADR adds no check because the check that enforces it was already there, which is the argument for the decision rather than an accident of it.frontend-guidelines.md§4 and §8 both carry the pattern, so a reader arriving from either rule finds the composition rather than the apparent contradiction.- Not enforced, stated: nothing asserts that a query cache does not hold a presigned URL. That consequence is a rule for the change that introduces asset reads, and it has no object today because no URL is issued to this client yet. It is recorded here so it is a known constraint rather than a discovered one.
§11 Index Line
ADR-125: Server state is cached in the UI and fetched in the network layer — TanStack Query's queryFn is an inversion point rather than an exemption from the main-thread network prohibition; the cache stays with the component tree, every request is a named function exported from src/network/ returning plain objects, and the pre-existing boundary check is the enforcement point, unchanged.