Skip to content

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 in Audit_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, WebTransport or sendBeacon outside src/network/.” This one is mechanised: tools/check-network-boundary.mjs fails pnpm run build on 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.

  1. 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.
  2. A queryFn MUST NOT call fetch or construct any transport primitive. It calls a named function exported from src/network/ and does nothing else with the network.
  3. 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.
  4. 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 queryFn from the boundary check (by path, by filename suffix, or by an eslint-disable). Rejected: a mechanised boundary with a named exemption is a reviewed boundary, which is two rungs down P3’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 gives src/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, isFetching on the component that asked. Behind a MessagePort it 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.ts gains a second category of export beside createNetworkClient. Its header’s claim — the application imports from here and from nowhere else inside src/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 dynamic import() 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 queryFn actually calls into src/network/. A queryFn returning a constant is legitimate — createFixtureVaultSource is 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-089 bounds 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.mjs rule 2 — pre-existing, unchanged, and already a pnpm run build gate. A queryFn that calls fetch, or constructs a WebSocket, EventSource, WebTransport or sendBeacon outside src/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.