Perf and data-integrity audit — 2026-09-12 → 2026-09-17 delta
Status: Accepted · Date: 2026-09-17 · Scope: commits 56fc2132..HEAD
Resolution: PF-01–PF-04 and DI-01–DI-04 all built 2026-09-17 (SessionLiveAnchor prop-or-fetch; dues handlers memoised + DuesRow memo; club-wizard persist migrate; venue mutations invalidate the club bundle; migrations 00652 advisory lock + v_max_bases with check:club-regions-cap-parity, 00653 RSVP fee-read row lock; types regenerated). Commit aee4d359 · preview OTA b8186450.
Method
Read the delta (git log/git diff --stat 56fc2132..HEAD, 1154 files) against docs/canon/networking.md (Protocols A–N) and docs/canon/data-and-hooks.md, then traced the named hooks/components/migrations to their actual call sites and live definitions (never trusting a file's name or a migration's stated intent) — three cases turned up daylight between the doc comment and the wired-up behavior. Local Supabase (supabase_db_twomore-v2 container) was up but unseeded (0 rows); used it for live schema/RLS-policy/index verification via docker exec psql and one EXPLAIN (plan-shape only, no volume signal). All findings below are read-only observations — no code changed.
Findings — Perf
PF-01 — SessionLiveAnchor mounts a full second copy of useLiveSessionData on every live-session screen
Evidence: SessionLiveAnchor (packages/app/src/presentation/components/sessions/session-live-anchor.tsx:60-64) calls useSessionLiveFacts(sessionId), which calls useLiveSessionData(sessionId) directly (packages/app/src/presentation/hooks/queries/use-session-live-facts.ts:29). All three mount sites already source the same session's data through this exact composer via their own data hook: spectator-scorecard-screen.tsx:108 calls useLiveSessionData directly and then mounts <SessionLiveAnchor> at line 485; match-board/use-match-board-data.ts:163 calls it (consumed by match-board-screen.tsx:289's <SessionLiveAnchor>); session-detail/use-session-detail-model.ts:89 calls it (consumed by session-detail-screen.tsx:377's <SessionLiveAnchor>). So useLiveSessionData(sessionId) runs twice per screen, per render.
useLiveSessionData (packages/app/src/presentation/hooks/composites/use-live-session-data.ts) does three things per mount, not just a useQuery read:
- A
useEffect(lines 183-186) that runswriteSessionDetailBundleBackfill(...)— fans the bundle across every per-key cache (session/matches/rsvps/scoreCalls/guestApplications/sessionPayments) — on every bundle change. - A
useEffect(lines 222-234) that owns its ownsetInterval(..., 10_000)callingqueryClient.invalidateQueries(...)while focused and non-terminal. - A
useCompositeNetworkcall and fiveuseMemos (rsvps, clubProfiles, memberIds, ratingSwings, participantProfiles) thatuseSessionLiveFactsnever reads (it only destructuressession/matches, line 29 ofuse-session-live-facts.ts).
TanStack Query dedupes the underlying fetch for the shared queryKey (so the doc comment's claim of "zero extra network round trips" is correct), but it does not dedupe the hook instance — each useQuery() call is a separate observer, and each useEffect in the composer is a separate effect instance. The result: two independent 10-second interval timers per screen, each independently calling invalidateQueries on the identical key, and the backfill fan-out running twice per bundle refresh.
Failure scenario: Not a crash. The two timers mount in the same commit so they're unlikely to drift far out of phase today, but nothing enforces that — a future conditional render of SessionLiveAnchor (e.g. gating it behind an animation or a later-resolving flag) would desynchronize the two intervals and could silently double the effective poll frequency, directly contradicting Protocol N's stated invariant ("a live tick costs 1 request") that this exact codebase did real work to establish (migration 00637/00638, 2026-09-09). Today's cost is two live setInterval closures + a doubled backfill fan-out (writes every per-key dues/session/rsvp cache twice per 10s tick) for every open live-session screen — wasted JS-thread work, not user-visible lag at current scale.
Severity: wave.
Canonical fix: Apply this codebase's own Protocol B idiom (prop-or-fetch): give SessionLiveAnchor optional session/matches props, falling back to useSessionLiveFacts only when absent — every call site already has both values in scope from its own useLiveSessionData/useSessionDetailModel/useMatchBoardData call, so no new fetch or prop-plumbing is needed, just threading the two fields down.
Prevention: none needed beyond the fix itself — this is a single-pattern, three-call-site fix. Worth a one-line addition to docs/canon/sessions.md's composer note: "a self-contained component reading the SAME composer as its parent screen must accept the composer's output as props, per Protocol B."
PF-02 — DuesRow's U-14 action handlers are unmemoized, silently defeating the adjacent "Wave G1" memoization fix
Evidence: club-dues-screen.tsx:187-189 carries this exact comment: "Stable FeedList callbacks — without these, every parent state tick (modal toggle, realtime dues event) re-renders all visible rows (Wave G1 finding F-01)." renderAdminItem is correctly wrapped in useCallback (line 195), but its dependency array (lines 220-231) lists handleMarkPaid, handleWaive, handleUnmarkPaid — and use-club-dues-actions.ts:152,179,209 declares all three as plain, non-useCallback functions inside useClubDuesActions's body (only openNote, openAttest, and handleConfirmPending are useCallback-wrapped in that same file). A plain function declared inside a hook body is a new reference every render of that hook. Since useClubDuesActions is called once per render of club-dues-screen.tsx, handleMarkPaid/handleWaive/handleUnmarkPaid are new references on every screen render, which forces renderAdminItem's own useCallback to produce a new function every render too — exactly the class of bug the adjacent comment was written to prevent.
Failure scenario: Opening the note-editor modal, confirming a pending self-attest (setConfirmingIds at use-club-dues-actions.ts:130-140), or any other screen-level state tick on club-dues-screen.tsx gives FeedList a new renderItem reference, which (per the very comment this code sits next to) re-renders every currently-rendered DuesRow instead of just the one row whose state actually changed. DuesRow itself is not React.memo-wrapped either, so there's no second line of defense. Bounded by list virtualization (only visible rows re-render) and typical roster size (tens of members), so not a user-visible stutter today, but it is a confirmed regression against a named, documented prior fix, introduced by this same wave's U-14 inline-pill refactor (dues-row.tsx's doc header dates the refactor 2026-09-17).
Severity: wave.
Canonical fix: Wrap handleMarkPaid, handleWaive, handleUnmarkPaid (and, for consistency, handleMarkAllPaid/handleNoteSubmit/handleGenerateDues/handleAttestSubmit) in useCallback in use-club-dues-actions.ts, matching the pattern already used two functions above them (openNote/openAttest/handleConfirmPending) in the same file.
Prevention: this is exactly the shape an ESLint rule can catch mechanically (a function returned from a custom hook, consumed inside another hook's dependency array, that isn't itself memoized) — worth checking whether react-hooks/exhaustive-deps or a project-custom rule can be tightened to flag a non-memoized function literal flowing into a useCallback/useMemo dependency array of a sibling hook. Absent that, a comment audit: grep hooks under use-club-dues-actions.ts's pattern (a Use*ActionsResult object of handlers, several useCallback and several plain) for other action-hooks with the same split.
PF-03 — Club-wizard persist-key bump (v10→v11) discards in-flight drafts for a change that didn't need it
Evidence: packages/app/src/lib/persist-keys.ts:38 bumped clubWizard from 'twomore-club-wizard-v10' to 'twomore-club-wizard-v11' in this delta (confirmed via git diff 56fc2132..HEAD -- packages/app/src/lib/persist-keys.ts). The store's own new comment (club-wizard.store.ts, diff of the same commit) reads: "formData grew quickStart... Read via a plain ?? false-style boolean guard in resumeClubDraft... so a v10 draft would resume fine under the same key — bumped anyway to follow this file's own precedent (any formData shape change gets a fresh key)." The persist migrate option is a bare passthrough (migrate: (state: unknown) => state as ClubWizardState, per the same file's own doc comment), so there is no real shape migration happening either way — the bump's only effect is changing which MMKV key the store reads/writes, orphaning whatever was under the old key.
Failure scenario: Any user with an in-progress, not-yet-submitted club-creation draft at the moment this OTA lands loses it — the wizard opens fresh under the new key with no warning, no attempted recovery, and no telemetry event distinguishing "no draft" from "draft orphaned by a version bump." The same comment shows this is not a one-off: the prior bump (v8→v9) carries an identical "would resume fine... bumped anyway" note. This directly cuts against the product's own "이어서 하기" (resume) feature and the Toss-grade UX bar this codebase holds itself to (CLAUDE.md's "would Toss ship this?" filter) — Toss would not silently discard an unfinished form on a routine app update.
Severity: note (an acknowledged, repeated, intentional tradeoff — not a new regression — but the recurrence across v8→v9→v10→v11 in a few weeks suggests the underlying convention deserves reconsideration rather than repeated acceptance).
Canonical fix: none required immediately; the safer general fix is to distinguish, in the file's own convention comment, "shape changes that need a migrator" from "additive fields already read with a safe fallback" (this v11 bump is explicitly the latter) — the latter case should NOT bump the key. If simplicity is preferred over precision, consider instead writing a real migrate(state, version) that fills in defaults for new fields, which is exactly what the ?? false guard already does at read time — promoting it into migrate would let the SAME key survive every additive change, and reserve key bumps for genuinely-incompatible shape changes (a field renamed or repurposed).
Prevention: none mechanical; a convention-only fix. Worth a line in the wizard's own header comment the next time this file is touched.
PF-04 — Court/venue mutations don't invalidate the club-level cache that the new courts→regions trigger updates
Evidence: useCreateVenue/useUpdateVenue/useDeleteVenue (packages/app/src/presentation/hooks/queries/use-venues.ts:109-137) invalidate only venueKeys.byClub(clubId) (+ venueKeys.detail for update). None invalidates clubKeys.detail/clubDetailBundleKeys.detail or any club-scoped key. club-venues-screen.tsx:202-206 (the only feature call site besides the wizard) calls these three hooks with no additional invalidateQueries/cache-write anywhere in the file. Migration 00650 (this delta) added a trigger that recomputes clubs.adm1_slug/adm2_slug + club_play_regions as a server-side side effect of every court_venues write — a fact the client-side mutation hooks predate and don't know about.
Failure scenario: A club admin adds or edits a court on club-venues-screen. The court list updates correctly (its own key is invalidated). The club's own region tags — read via useClubDetailBundle (club.club_play_regions field, STALE_TIME.realtime = 60s per use-club-detail-bundle.ts:40) anywhere else in the app (club-detail home tab, discovery/browse filters keyed on adm1Slug/adm2Slug) — keep showing the pre-edit region(s) until the bundle's 60-second staleTime lapses and some trigger (refocus, remount, reconnect) fires a refetch. In the interim, a club whose admin just added a second-district court would not appear in that district's discovery filter yet, and the club's own header could show its old sole region.
Severity: wave (self-corrects within roughly a minute of normal navigation; not permanent staleness, but a confirmed, silent gap between a new server-side derivation and the client cache layer built specifically to avoid this class of bug).
Canonical fix: extend useCreateVenue/useUpdateVenue/useDeleteVenue's invalidateKeys to also include clubDetailBundleKeys.detail(clubId) (and clubKeys.detail(clubId) if a bare useClub reader exists) — same shape as any other Protocol-C-style "this write also changed a derived field on another entity" invalidation already in the codebase (e.g. writeRsvp's multi-key fan-out).
Prevention: none mechanical exists for "a new DB trigger changed entity X as a side effect of writing entity Y, and Y's mutation hooks don't know" — this is the same class the data-and-hooks.md "one logical fact, one consolidation helper" rule addresses for hand-enumerated keys, but here the desync is cross-entity (venue write → club field), which that rule doesn't cover today. Worth a line in docs/canon/data-and-hooks.md under that rule: "a DB trigger that derives entity X's fields from entity Y's writes needs Y's own mutation hooks to invalidate X's key too — the trigger existing server-side doesn't make the client cache aware of it."
Findings — Data integrity
DI-01 — trg_sync_club_regions races under the wizard's parallel court-venue inserts, can silently drop a district
Evidence: Migration 00650_club_regions_from_courts.sql:100-162 adds private.sync_club_regions_from_courts(p_club_id): a full recompute-and-replace (SELECT over all of the club's court_venues, then UPDATE clubs + DELETE+INSERT club_play_regions), fired by trg_sync_club_regions (AFTER INSERT OR DELETE OR UPDATE ... FOR EACH ROW, lines 189-192) on every court_venues write. create-club/screen/use-club-submit.ts:182-236 creates one court_venues row per staged court pick via Promise.allSettled(picks.map(async pick => { await createVenue.mutateAsync(...) })) — the code's own comment states this is deliberately parallel, not sequential ("N sequential awaited round-trips would scale submit latency linearly... allSettled + per-pick catch keep the non-fatal-per-pick contract"). Each createVenue.mutateAsync is a separate PostgREST call and therefore a separate DB transaction.
Postgres's default READ COMMITTED isolation means each trigger's internal SELECT (querying sibling court_venues rows for the same club) takes a fresh snapshot at statement time that does not include any concurrently-open sibling transaction's not-yet-committed insert. For two courts in different districts submitted via Promise.allSettled, if transaction B's trigger SELECT runs before transaction A commits, B computes its recompute from only its own row (not A's), and if B's UPDATE clubs/DELETE+INSERT club_play_regions is the last one to actually write (which a clubs row lock forces to happen only after A commits, but does not force B to re-derive its already-computed values), the final persisted state can reflect only one of the two districts — the other is silently dropped, with no error, no retry, and no self-healing until some future, unrelated court_venues write on that club fires the trigger again with a now-fully-committed view.
The pgTAP suite (supabase/tests/00650_club_regions_from_courts.test.sql) only exercises sequential, single-transaction inserts (each INSERT is its own statement inside one enclosing BEGIN...ROLLBACK), which cannot reproduce a cross-transaction visibility race — so this gap is structurally untestable by the existing suite, not merely untested.
Failure scenario: A user runs the create-club wizard, selects home-court picks spanning two districts (e.g. Gangnam + Haeundae) in the same submission. If the two createVenue calls' underlying transactions overlap unfavorably (plausible given they're fired back-to-back over the network with no synchronization), the resulting club can end up with only ONE district recorded in clubs.adm1_slug/adm2_slug + club_play_regions, even though both courts exist in court_venues. The club is then invisible to discovery/browse filters for the dropped district until an unrelated future court edit happens to fire the trigger again with full visibility.
Severity: wave (silent, but requires a specific input — multi-district picks at creation time — and self-heals on the next court add/edit/delete for that club).
Canonical fix: extend private.sync_club_regions_from_courts with PERFORM pg_advisory_xact_lock(hashtext(p_club_id::text)); as its first statement. This serializes concurrent recomputes for the same club: the second trigger to acquire the lock does so only after the first's transaction commits, and because its own SELECT runs as a fresh statement after acquiring the lock, it then sees the fully-committed sibling row. This is not a new idiom for this codebase — pg_advisory-family locks are already used for exactly this kind of serialization in the venue-maintenance migrations (e.g. 00619_venue_maintenance_atomic_apply.sql).
Prevention: a pgTAP test cannot exercise true cross-transaction concurrency in the standard single-connection harness this suite uses; the practical prevention is the advisory lock itself (making the race structurally impossible rather than merely improbable) plus a code-review note on this migration file pointing future readers at the fix already applied.
DI-02 — Fee-edit boundary and the RSVP-triggered hold-creation path aren't mutually serialized — a concurrent confirm can leave a hold at the pre-edit fee
Evidence: private.block_fee_edit_with_existing_holds (body unchanged by 00649, only gains a HINT — 00649_session_fee_edit_until_payment.sql:48-59) rejects a sessions.participation_fee UPDATE only when EXISTS (SELECT 1 FROM session_payments WHERE session_id = NEW.id) at check time. Separately, private.sync_session_payment_on_rsvp (live definition confirmed via docker exec ... psql -c "\sf private.sync_session_payment_on_rsvp()", current body from 00543_club_host_fee_exempt.sql) fires on every RSVP insert/update: on a confirmed status it does SELECT s.participation_fee ... FROM sessions s ... WHERE s.id = NEW.session_id and inserts a session_payments row stamped with that read's value of the fee. Neither function takes a row lock (FOR UPDATE) or an advisory lock on the session before reading/checking — a plain UPDATE does take an implicit row lock on the sessions row for the editor's own transaction, but a plain SELECT (as sync_session_payment_on_rsvp uses) never blocks on that lock in Postgres MVCC; it simply reads the last-committed value as of its own snapshot.
The 00649 migration's own comment acknowledges a race exists ("this trigger firing in practice means a hold landed in the race window between this form's load and its save") but only describes and guards the direction where a hold already exists when the edit is attempted. It does not address the opposite interleaving: a fee-edit transaction's existence-check finds zero holds and proceeds, while a concurrent RSVP-confirm transaction's fee-read (for the same session) is mid-flight against the pre-edit value and creates a hold stamped with the OLD fee. Both can commit, leaving sessions.participation_fee at the new value with a session_payments row that doesn't match it, and nothing (no CHECK constraint, no reconciliation job) ever detects or corrects the mismatch.
Unlike DI-01, the trigger path here (sync_session_payment_on_rsvp) fires on the ordinary, frequent action of confirming an RSVP on a fee-bearing session — not a rare bulk-parallel-insert flow — so the realistic scenario is mundane: a 총무 corrects a session's fee shortly after publishing it, while members are actively RSVPing.
Failure scenario: Session created with participation_fee = 10000. A member's RSVP-confirm and the host's fee-edit-to-20000 land within the same narrow window. Outcomes observed to be possible under Postgres's documented READ COMMITTED semantics: the fee ends up 20000, but that member's session_payments.amount stays 10000 — a real-money discrepancy with no error raised to either party and no flag distinguishing it from an intentionally-grandfathered rate.
Severity: wave (requires sub-second-to-few-second concurrent timing between two specific actions; consequence is a silent amount mismatch rather than a crash or double-charge, and existing manual dues/payment-adjustment tooling can correct it once noticed — but it is money-domain and currently invisible).
Canonical fix: have sync_session_payment_on_rsvp take a lock on the session row before reading the fee it will stamp — e.g. PERFORM 1 FROM public.sessions WHERE id = NEW.session_id FOR UPDATE; immediately before the existing SELECT ... INTO v_fee. Because block_fee_edit_with_existing_holds fires from a plain UPDATE on the same sessions row (which already takes the equivalent row-exclusive lock for the duration of that transaction), the two would then correctly serialize against each other: whichever transaction's lock wins goes first, and the second one's subsequent read is guaranteed to see the first's committed result.
Prevention: a pgTAP test for this needs genuine concurrency (e.g. two dblink/background-worker-driven sessions, or a documented manual verification step) since a single-transaction pgTAP file cannot express it — same structural limitation as DI-01. Consider adding one shared "concurrent trigger interleaving" test helper (using dblink or pg_background, if available in the test image) the next time either of these two triggers is touched, given this is now the second instance of the same race SHAPE (court regions, session fees) in one week's migrations.
DI-03 — MAX_BASES = 3 is declared in both TypeScript and SQL with no parity gate
Evidence: packages/features/clubs/src/create-club/use-step2-picks.ts:39 declares const MAX_BASES = 3; (client-side wizard preview cap). 00650_club_regions_from_courts.sql's sync_club_regions_from_courts hardcodes the same cap as rn = 1 (primary) + rn BETWEEN 2 AND 3 (additional bases) — a total of 3, matching today. data-and-hooks.md's own house rule (item under "Telemetry, perf & debugging," 2026-09 addition) states plainly: "A rule that exists in BOTH TypeScript and Postgres is a bug waiting to happen... a policy NUMBER lives in data [or] a parity gate is the fallback" and lists the project's existing gates (check:cancel-policy, check:attendance-policy, check:elo-parity, check:cron-budget) as the precedent. No check:club-regions-cap (or equivalent) exists — grepped scripts/*.mjs and package.json for any region/base-cap parity check; none found.
Failure scenario: none today (values agree). The exposure is future: a product decision to raise/lower the total-bases cap only updates one side (most likely the TypeScript constant, since that's what a wizard-copy change would touch) and the SQL trigger silently keeps enforcing the old cap (or vice versa), with no test or type system connecting them.
Severity: note.
Canonical fix: none needed now; when this constant is next touched, either move it into a private.app_config-style data row the SQL function reads (rule 1 of the codebase's own policy-number law) or add a check:club-regions-cap-parity gate mirroring the existing four.
Prevention: add to the "policy numbers in two languages" audit list the next time that gate family is swept.
DI-04 — Generated Supabase types weren't regenerated for the 00650/00651 wave (harmless this time, but a process gap)
Evidence: packages/app/src/adapters/supabase/generated.types.ts was last touched at commit c36b5111 (2026-09-16, the "usability tier 2" wave that shipped 00648/00649); git show --stat 747cc80f (the wave that shipped 00650/00651) does not touch that file. Checked whether this constitutes real drift: get_club_detail_bundle's signature ({ p_club_id: string } -> Json) is unchanged by 00651 (only the function body/returned JSON shape grew) — confirmed present and correct in generated.types.ts:6975. mark_dues_paid_bulk (00648) is present and correct at generated.types.ts:7389. private.sync_club_regions_from_courts/private.tg_sync_club_regions_from_courts (00650) are private-schema, REVOKE ALL FROM PUBLIC, never GRANTed — not PostgREST-exposed RPCs, so they were never expected to appear in generated types. Net: no actual type drift resulted from skipping the regen step in this specific wave, purely because neither new SQL object needed one.
Failure scenario: none this time. The risk is process, not output: yarn check:supabase-types is the documented drift gate, and this wave's release notes/commits show no evidence it was run after 00650/00651 landed — if a future migration in the same "skip the regen" habit does change a public RPC signature, the same skip would produce silent type drift undetected until yarn check (or CI's type-drift job) catches it, or worse, a runtime type mismatch.
Severity: note.
Canonical fix: none needed for this wave. Re-run yarn check:supabase-types (piped through prettier per its own documented footgun) as part of closing out any migration-adding commit, regardless of whether the author believes the signature changed — the belief is exactly the thing that's cheap to get wrong.
Prevention: already covered by the existing yarn check:supabase-types gate in principle; the gap is process discipline (running it), not tooling.
Verified sound
mark_dues_paid_bulk(00648) authority + idempotency:SECURITY INVOKER,search_path = '',REVOKE ALL FROM PUBLIC, anon+ explicitGRANT ... TO authenticated(00648_dues_mark_paid_bulk.sql:72-73). RLS (dues_update:is_club_admin(club_id) AND user_id <> auth.uid()) is the sole authorization surface, identical to the single-row path. Predicate parity with the single-row write confirmed exactly: samestatus IN ('unpaid','partial')gate asmarkAllPaidTargets(use-club-dues-data.ts:156-159), same three-keyinvalidateKeysasuseUpdateDuesStatus'sonSettled(use-dues-payment-actions.ts:165-169vsuse-update-dues.ts:178-191), same untouched-payment_methodbehavior. pgTAP (00648_dues_mark_paid_bulk.test.sql) covers foreign-admin rejection, non-admin rejection, already-paid exclusion, self-row exclusion (RLSuser_id <> auth.uid()), and exact returned-count correctness across a 4-id array mixing valid/invalid targets.get_club_detail_bundle'srecentMediaUploads(00651) does not leak non-public media to non-members. Verified against the LIVEclub_mediaRLS policy (viadocker exec ... psql -c "\d public.club_media", not just the historical migration file):"club media metadata read"=is_club_admin(club_id) OR (archived_at IS NULL AND (is_club_member(club_id) OR (approval_status='approved' AND visibility='public' AND is_public_discoverable_club(club_id)))). Because the function isSECURITY INVOKER, this policy applies as the calling user regardless of what the newmedia_upload_rowsCTE's ownWHEREclause says — a non-member is restricted to public-visibility approved rows by RLS itself, independent of the aggregate's filter.uploaderIdis carried as a bare id and resolved to a display name client-side (use-home-tab-data.tsx's existing bulkprofileByIdmap) — no PII in the JSON payload.- Index coverage for the new media-grouping query.
idx_club_media_club_status(00255_club_growth_profile_media.sql:71-73, partial indexWHERE archived_at IS NULLon(club_id, approval_status, visibility, sort_order, created_at DESC)) exactly covers00651'smedia_upload_rowspredicate (club_id = ... AND approval_status = 'approved' AND archived_at IS NULL). Confirmed present on the live local schema via\d public.club_media.EXPLAINon an empty table correctly shows a seq scan (0 rows — expected planner behavior, not a signal of a missing index); the index exists and matches the predicate shape for when data volume justifies it. get_club_detail_bundlepayload growth is bounded and off the critical path.recentMediaUploadsis capped at 3 groups × 4 ids/paths each (00651, lines 224-225, 239) — roughly 1.5-2.5KB worst case, small relative to the bundle's other capped fields (posts LIMIT 15, sessions LIMIT 20). Signed-URL resolution (useClubMediaSignedUrls) is a separate, non-blocking follow-up query —use-home-tab-data.tsx'sshowSkeletongate does not wait on it, so thumbnails progressively pop in without delaying first paint.ElapsedClock(consumed bySessionLiveAnchor) correctly isolates per-tick re-renders viaReact.memo+useSharedTicker+ a module-level stableformatcallback (formatElapsedDurationKo, imported not inlined) — does not reintroduce the "whole-parent re-renders every tick" class of bug this component's own doc comment describes fixing elsewhere.useHomeTabData'sHomeFeedRowmerge anduseClubMediaSignedUrlswiring follow Protocol A correctly — one bulk signed-URL call at the parent (use-home-tab-data.tsx:188-192), path-set deduped/sorted before the query key,feedRowsmerge properly memoized with a correct, complete dependency array (feedFilter, pinnedPosts, posts, recentMatches, recentMediaUploads).useWizardNavigation's newsequenceoption (quick-start wizard) is a deliberately-unmemoized plain array per its own reasoned comment (consumed synchronously, never as an effect dependency) —nextInSequence/prevInSequenceare O(totalSteps), trivial at wizard scale (≤10 steps). No unnecessary re-computation.HeaderTextAction/ListRow'sNAME_LINES/titleLinesare plain presentational leaves with static layout props — no per-render cost or layout-thrash concern found.- 00649's pgTAP coverage (
00649_session_fee_edit_until_payment.test.sql) correctly locks down the boundary it claims: a fee change is blocked once ANY hold exists (with the new stable HINT) and succeeds with zero holds, plus the widenedsession_updatedsignal fires on a fee-only edit. (Does not and structurally cannot cover the cross-transaction race in DI-02 — noted there.) - The entity→mapper→adapter→migration→types chain for
ClubMediaUploadGroup/recentMediaUploadsis complete and correctly typed: domain entity (domain/entities/club/club.ts:332-342) →RawMediaUploadGroup+mapMediaUploadGroupmapper going throughsafeMapper Protocol K (club-detail-bundle.supabase.ts:44-66,128-132) → port (ClubDetailBundleRepositoryPort) → migration 00651 → generated types (unaffected, see DI-04 — the RPC'sJson-typed signature is unchanged, so nothing was missed).