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.mdis 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:
- Actively watched — the user dwells on a screen watching THIS data change (not glance-and-leave).
- Latency-critical — seconds of staleness degrade the experience or correctness.
- 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). ManualsetInterval + invalidateQueries(not per-queryrefetchInterval) is used because these 6 queries are shared with other screens — a per-query interval would poll them while the scorecard is NOT focused. 60sstaleTimeis the background fallback. Surfaces: live match scores + score-calls + session status + session RSVP, all viauseLiveSessionData. 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.tsbustssignalKeys.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 followrefetchOnMount. GlobalrefetchOnWindowFocusandrefetchOnReconnectare disabled. The rootuseAppForegroundRefreshcoalesces native app-resume and visible browser focus/pageshow events for 500ms, then invalidates only active queries satisfyingisStale()(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 +
onSettledinvalidation of the relevant keys (INCLUDING thebyUser/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 — theno-pii-keys-in-jsonb-writesrule covers the write side). realtime.subscribeis allowed ONLY insidepresentation/hooks/realtime/**(enforced byno-realtime-subscribe-outside-realtime-hooks) — and every such hook MUST be focus-gated (useFocusedEffect).hooks/realtime/dir is absent (2026-06-01:use-dm-realtimedeleted); the LIVE-tier scorecard/match-board uses focus-gated 10s manual-invalidate polling (useLiveSessionData, stops when terminal); DM uses nativerefetchInterval. Therealtime-focus-gatearch-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
NetworkBanneris the only strip.QueryBoundaryrenders a warm cache (offline-stale) and blocks only a cold read; itsoffline: 'hidden'is for private reads. The disk cache (query-lazy-persister, 24 h) is an allow-list: only the query roots thatPERSIST_TABLE(query-persist-policy.ts) lists withpersist: trueare written, and the hidden class (staff queues, claims, certifications, invite codes, accounts, the officials' ledger and dues rows, signed-URL grants, typed search terms) ispersist: 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 typedOFFLINE_WRITE_BLOCKED(createMutationHook'soffline: 'block', the default; a rawuseMutationcalls the samerefuseWriteWhileOffline()), 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 subscribersnullonce, idempotent; online it callssignOut(), 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) answerstransport(no answer: offline, DNS, a deadline,status: 0; an error that NAMES a fetch or network failure, or anAbortError/TimeoutError; a bareTypeErrorfrom the app's own code is a defect andpermanent),permanent(a refusal a repeat cannot change),rate-limited(429 or a rate-limit hint; the request wrapper stamps therate_limithint 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) orserver(5xx, a database outage, SQLSTATE class 08, 53, 57, 58 or XX,PGRST000-003). Nothing readserror.statusfor this any more. - The app's retry rule is the ONLY retry (H1). The installed database client (
@supabase/postgrest-js2.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 passesdb: 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
transportonly (retryOnceOnTransport). A write retries 0 times (createMutationHookand the client's mutation default). A write that carries an idempotency key (everyuseQueueableMutationcall: the request id is the key) retries once ontransportORserver(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 newcreateMutationHooknever retries; a write that must survive a flaky link is a queueable mutation. - The request guard (RD-2).
createAppRequestFetch(the phone, the web app andapps/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 throwsSignInUnavailableError(codeAUTH_SIGN_IN_UNAVAILABLE) and sends nothing. The failure classifies asauth-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 (onlysuccessqueries 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 toanonin the database (a SUBSET ofinvariant_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 readauth.uid()to shape the answer unless the table inrequest-access.test.tssays why.get_club_discovery_summaries(viewer-dependent),is_club_member,is_club_adminandis_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.tspins 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 (createRequestGatetwice inrequest-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 isauth-unavailableortransportis invalidated once per recovery (a session renewed, the server reachable again) bypresentation/providers/recover-failed-reads.ts, wired beside the session lifecycle inSessionProvider(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.tswhether 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.tssends 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.tspublishes 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 everyOFFLINE_POLL_MSwhile 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.tsregisters 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 outuseClubMembers(id)× N clubs ANDuseClubAdminSnapshot(id)× M admin clubs viauseQueries— replacing the client-sideuseQueriesloop 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-clubget_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) aqueryFnfor a bulk-by-map hook must returnArray<[id, Xxx]>entries, never aMapdirectly — aMaprehydrates as a plain object from the persisted cache (@twomore/json-safe-queryfn; derive theMapview in auseMemo), and any cache-priming backfill into the per-item keys (e.g.clubKeys.members(id),clubAdminKeys.snapshot(id)) must go through awriteXBackfillhelper inpresentation/cache/, never a rawsetQueryData(@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.tsname the exact keys:profileIdentityKeys(a profile edit, a visibility change, a block),clubStandingKeys(archive, restore, ownership transfer),pickupCreatedSessionKeys,ledgerClubKeys, andchallengeScoreKeysplus the standings family inscoreResolutionChangedKeys. 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.tsfails when a profile or club key builder is added without being classified, andprecise-invalidation.test.tsruns 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, themySnapshotskey) 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 usesusePrefetchClub,usePrefetchPost,usePrefetchProfile, neveruseQueryClientin 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 areadyboolean 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.useMyAdminClubAttentionis the canonical example (feedsNotificationBell, 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 onprop === 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."tsxfunction 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/*Itemare 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
*.allprefix.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 (seepackages/app/src/presentation/hooks/use-app-foreground-refresh.ts):queryClient.invalidateQueries({ type: 'active', predicate: (q) => q.isStale() }). Common misconception (do not repeat): bareinvalidateQueries({ 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 (detachInactiveScreenskeeps 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. Thepredicate: (q) => q.isStale()restores the intended semantics — only observers already past their own staleTime refetch. (refetchQueriesis even worse — it refetches regardless ofisInvalidatedtoo.)Protocol E — Selector over second-fetch. When children need a derived view of parent data (count, filter, mapping), use
useMemoover the parent's already-fetched data OR TanStack'sselectoption on the sameuseQuerykey — 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'isFetchedflags 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. UseisFetchednotisLoadingso 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 ononPressIn(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 topackages/app/src/presentation/cache/prefetch.tsthat 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-levelsafeMap(rows, mapper, context)frompackages/app/src/adapters/supabase/base.adapter.tswraps every adapter list-query.map()site and drops individual rows that throw, logginglogger.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-fieldsafeParse(...).data ?? fallbackfor 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 (safeMapthen drops the row). Single-rowfindById/.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 usingsafeMap(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))inpackages/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. EverysetInterval/ 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-effectwithin the app package) — runs the effect only while the consuming screen is currently focused, pauses on blur, resumes on focus. Internally wrapsuseEffect+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 viauseFocusedEffectinternally (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. Therealtime-focus-gatearch-test asserts it stays absent, and@twomore/require-is-focused-in-realtime-hookvalidates 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
staleTimeexpires. App/browser foreground catch-up belongs to the root's 500ms stale-active refresh (Protocol D); a newly mounted observer followsrefetchOnMount.
- The primitive:
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,isRefetchingandfetchStatusflip 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, sodataalone would not re-render at all). The 2026-09-08 lag audit founduseCompositeNetwork/deriveNetworkPhasereadingfetchStatuson every composed query and 45 screens wiringrefreshing={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 readsisPaused(flips only when connectivity pauses a fetch) and touchesisFetchingonly after a paused query is found —QueryLikedeliberately has nofetchStatus; (2) the pull-to-refresh spinner is a USER-gesture acknowledgement —const { refreshing, onRefresh } = usePullToRefresh(refetch)from@twomore/appis the one way to driverefreshing/onRefresh(@twomore/no-background-refetch-spinner, error, zero waivers); (3) a composer never exposesisRefetching— exposeretry/refetchXand let the screen compose the hook; (4) a per-renderfetchingcue 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 whosestate.datareference 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
Dispatcherallows 5 concurrent requests per host and every Supabase DATA call goes to one host, so a screen's mount latency isceil(requests / 5) × per-request timeregardless of how well each hook follows Protocol A. (Verified 2026-09-09 against what actually runs: globalfetch→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-imagebuilds its ownOkHttpClientinExpoImageOkHttpClientGlideModule, 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 (useSessionCardModels8,useLiveSessionData5,useSessionDetailModel+14,useHomeData8–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 — aSECURITY INVOKERSQL function returningjsonbbuilt 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 throughpresentation/cachehelpers 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) andget_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-screenuseQueriesfan-out. Secondary content (badges, glance-later rollups) still defers behind first paint (Protocol A′). Do not reach for the native knob instead. RaisingmaxRequestsPerHostneeds a customOkHttpClientFactoryset inMainApplication.onCreate, which in this CNG (no committedandroid/) project means a config plugin patchingMainApplication.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-ipwhen Cloudflare fronts it); the valueunknowndeliberately collapses everyone into one bucket. Unset, empty or malformed means the default. The DB-side policy columnpublic_ingress_policies.trusted_ip_header(defaultx-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-holidaysreserves aholiday-provideradmission before calling data.go.kr and RELEASES it (holiday-provider-release) on any provider failure, so an outage returns 503 +Retry-Afterwithout burning the daily cap toward a global 429; the isolate also pauses retries of a failed year for 60 s. A missingDATA_GO_KR_SERVICE_KEYkeeps 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, neverfetchStatusof one read):useMyParticipatingSessionsreported only the sessions it read, so a person offline saw "예정된 일정이 없어요" instead of the offline wall. The gatepresentation/hooks/__tests__/joined-reads.gate.test.tsfinds a hook that hydrates ids throughuseSessionsByIdswithoutuseCompositeNetwork(two reviewed exceptions, each named with its reason; it carries canaries). It does not see a join made any other way (a readenabledby or keyed on another read's data, hydrated through a different hook): a reviewer checks that by hand. useLiveSessionData(sessionId)frompackages/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 whensession.statusis'completed'/'cancelled'), Protocol-G ready-gate combining all 4isFetchedflags. Returns{ session, matches, rsvps, clubProfiles, ready, refetchSession, refetchMatches }(noisRefetching— Protocol M). As of 2026-09-09 all four slices come from ONEget_session_detail_bundleread (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)frompackages/app/src/presentation/hooks/composites/use-club-context.ts— canonical setup for any club detail surface: oneget_club_detail_bundleread (Protocol N, 2026-09-09) with role derived from it via the purederiveClubRoleextracted fromuseClubRole—useClubRoleitself is unchanged for its other callers, Protocol-G ready-gate over club+members. Returns{ club, members, userId, role, ready, isError, retry, queries }whereroleis the fullUseClubRoleReturn(canManage* flags +hasPermission+isMember) andqueriesis 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 + inlinemembers.some(m => m.userId === uid && m.isActive)isMember derivation.usePlayerProfileData(userId)frompackages/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 spreadsqueriesinto its<QueryBoundary>. NOT used by public-profile-screen (it reads only profile + a 5-row preview + a pairwiseuseFriendship, 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:
- Identify the entity (live session, user activity, club detail, etc.)
- List the canonical queries + polling interval (if LIVE-tier) + ready-gate for it
- Wrap them in a
useXxxData(id)composer inpresentation/hooks/composites/returning the data slices +ready/isError/retry+ the critical-pathqueries[](for<QueryBoundary>composition) + anyrefetch*callbacks - Migrate every screen consuming the entity to the composer
- 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").
onlineManageris fed by ONE state machine,createConnectivityMonitor(packages/app/src/lib/connectivity-monitor.ts, wired inonline-manager-setup.ts), never by a raw platform callback. expo-network's Android listener fires ononAvailable/onLostof ANY network and reads the active network 250 ms later; in a handoff window that snapshot saysisConnected: falsefor 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 snapshotCONNECTIVITY.OFFLINE_CONFIRM_MSlater — two negatives flip offline, which also swallows sub-second flaps; (3) while offline, a fresh snapshot runs everyCONNECTIVITY.OFFLINE_POLL_MSand on every foreground, so "offline" can never outlive the outage; (4) a fresh platform event outranks a snapshot still in flight; (5)snapshotIsOnlineis 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.