Skip to content

Networking Conventions ​

Status: Active Last reviewed: 2026-10-06

App-specific protocol layer for how TwoMore keeps data fresh — the four freshness tiers (LIVE / NOTIFY / AMBIENT / ON-DEMAND), the twelve fan-out and UX protocols (A–L), and the three canonical composer hooks. This doc covers the decision rules; docs/best-practices/networking-and-caching.md is the generic deep-dive on TanStack Query patterns, cache shapes, and adapter wiring. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md (ARCH-6, DATA-9..DATA-12).

Freshness tiers — when to use realtime vs push vs focus-refetch vs cache (FOUNDATION) ​

Every data surface belongs to exactly ONE freshness tier, and the tier dictates the mechanism. This is the foundation the Network Protocols below operate within: protocols A-L shape HOW a tier's fetches are bounded; the tier decides WHICH mechanism keeps the data fresh. Origin: a 2026-05-22 deep-dive found realtime (Supabase Postgres Changes) over-applied to low-urgency data — dues, attendance, manner-tags, posts, payments — each costing an RLS check per change per subscriber (Supabase docs: "100 subscribers + 1 insert = 100 reads"), processed single-threaded. Realtime is the most expensive freshness tool and must be rationed.

The decision rule — realtime (a persistent Postgres-Changes channel) is justified ONLY when all three hold:

  1. Actively watched — the user dwells on a screen watching THIS data change (not glance-and-leave).
  2. Latency-critical — seconds of staleness degrade the experience or correctness.
  3. Multi-actor — the change comes from someone else (if you're the only writer, optimistic + local invalidation is enough).

If any condition fails, use the cheaper correct tool. The four tiers:

  • LIVE — focus-gated poll. Actively-watched, latency-critical, multi-actor. No persistent Postgres-Changes channels — the scorecard / match-board / live banner use a focus-gated 10s manual-invalidate poll inside useLiveSessionData (pauses when screen is blurred or session reaches a terminal state: completed/cancelled; resumes on focus via Protocol L). Manual setInterval + invalidateQueries (not per-query refetchInterval) is used because these 6 queries are shared with other screens — a per-query interval would poll them while the scorecard is NOT focused. 60s staleTime is the background fallback. Surfaces: live match scores + score-calls + session status + session RSVP, all via useLiveSessionData. Per-subscriber RLS cost is avoided entirely (no socket channels open).
  • NOTIFY — FCM push + cache-bust. "Something happened, tell me." Mobile OS closes WebSockets on background, so a notification's delivery mechanism is a push notification, not a socket. The in-app badge stays fresh via: the push-received/tapped handler invalidating the keys (use-notification-handler.ts busts signalKeys.active + unreadCount) + the app-foreground catch-up (signals carry 60s staleTime → refetched on resume) + refetchOnMount. Surface: notification bell (signals). No persistent realtime channel.
  • AMBIENT — staleTime + scoped refresh/push. Glance-and-leave shared state on a frequently revisited screen. Primary queries use staleTime: STALE_TIME.realtime (60s); expiry marks data stale but does not fetch continuously mounted observers. New observers follow refetchOnMount. Global refetchOnWindowFocus and refetchOnReconnect are disabled. The root useAppForegroundRefresh coalesces native app-resume and visible browser focus/pageshow events for 500ms, then invalidates only active queries satisfying isStale() (Protocol D). Freshness also comes from awaited screen refresh, push-triggered invalidation and mutation updates. Returning to a retained navigation screen does not itself refetch its queries. Any explicit navigation-focus catch-up MUST target that screen's keys and check staleness. Surface: home session/match/RSVP state. No persistent realtime channels; the home AMBIENT channels were removed 2026-06-01 after measuring their subscriber cost.
  • ON-DEMAND — staleTime + optimistic + invalidate-on-mutation. Not actively watched on its screen (you read it, you don't watch it change); the viewer is usually the mutator. Your own writes feel instant via optimistic patch + onSettled invalidation of the relevant keys (INCLUDING the byUser/list keys other actors read). Cross-actor changes surface through observer mount, stale-active foreground catch-up, manual refresh or targeted invalidation; expiry alone does not fetch a retained observer. Explicit query focus/reconnect overrides may add their own catch-up. Surfaces: dues, attendance, manner-tags, session-payments, board posts/comments/reactions. No realtime channel — even a focus-gated one is wasted when nobody's watching the data change.

Realtime anti-patterns removed to date: (1) ALWAYS-ON channels not gated on focus (2026-05-22) — signals, session_payments were mounted at the app shell; moved to NOTIFY + ON-DEMAND respectively. (2) Realtime for unwatched data (2026-05-22) — board posts, dues, attendance, manner-tags moved to ON-DEMAND. (3) Home AMBIENT channels + ALL remaining Postgres-Changes channels (2026-06-01, owner decision) — all 8 no-op stub files deleted; live screens switched to focus-gated 10s polling (useLiveSessionData, stops when terminal); use-dm-realtime deleted — DM uses native refetchInterval (thread screen 5s, inbox 15s) instead. No Postgres-Changes subscriptions remain; hooks/realtime/ dir deleted.

Security / pipeline invariants:

  • No persistent Postgres-Changes channels exist in the app (all retired 2026-06-01). If a future channel is added, reintroduce the auth/socket lifecycle deliberately and keep it RLS-gated; do not copy the pre-retirement auth/registry wiring pattern from old docs (see git history before 2026-06-01 for the retired shape).
  • Every refresh path invalidates ONLY the current user's keys — never a cross-user prefix.
  • No PII in realtime payloads (carry userId, resolve names at read time — the no-pii-keys-in-jsonb-writes rule covers the write side).
  • realtime.subscribe is allowed ONLY inside presentation/hooks/realtime/** (enforced by no-realtime-subscribe-outside-realtime-hooks) — and every such hook MUST be focus-gated (useFocusedEffect). hooks/realtime/ dir is absent (2026-06-01: use-dm-realtime deleted); the LIVE-tier scorecard/match-board uses focus-gated 10s manual-invalidate polling (useLiveSessionData, stops when terminal); DM uses native refetchInterval. The realtime-focus-gate arch-test asserts the dir stays empty and validates any future hook.

When adding a data surface: classify its tier FIRST (apply the 3-condition rule), then wire the tier's mechanism. Reaching for realtime is the exception that must clear all three conditions, not the default.

Offline reads and writes (owner decision 16, 2026-11-10) ​

Offline, cached content shows under the one root strip and the actions that cannot work are disabled; private reads stay hidden. Consent Management is hidden offline (UX-6, 2026-11-13): a stale copy of a legal choice must not look current, so its page reads with offline: 'hidden' even when memory is warm.

  • Reads. The root NetworkBanner is the only strip. QueryBoundary renders a warm cache (offline-stale) and blocks only a cold read; its offline: 'hidden' is for private reads. The disk cache (query-lazy-persister, 24 h) is an allow-list: only the query roots that PERSIST_TABLE (query-persist-policy.ts) lists with persist: true are written, and the hidden class (staff queues, claims, certifications, invite codes, accounts, the officials' ledger and dues rows, signed-URL grants, typed search terms) is persist: false; see canon/data-and-hooks.md "The persist allow-list law". The disk cache does not survive a cold start today: every start deletes the persisted query rows (beginSessionBootstrap, so one account's cache can never show to another), so "cached content shows offline" holds while the app stays in memory; a cold start with no network shows no cached data (a release-list item). Web has no disk cache: warm there means in-session.
  • Writes, two classes. (1) The durable queue: useQueueableMutation (networkMode: 'always', DATA-11) writes stay live offline, are counted in the strip ("N건 대기 중") and replay on reconnect: score entry, RSVP, starting a session, session payment reports and confirmations, dues status, manner tags, friend requests, messages, reactions and poll votes, signal preferences and read marks, club set-up replay, the venue certification request. (2) Everything else is refused at once with the typed OFFLINE_WRITE_BLOCKED (createMutationHook's offline: 'block', the default; a raw useMutation calls the same refuseWriteWhileOffline()), so an offline tap never pauses and replays stale intent on reconnect. The refusal shows no Toast (the strip is already up and says it); a page that presents its own failures shows the same sentence ("오프라인이에요"). offline: 'queue' on the factory (TanStack's pause-and-replay) is only for the reviewed view-effect read-state marks. Gate: offline-class.gate.test.ts.
  • Sign-out works offline and never waits for the network. Offline (the app's online state) the session lifecycle calls AuthServicePort.signOutThisDevice(): the adapter removes the stored session and its companions (auth-storage.ts) and tells its own subscribers null once, idempotent; online it calls signOut(), and on the typed retryable fetch error (server unreachable) it ends the session on the device by the same path, because the library removes the local session only while the access token is unexpired. The auth storage key is the app's (authStorageKey, passed to both clients; a test holds it equal to the library default). The caches are cleared by the departure in every case.

Failure classes, retries, the request guard and the offline state (batch 58) ​

  • One classifier. classifyFailure(error) (presentation/providers/failure-class.ts, pure) answers transport (no answer: offline, DNS, a deadline, status: 0; an error that NAMES a fetch or network failure, or an AbortError / TimeoutError; a bare TypeError from the app's own code is a defect and permanent), permanent (a refusal a repeat cannot change), rate-limited (429 or a rate-limit hint; the request wrapper stamps the rate_limit hint onto the error body of a REST 429, because the database client's error does not carry the status: adapters/supabase/rate-limit-response.ts), auth-unavailable (no usable session, RD-2) or server (5xx, a database outage, SQLSTATE class 08, 53, 57, 58 or XX, PGRST000-003). Nothing reads error.status for this any more.
  • The app's retry rule is the ONLY retry (H1). The installed database client (@supabase/postgrest-js 2.117, db.retry, default on) repeats every GET/HEAD three times (1 s, 2 s, 4 s) on a fetch failure and on a 503/520. Every client the app builds passes db: APP_DATABASE_OPTIONS (retry: false, adapters/supabase/request-fetch.ts): the phone, the web client, apps/web/lib/supabase.ts, the replay client and the venue-owner client (the SSR share-page client is not the app's wrapper and keeps the library default). Proven at the SDK: a failing read is dispatched twice in all (the app's one retry), a 503 once, a refused "sign-in unavailable" read never (sdk-single-retry.test.ts). The auth client retries only a token refresh on a retryable fetch error, bounded by its 30 s tick; the storage and functions clients have no retry of their own.
  • Retries (RD-1, as amended). A read retries ONCE, on transport only (retryOnceOnTransport). A write retries 0 times (createMutationHook and the client's mutation default). A write that carries an idempotency key (every useQueueableMutation call: the request id is the key) retries once on transport OR server (retryKeyedWrite): a 5xx may have applied the write, and the key is what makes the repeat safe. Nothing retries a 429, a refusal or a missing sign-in. A new createMutationHook never retries; a write that must survive a flaky link is a queueable mutation.
  • The request guard (RD-2). createAppRequestFetch (the phone, the web app and apps/web/lib/supabase.ts) refuses to send a request that would reach the server as the anonymous role (no bearer, or the bearer is the publishable key) unless its path is on the anonymous allow-list (adapters/supabase/request-access.ts): it throws SignInUnavailableError (code AUTH_SIGN_IN_UNAVAILABLE) and sends nothing. The failure classifies as auth-unavailable; a page shows its error or offline state, the query keeps the data it had and writes nothing as data, nothing reaches the disk cache (only success queries persist), and the session lifecycle is untouched (no sign-out). The allow-list holds only answers that do not depend on who asks (H3): every RPC entry is granted to anon in the database (a SUBSET of invariant_anon_rpc_allowlist.test.sql's list, which stays the database's grant surface), is called by a named signed-out screen, and its SQL does not read auth.uid() to shape the answer unless the table in request-access.test.ts says why. get_club_discovery_summaries (viewer-dependent), is_club_member, is_club_admin and is_public_discoverable_club (no app caller) are granted but NOT on the list. A new anonymous-callable function or table joins the list only with a caller and that reading; request-access.test.ts pins it. Tests: request-access.test.ts, signed-out-requests.test.ts (each signed-out screen family sends only allow-listed requests), sign-in-unavailable-trace.test.ts (one read traced to the page state). Deadlines: reads 20 s, edge functions 150 s, uploads 120 s. Uploads take at most two slots of their own (H5) instead of the six every read and write shares (createRequestGate twice in request-fetch.ts; request-fetch-pools.test.ts): several slow photo uploads never queue the app's reads. Edge functions stay on the shared six. A page recovers by itself (H4): a query whose last failure is auth-unavailable or transport is invalidated once per recovery (a session renewed, the server reachable again) by presentation/providers/recover-failed-reads.ts, wired beside the session lifecycle in SessionProvider (useRecoverFailedReads). Accepted: a captive portal that answers HTML counts as "answered" (the read fails with the page's error; the OS link check is the only signal there).
  • Slow is not offline, and one probe confirms (RD-3, H2). The request wrapper tells lib/transport-health.ts whether the server answered, and only a request that got NO RESPONSE from the network counts: a deadline the app's own clock cut (20 s, 120 s, 150 s) is slow, not offline, and is not recorded, so a slow database or one dead function never blocks RSVP or hides pages while the rest answers. CONNECTIVITY.TRANSPORT_FAILURES_TO_OFFLINE (3) such failures in a row make the server SUSPECTED, not down: lib/server-reachability.ts sends ONE health probe (/auth/v1/health) at once, and only a probe that also gets no answer marks the app offline (markDown); an answered probe resets the count. lib/online-state.ts publishes online = link AND not-down, on the phone and the web alike. A paused app sends no request that could prove recovery, so the watcher probes every OFFLINE_POLL_MS while down: only while someone looks (the app in the foreground, a visible browser tab; a suspicion raised meanwhile and a server already down are probed at once when attention returns), and only a JSON answer counts (a captive portal's or a gateway's HTML page does not). The probe never latches: any failure of the probe itself is "not answered", reported once per outage, and the next probe is scheduled. useReadOffline's table is unchanged.
  • Sign-out and the push token (H7). deviceLink.isOnline() (lib/online-state.ts) is the device link alone. With the link up and only our server silent, sign-out still tries the bounded 3 s push-token clear, then signs out on this device if the server still does not answer; with the link down it skips the clear as before (session-sign-out.ts). A write refused as sign-in unavailable is a breadcrumb, not a critical report, like a read.
  • The push token. push-token-registration.ts registers the token: a permanent refusal is reported once and never retried; a network failure retries up to 3 times (30 s x attempt, while online or at the next online transition); a rotated token re-registers (onPushTokenChanged).

Request completion and recoverable delivery ​

createSupabaseRequestFetch keeps the REST/RPC 20-second and Edge 150-second deadlines attached after headers, through buffered reads or streaming body completion, cancellation, or expiry. Caller cancellation remains connected for the same lifetime. The shared six-slot queue still releases at headers; an unused response retains its bounded deadline until consumed or cancelled. Before headers, an aborted call retains its physical slot until the native fetch actually settles; late responses are discarded and their bodies cancel. Timeout diagnostics use AbortError so the pinned SDK does not retry the deadline. Empty-code SDK transport failures retain NETWORK_* classifications.

A failed profile read cannot prove an incomplete profile. Session readiness keeps completion unknown after an error; only a successful missing/incomplete profile result enters onboarding. Account-generation checks still fence commits.

Queue replay schedules bounded retries while connectivity remains online, starting at five seconds and capping the delay at sixty seconds. The existing five-attempt and seven-day limits remain. Timers stop on offline, manager shutdown, account departure, or queue-generation changes. Infrastructure and unknown PostgreSQL errors retain an intent until its bounded recovery policy; explicit business/structural rejections remain permanent.

Ordinary queueable mutations serialize their complete lifecycle with replay for the same entity. Post reactions opt into a separately ordered optimistic phase so the latest tap appears while the previous write is still pending. Physical and durable writes retain entity order; sequence and account checks prevent older success or failure callbacks from replacing the latest state.

Network protocols (canonical fan-out rules) ​

These six protocols compose to keep network traffic bounded and predictable. Apply them whenever you touch a query hook, a list-rendered component, or a realtime subscription. Origin: the 2026-05-14 network audit found a 40-match scorecard firing ~120 parallel HTTP requests because per-item hooks lived inside list-rendered children.

  • Protocol A — Bulk-fetch hook always exists. Every per-item query hook MUST have a bulk sibling at parent scope. Naming convention: useXxx(parentId, itemId) ⇄ useXxxByList(parentId): Map<itemId, Xxx> (or array — caller memoizes into a Map). If the per-item hook has no bulk sibling, that's the bug — add the bulk hook (port method + adapter + presentation hook) in the same commit before touching consumers. Examples already in tree: useMyRsvp(userId, sessionId) ⇄ useMyRsvps(userId). A bulk sibling that spans MULTIPLE parents needs the reduction at BOTH layers, not just the client fan-out. useMyAdminClubAttention (2026-09-08 perf pass, owner-reported on-device lag) fanned out useClubMembers(id) × N clubs AND useClubAdminSnapshot(id) × M admin clubs via useQueries — replacing the client-side useQueries loop with ONE .in('club_id', ids) read fixed the membership half, but the M admin-snapshot RPCs stayed N round trips until a NEW server-side bulk RPC (get_my_admin_club_snapshots, migration 00634) resolved the caller's admin clubs itself and looped the EXISTING per-club get_club_admin_snapshot(uuid) internally (one DB call, zero duplicated aggregation logic — never re-derive the per-club SQL, delegate to it). Two lessons: (1) a bulk client read still costs N round trips if the underlying RPC is still called per id in a loop — check whether the RPC itself needs a caller-scoped multi-row form; (2) a queryFn for a bulk-by-map hook must return Array<[id, Xxx]> entries, never a Map directly — a Map rehydrates as a plain object from the persisted cache (@twomore/json-safe-queryfn; derive the Map view in a useMemo), and any cache-priming backfill into the per-item keys (e.g. clubKeys.members(id), clubAdminKeys.snapshot(id)) must go through a writeXBackfill helper in presentation/cache/, never a raw setQueryData (@twomore/no-setquerydata-outside-cache-helpers, AGENTS.md DATA-8).

  • Precise invalidation (wave 5, 2026-11-13). A write refreshes the queries it changed, not its whole family. The helpers in presentation/cache/invalidation.ts name the exact keys: profileIdentityKeys (a profile edit, a visibility change, a block), clubStandingKeys (archive, restore, ownership transfer), pickupCreatedSessionKeys, ledgerClubKeys, and challengeScoreKeys plus the standings family in scoreResolutionChangedKeys. A list that cannot be enumerated keeps the whole family (a doubt resolves to the wider invalidation), each with its reason in the lane's numbers: ending and starting a session, score resolution's rating reads, rating notices, the session-card bundle (its keys are a joined id list), deleting a ledger line (no club id in its variables). cache/__tests__/invalidation-precision.test.ts fails when a profile or club key builder is added without being classified, and precise-invalidation.test.ts runs each narrowed hook against seeded queries.

  • Bulk reads, polls and prefetch (wave 5). A list row never issues its own query: the club list reads the viewer's admin snapshots once (useMyClubAdminRows, the mySnapshots key) and passes each card its row model; a list of N rows makes the same number of requests whatever N is (a counting-mock test). A poll runs only while its screen is focused (useIsScreenFocused: the public session attendance and the post media URLs). A press-in prefetch uses usePrefetchClub, usePrefetchPost, usePrefetchProfile, never useQueryClient in a row. Every new query root has a row in the persist table (query-persist-policy.ts).

  • Protocol A′ — Header/badge content is not first-paint content; gate its fan-out. A rollup that renders in a persistent header (a bell badge, an always-visible chip) mounts on EVERY screen the header appears on, including a screen's very first paint — its queries compete with that screen's own critical-path queries for the same connection pool the instant the app opens, even though the rollup itself is glance-later content. Defer the ENTIRE fan-out (not just soften its priority) behind InteractionManager.runAfterInteractions — flip a ready boolean once initial interactions/paint settle, and gate every query in the fan-out on it (enabled: ready && …). Critically, a loading/pending flag derived from these queries must ALSO read "pending" while !ready, not just while a query is in flight — isLoading: hasWork && (!ready || query.isLoading) — otherwise a consumer reads a false "done, nothing here" the instant the hook mounts, before the deferred fan-out has even started. useMyAdminClubAttention is the canonical example (feeds NotificationBell, mounted on every screen's header).

  • Protocol B — Prop-or-fetch convention for list-rendered components. Any component that may render inside a .map() MUST expose its per-item dependencies as optional props. The internal hook becomes a FALLBACK gated on prop === undefined. Three guarantees: (1) incremental migration — existing call sites keep working; (2) standalone rendering still works (detail/preview); (3) bulk-pass is the explicit signal "this row is part of a list."

    tsx
    function ExampleCard({ session, userId, myRsvp: myRsvpProp }) {
      const fallback = useMyRsvp(userId ?? '', session.id, {
        enabled: myRsvpProp === undefined && Boolean(userId),
      });
      const myRsvp = myRsvpProp !== undefined ? myRsvpProp : fallback.data;
      // ...
    }

    Components named *Card / *Row / *Item are presumed list-rendered. New per-item hooks added inside them without a corresponding prop are a bug. (Note: the canonical list card, SessionCardV2, goes one step further than this fallback pattern — it is a PURE zero-data-hooks projection with no internal fallback hook at all; Protocol A's bulk sibling — useSessionCardModels — is mandatory at the parent, per Conventions › Session Surfaces.)

  • Protocol C — Event precision. For any future realtime/push/manual-poll event handler: (1) scope the upstream fetch or event as narrowly as the source supports; (2) client-filter via a known id Set when the event doesn't carry the parent's id directly; (3) invalidate ONLY the keys the event actually changed — never the parent *.all prefix.

  • Protocol D — Foreground catch-up respects staleTime via an isStale() predicate. The foreground/resume catch-up MUST gate on staleness, NOT fire a blanket invalidation. Correct form (see packages/app/src/presentation/hooks/use-app-foreground-refresh.ts): queryClient.invalidateQueries({ type: 'active', predicate: (q) => q.isStale() }). Common misconception (do not repeat): bare invalidateQueries({ type: 'active' }) does NOT "only refetch stale queries" — it force-refetches EVERY active observer immediately regardless of freshness (it marks them stale AND triggers an active refetch). On a navigation-heavy app where screens stay mounted (detachInactiveScreens keeps the React tree), the active-observer set grows, so a blanket catch-up fires a progressively larger burst that saturates the concurrency limiter into multi-second tails. The predicate: (q) => q.isStale() restores the intended semantics — only observers already past their own staleTime refetch. (refetchQueries is even worse — it refetches regardless of isInvalidated too.)

  • Protocol E — Selector over second-fetch. When children need a derived view of parent data (count, filter, mapping), use useMemo over the parent's already-fetched data OR TanStack's select option on the same useQuery key — never a separate query that re-fetches a slice of what's already cached. Pure-CPU derivations are not I/O; routing them through TanStack just churns cache keys.

  • Protocol F — Detail reads list cache as placeholderData. When the user navigates from a list to a detail screen, the detail's first-fetch should not refetch if the list cache already has the row. Pattern: placeholderData: () => listCache.find(i => i.id === detailId). Combine with TanStack's stale-while-revalidate so the placeholder paints instantly while the detail-specific fetch revalidates in background.

Foreground UX protocols (perceived-latency rules) ​

Network Protocols A-F shape what data is fetched and when. These four UX Protocols shape how the user perceives the loading process. They are orthogonal — a screen can have perfect network shape and still feel laggy if it ignores G/H/I, and a screen can hide good network shape behind sloppy UX. Keep the layers separate: never solve a network problem by changing the UI loading pattern, and never solve a UX problem by re-shaping queries.

  • Protocol G — Screen-level readiness gating. A screen's content area renders all sections together, not section-by-section. Cause of the anti-pattern: each section fires its own useQuery, queries resolve at different times, and sections that resolve to "empty" still flash a skeleton-then-null reveal. Fix: combine the critical-path queries' isFetched flags at the screen root and render a unified skeleton until ALL settle. Sub-sections (via DataSection) still own their final empty/content state — Protocol G just removes the cascading reveal. Use isFetched not isLoading so empty results count as ready. Critical path = the queries the screen's primary surface visually depends on; secondary background queries (achievements counts, friend lists) can resolve independently. Apply to surfaces where the user reads the whole screen as one unit (profile, records, club-detail home tab).

  • Protocol H — Anticipatory prefetch on onPressIn. Any pressable that navigates to a detail screen MUST call the destination's prefetch helper on onPressIn (press intent, ~100ms before navigation actually starts). The prefetch fires the destination's primary queries in parallel; by the time the route transition animation completes, the cache is warm. Wiring template:

    tsx
    <Pressable
      variant="card"
      onPressIn={() => prefetchSpectatorScorecard(queryClient, session.id)}
      onPress={() => appRouter.push(routes.scorecard(session.id) as never)}
    >

    For each detail screen, add a prefetchXxxScreen(client, id) helper to packages/app/src/presentation/cache/prefetch.ts that fires every primary query the screen mounts. Combine with Protocol F (placeholderData from list cache) so a destination renders instantly with cached data even before prefetch resolves.

  • Protocol I — Foreground vs background separation. Network Protocols (A-F) are background concerns — what to fetch, how to dedup, when to invalidate. UX Protocols (G-J) are foreground concerns — what the user sees during loading and transitions. Do not conflate them. A failing UX (laggy tap, piecewise reveals) is often misdiagnosed as a network shape problem. Before refactoring a query, ask: is this a fetch shape failure, or a perception failure? If the data is already cached but the screen still feels slow, the fix lives in G/H/J, not A/B/C.

  • Protocol J — Information architecture: screens have one job. Each top-level tab has a primary mental model the user assigns to it. 홈 = today's activity. 클럽 = my clubs + discovery. 경기 = matches I'm playing/RSVP'd. 기록 = analytics + leaderboards + personal records. 프로필 = identity + social + settings. Sections drift across screens over time; periodically audit "is this card on the right screen?" Analytics content (PersonalRecordsSection — biggest win, current streak, busiest week, best ELO swing) belongs on 기록, NOT 프로필. The check: if removing the section makes the host screen MORE focused on its primary job, the section should move.

  • Protocol K — Mapper resilience. A single Zod schema failure inside .map(toDomain) must not crash the whole query. Two complementary patterns: (1) boundary-level safeMap(rows, mapper, context) from packages/app/src/adapters/supabase/base.adapter.ts wraps every adapter list-query .map() site and drops individual rows that throw, logging logger.warn + a Sentry breadcrumb keyed to the entity context (e.g. 'adapter', 'safeMap:club.list dropped row') so the breadcrumb is searchable in the Sentry dashboard; (2) per-field safeParse(...).data ?? fallback for nullable enums and required-with-default fields in mapper files, leaving .parse() only for required-no-default fields where the row is fundamentally broken without that field (safeMap then drops the row). Single-row findById / .maybeSingle() paths are deliberately NOT wrapped — failure there should propagate so screens render an error state instead of pretending the row didn't exist. Add new bulk-query adapter methods using safeMap(rows, xMapper.toDomain, '<entity>.<scope>') so the context label is consistent and searchable. Enforced by @twomore/require-safemap-in-adapter-list (Wave H) — flags any bare .map(row => x.toDomain(row)) in packages/app/src/adapters/; the rule surfaced 6 residual unwrapped sites that a manual grep sweep had missed, validating mechanical enforcement over hand-audit.

  • Realtime is not a default dependency. The app must work fully without persistent sockets. Current data freshness is carried by TanStack staleTime/refetch-on-focus, push-triggered cache busts, focused polling for live surfaces, native DM refetchInterval, optimistic writes, and targeted invalidation. A future socket-based hook needs an explicit freshness-tier justification and must remain an enhancement over the query source of truth.

  • Protocol L — Pause-on-blur for active subscriptions. The bottom tabs use detachInactiveScreens: true, which removes inactive screens from the native view tree but keeps the React component tree mounted. Every setInterval / realtime subscription on a visited screen therefore stays live in the background, accumulating callback cost as the user navigates around. Every hook that subscribes to an external system MUST gate its subscription using the focus-aware primitive:

    • The primitive: useFocusedEffect(effect, deps) from @twomore/app (or @/presentation/hooks/use-focused-effect within the app package) — runs the effect only while the consuming screen is currently focused, pauses on blur, resumes on focus. Internally wraps useEffect + useIsFocused. Every clock-ticker and realtime hook in the codebase uses this primitive so the focus-gate behavior stays consistent across the app. New hooks that subscribe to external systems MUST use it (enforced by @twomore/require-is-focused-in-realtime-hook).
    • Clock-tickers: built into packages/app/src/presentation/hooks/use-now-ticking.ts, packages/app/src/presentation/hooks/use-live-countdown.ts, packages/app/src/presentation/hooks/use-live-countdown.ts (useDeadlineCountdown), packages/app/src/presentation/hooks/use-shared-ticker.ts — all via useFocusedEffect internally (preferred for multi-consumer cadences — collapses N per-row setIntervals into 1 broadcaster per cadence so React 18 batches all updates into one commit).
    • Realtime hooks: packages/app/src/presentation/hooks/realtime/ is absent after the 2026-06-01 retirement. The realtime-focus-gate arch-test asserts it stays absent, and @twomore/require-is-focused-in-realtime-hook validates any future hook if the directory returns.
    • On focus re-resume: focus-aware effects restart; retained query observers do not fetch merely because navigation focus returns or staleTime expires. App/browser foreground catch-up belongs to the root's 500ms stale-active refresh (Protocol D); a newly mounted observer follows refetchOnMount.
  • Protocol M — Tracked-props discipline: background refetches must be render-free and invisible. TanStack v5 re-renders an observer only for the result properties the render actually READ (tracked props). isFetching, isRefetching and fetchStatus flip twice on EVERY refetch (idle → fetching → idle) — focus catch-up, invalidation, and the live screens' 10 s poll included — so a derivation that reads them subscribes the WHOLE screen to every fetch start/stop even when the data comes back byte-identical (structural sharing keeps the reference, so data alone would not re-render at all). The 2026-09-08 lag audit found useCompositeNetwork/deriveNetworkPhase reading fetchStatus on every composed query and 45 screens wiring refreshing={isRefetching}: session detail / scorecard / match board re-rendered ~8× per poll tick AND flashed a pull-to-refresh spinner nobody pulled. Rules: (1) a composed-network derivation reads isPaused (flips only when connectivity pauses a fetch) and touches isFetching only after a paused query is found — QueryLike deliberately has no fetchStatus; (2) the pull-to-refresh spinner is a USER-gesture acknowledgement — const { refreshing, onRefresh } = usePullToRefresh(refetch) from @twomore/app is the one way to drive refreshing/onRefresh (@twomore/no-background-refetch-spinner, error, zero waivers); (3) a composer never exposes isRefetching — expose retry/refetchX and let the screen compose the hook; (4) a per-render fetching cue for an in-place list refresh is allowed only where the product wants the cue (VenueFinder's browse list), never as a side-effect of the spinner wiring. Corollary on the persistence side: the lazy persister skips re-serializing a query whose state.data reference is unchanged (structural sharing) for 5 minutes, so an unchanged poll response costs no JSON.stringify/MMKV write either.

  • Protocol N — One round trip per screen (the composition layer is the bottleneck). React Native Android's OkHttp Dispatcher allows 5 concurrent requests per host and every Supabase DATA call goes to one host, so a screen's mount latency is ceil(requests / 5) × per-request time regardless of how well each hook follows Protocol A. (Verified 2026-09-09 against what actually runs: global fetch → whatwg-fetch → XMLHttpRequest → NetworkingModule → OkHttpClientProvider, which in RN 0.83.6 never calls .dispatcher(...), so OkHttp 4.9.2's defaults of 5-per-host / 64-total apply. Images do NOT share that client: expo-image builds its own OkHttpClient in ExpoImageOkHttpClientGlideModule, so map tiles and avatars get a SEPARATE 5-per-host budget and never contend with queries — do not count them against this budget.) Composites that are each correct in isolation (useSessionCardModels 8, useLiveSessionData 5, useSessionDetailModel +14, useHomeData 8–10) stacked into 18–22 requests per screen and 2–4 s tails (2026-09-09 loading audit). Rule: a screen's FIRST PAINT reads through ONE aggregate — a SECURITY INVOKER SQL function returning jsonb built from the SAME columns the per-item adapters select (RLS applies unchanged; INVOKER, never DEFINER, when every source table is already caller-scoped), mapped with the existing mappers (Protocol K per part), and BACKFILLING the per-item cache keys through presentation/cache helpers so detail screens stay warm. Shipped: get_session_card_bundle(uuid[]) (00635, every session list), get_home_snapshot() (00636), get_session_detail_bundle(uuid) (00637, every live-session screen) and get_club_detail_bundle(uuid) (00638). A focus-gated poll is a per-tick fan-out too: the live-session poll invalidated 7 shared keys every 10 s, i.e. 7 requests per tick through the same 5-slot dispatcher, on exactly the screens the owner reported as laggiest. With an aggregate the poll invalidates ONE key and the backfill republishes every per-item key the 7 used to cover — so a live tick costs 1 request, and no other screen goes stale. Any new poll or refresh path invalidates the bundle key, never the parts. Add a new aggregate when a screen composes more than ~6 requests at mount; never a per-screen useQueries fan-out. Secondary content (badges, glance-later rollups) still defers behind first paint (Protocol A′). Do not reach for the native knob instead. Raising maxRequestsPerHost needs a custom OkHttpClientFactory set in MainApplication.onCreate, which in this CNG (no committed android/) project means a config plugin patching MainApplication.kt; the factory is read once at first client creation (RN issue #34789), the same once-only shape that boot-crashed the retired feature-flag-override plugin. It is also unnecessary: with the aggregates a screen's first paint is 1–3 data requests, well under the cap. Fix the composition, not the transport.

Public edge ingress — caller identity and caps ​

Anonymous edge traffic (static-map, search-venues, fetch-holidays, the vitals/perf insert path) is bounded by private.public_ingress_policies (global daily cap per action) AND private.rate_limit_policies (per-source rate, e.g. 120/min), both enforced by private.admit_public_ingress (migration 00689). The source is the caller IP resolved by trustedClientIp (supabase/functions/_shared/trusted-client-ip.ts):

  • Default: the LAST hop of x-forwarded-for. Supabase's gateway appends the peer it observed; the client-supplied prefix is forgeable and ignored. A forged prefix, cf-connecting-ip, UA or Bearer text never opens a second bucket.
  • Override: env TWOMORE_TRUSTED_CLIENT_IP_HEADER (edge function secret) names a single-valued header the operator has proven the gateway overwrites (e.g. cf-connecting-ip when Cloudflare fronts it); the value unknown deliberately collapses everyone into one bucket. Unset, empty or malformed means the default. The DB-side policy column public_ingress_policies.trusted_ip_header (default x-forwarded-for-last) does the same for the direct PostgREST path (client_perf_events).
  • Only a missing, oversized or malformed header falls back to unknown-ip, one shared bucket. It cannot starve the fleet: the per-source cap bounds it and the global daily cap bounds the day. Never fall through from a malformed trusted header to an editable one.
  • Provider spend counts successes only. fetch-holidays reserves a holiday-provider admission before calling data.go.kr and RELEASES it (holiday-provider-release) on any provider failure, so an outage returns 503 + Retry-After without burning the daily cap toward a global 429; the isolate also pauses retries of a failed year for 60 s. A missing DATA_GO_KR_SERVICE_KEY keeps the empty-list 200.

Network Orchestration — composer hooks ​

The Network Protocols A-L describe individual rules. The composer hooks codify the COMPOSITION — how the rules apply together for a specific domain. Each composer owns the network shape (queries + realtime + ready-gate) for one logical entity:

  • A hook that joins two reads reports both (DATA-17, batch 61). A composite over several queries takes its offline state, its error and its retry from all of them through useCompositeNetwork (isPausedOffline, never fetchStatus of one read): useMyParticipatingSessions reported only the sessions it read, so a person offline saw "예정된 일정이 없어요" instead of the offline wall. The gate presentation/hooks/__tests__/joined-reads.gate.test.ts finds a hook that hydrates ids through useSessionsByIds without useCompositeNetwork (two reviewed exceptions, each named with its reason; it carries canaries). It does not see a join made any other way (a read enabled by or keyed on another read's data, hydrated through a different hook): a reviewer checks that by hand.
  • useLiveSessionData(sessionId) from packages/app/src/presentation/hooks/composites/use-live-session-data.ts — canonical setup for any screen displaying a live session: 4 queries (session + matches + rsvps + profiles), focus-gated 10s manual-invalidate poll (stops when session.status is 'completed'/'cancelled'), Protocol-G ready-gate combining all 4 isFetched flags. Returns { session, matches, rsvps, clubProfiles, ready, refetchSession, refetchMatches } (no isRefetching — Protocol M). As of 2026-09-09 all four slices come from ONE get_session_detail_bundle read (Protocol N) and the 10 s poll invalidates only that bundle key; the return shape is unchanged for its consumers. Used by session-detail-screen, spectator-scorecard-screen, match-board-screen, and live-session-stack on home. TanStack dedupes across consumers via cache keys.
  • useClubContext(clubId) from packages/app/src/presentation/hooks/composites/use-club-context.ts — canonical setup for any club detail surface: one get_club_detail_bundle read (Protocol N, 2026-09-09) with role derived from it via the pure deriveClubRole extracted from useClubRole — useClubRole itself is unchanged for its other callers, Protocol-G ready-gate over club+members. Returns { club, members, userId, role, ready, isError, retry, queries } where role is the full UseClubRoleReturn (canManage* flags + hasPermission + isMember) and queries is the [clubQ, membersQ] objects to spread into a screen's <QueryBoundary> alongside screen-specific queries. Used by club-detail, club-members, club-dues, club-attendance, club-board. Eliminates the repeated useAuth+useClub+useClubMembers+useClubRole quartet + inline members.some(m => m.userId === uid && m.isActive) isMember derivation.
  • usePlayerProfileData(userId) from packages/app/src/presentation/hooks/composites/use-player-profile-data.ts — canonical setup for the profile/records cluster: useProfile + useMatchHistory + useFriends, Protocol-G ready-gate over all three. Returns { profile, matches, friends, ready, isError, retry, queries }. Used by profile-screen, records-screen, records-history-screen — each adds its screen-specific hooks (achievements / manner-tags / player-stats-detail / my-clubs) on top and spreads queries into its <QueryBoundary>. NOT used by public-profile-screen (it reads only profile + a 5-row preview + a pairwise useFriendship, sharing no friends list — two hooks isn't boilerplate worth a composer).

A page's conclusion waits on every read it depends on (F10, 2026-09-29). A screen composed of several reads derives its state through useCompositeNetwork(queries) — loading, paused, error and ready come from the whole set, so an errored or paused sibling shows the error or offline band, never "no clubs" / "nothing saved" / "no applicants". Its refetch() awaits every query, so a pull-to-refresh spinner ends when the data does (a void wrapper that returns at once is the bug).

New screens displaying the same entity MUST use the composer — never re-implement the boilerplate. Drift between screens is the failure mode the composer prevents.

Pattern for new composers:

  1. Identify the entity (live session, user activity, club detail, etc.)
  2. List the canonical queries + polling interval (if LIVE-tier) + ready-gate for it
  3. Wrap them in a useXxxData(id) composer in presentation/hooks/composites/ returning the data slices + ready/isError/retry + the critical-path queries[] (for <QueryBoundary> composition) + any refetch* callbacks
  4. Migrate every screen consuming the entity to the composer
  5. Document the composer in CLAUDE.md's Network Orchestration section

Future best-practice migration (not yet done): TanStack v5's queryOptions factory is the recommended building block for sharing a query's config between a hook, a prefetch helper, and a composer (one source of truth for queryKey + queryFn + staleTime, fully typed). Our query hooks currently inline useQuery({...}); migrating them to export xxxQueryOptions(id) and having hooks/prefetch/composers consume those would remove the last duplication (queryKey ↔ prefetch ↔ hook). Tracked as a follow-up — do it incrementally as query hooks are touched.

See also ​

  • Protocol O — Connectivity is evidence, not truth (2026-09-17, owner: "disconnected at the top while the phone is online"). onlineManager is fed by ONE state machine, createConnectivityMonitor (packages/app/src/lib/connectivity-monitor.ts, wired in online-manager-setup.ts), never by a raw platform callback. expo-network's Android listener fires on onAvailable/onLost of ANY network and reads the active network 250 ms later; in a handoff window that snapshot says isConnected: false for an online phone, and the library only emits again on the NEXT network event — so a single trusted reading stuck the app offline (banner, paused queries) until the next handoff or a restart. Rules: (1) a positive reading applies at once; (2) a negative reading is confirmed by a second snapshot CONNECTIVITY.OFFLINE_CONFIRM_MS later — two negatives flip offline, which also swallows sub-second flaps; (3) while offline, a fresh snapshot runs every CONNECTIVITY.OFFLINE_POLL_MS and on every foreground, so "offline" can never outlive the outage; (4) a fresh platform event outranks a snapshot still in flight; (5) snapshotIsOnline is the one predicate (isConnected === true && isInternetReachable !== false — an unknown probe is online, a known-unreachable link is not). Nothing else in the app reads expo-network. The unit test (connectivity-monitor.test.ts) pins the reported failure as a named case.

  • Best Practices › Networking & Caching — generic deep-dive on TanStack Query patterns, cache shapes, and adapter wiring.

  • docs/adr/0005-network-orchestration-protocols.md — ADR for the network orchestration protocols.

  • AGENTS.md — ARCH-6, DATA-9..DATA-12 (machine-enforced network and caching constraints).

  • CLAUDE.md — orchestration core + the pointer index back to this doc.

Markdown remains the source of truth. Run yarn docs:check before handoff.