Data & Hooks Conventions
Status: Active Last reviewed: 2026-10-11
Single source of truth for how TwoMore fetches, mutates, and displays data. Covers formatting utilities, mutation and query hook discipline, the port/adapter/mapper layer, telemetry and performance instrumentation, mock data seeding, API key rotation, tier display, cross-query cache writes, PII/consent/data-collection obligations, the profile-photo (face) rule, and SQL migration footguns (SECDEF discipline,
CREATE OR REPLACEtraps, idempotency-guard silent skips, secret hygiene, and the no-PII-in-JSONB rule). Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md (DATA-1..DATA-27, KR-5).
Formatting & display utilities
- Formatting a date →
packages/app/src/presentation/utils/date-format.ts. Never inlinegetMonth()/getDay()in components. ONE-CLOCK (owner ruling, improvement plan 2026-09-23, W2 F7) — 24-hourHH:MMis the one clock, everywhere. Every time-of-day render readsformatHm(@/config/date-utils, for aDate) orformatTime(the module above, for an"HH:MM[:SS]"string) — deadlines (formatDeadline, whose past form isM/D HH:MM 마감됨, e.g.4/13 09:00 마감됨), the late-arrival chip, and the wizard's deadline receipt all read through them. The retired 12-hour오전/오후house clock (formatTimeOfDayKo12) and its i18n keys are DELETED, not merely unused — don't reintroduce a 12-hour form. Enforced by@twomore/no-inline-date-format, which also catches a hand-rolledgetHours()-style value fed topadStart(a sweep with this wider check found three more ad-hoc local-date builders, all moved onto the domain's one local-date formatter,toLocalDateStr,packages/app/src/domain/rules/local-date.ts). Evidence: decision log (ROUND 4 — one clock, empty profiles, rows). - Formatting an ELO rating →
formatElo(elo)from@twomore/app. Never inlineelo.toLocaleString('ko-KR'). - Formatting a session/match format label →
formatSessionFormat(format)from@twomore/app(reads the long-formt().sessions.format*namespace). Never re-declare a localformatLabel. Space-constrained short-form badges (getFormatLabelinpackages/features/sessions/src/match-board-helpers.ts, used bymatch-board-screen.tsx's fact row andadd-round-sheet.tsx's feasibility tile —SessionCardV2now reads the same long-formformatSessionFormatfor its 🎾 fact, not this map) keep their own혼복-style map — different i18n namespace (t().sessionHeader, retained from the retiredSessionHeadercomponent) by design. - Displaying a session title →
sessionTitle(session)from@twomore/app(generated from format + competitive/friendly style, e.g. "혼합복식 경쟁"). Session titles are always metadata-derived, never custom — never readsession.titlefor display (the create/edit flows no longer collect a custom title; the DB column persists only a generated fallback). Use it at every surface: detail header + identity card,SessionCardV2, scorecard headers, share cards, recap/history. - Grouping list items into "YYYY년 M월" sections →
groupByMonth(items, getDate)from@twomore/app(returns{ title, data }[]for SectionList). Never re-implement the month-bucketing loop. - Filtering RSVPs by status →
filterConfirmed/filterWaitlisted/confirmedUserIds/isConfirmedfrom@twomore/app(domain/rules/rsvp-selectors). Never inlinersvps.filter(r => r.status === 'confirmed'). Bespoke partitions (waitlist sorted by position, confirmed-excluding-self, sessionId extraction) stay inline. - A person's name for display is never blank →
profileDisplayName(profile)from@twomore/appis total: a name of only whitespace is no name (신규 멤버), a profile the read did not return (undefined) reads신규 멤버too, and탈퇴한 멤버is said only of a profile whoseanonymizedAtthe data sets. Every team line, roster, person row and score sheet takes a name through it; a query that has not answered yet is not a missing profile, so the block waits (draws nothing) rather than name a stand-in. NeverbyId.get(id)?.displayName ?? ''or a second fallback. - Building an id→name lookup from profiles →
buildNameMap(profiles)(id→displayName) /buildProfileStubMap(profiles)(id→{displayName, avatarUrl}) /getProfileName(map, id)from@twomore/app(presentation/utils/profile-map). Full-Profilemaps stay inlinenew Map(profiles.map(p => [p.id, p])). - Validating a name →
validateName(). Never inline if/else chains - Reading auth state in presentation →
useAuth(). Neversupabase.auth.*outside auth adapter - Reading theme →
useThemeColors()/useTheme(). NeveruseColorScheme()outside theme-provider - UI strings →
t()frompackages/app/src/config/i18n/. Code + comments → English - Option
valuevslabel→ in a{ value, label }option (chips / selects / PillNav / SegmentedTabs / filters),valueis a stable language-invariant key (used for comparison, DB storage, persistence, deep-links, React keys);labelis the display string and the only field that may hold Korean (ideally viat()). NEVER put a Korean/localized string invalue— it's a value/label confusion that breaks the moment the value meets a stored key or another locale. Canonical example: region uses the english SIDO key ('seoul') as value, Korean ('서울') only as label (REGIONS/PICKUP_REGION_OPTIONSfromconfig/regions.ts,SIDO_OPTIONSfor key→label display). Enforced by@twomore/no-localized-option-value. The region filter shipped broken (Korean value compared against the english-keysession.region) until this was fixed (2026-05-22).
Club regular meets & match history
Club regular meets are RRULE-modeled (iCalendar RFC5545 subset). The
club_regular_meetstable storesrrule TEXT(FREQ/INTERVAL/BYDAY/BYSETPOS + dtstart phase anchor); date-math uses therrulenpm package (nextOccurrence,regularMeetDaysfromdomain/entities/regular-meet.entity.ts) — pure-JS, Hermes-safe, OTA-deployable.clubs.primary_day+clubs.primary_time_slotare DENORMALIZED CACHE synced by theprivate.sync_club_primary_scheduletrigger (AFTER INSERT/UPDATE/DELETE onclub_regular_meets) — never write them directly; they exist only for cheap card-rendering queries. The source of truth is alwaysclub_regular_meets.club_schedule_slotsis RETIRED (dormant since the 2026-05 roadmap; data migrated; do not reference). Korean display lives inpresentation/utils/recurrence-format.ts(describeRecurrence/describeRegularMeet— produces "격주 토요일 09:00~11:00") — deliberately i18n-free domain strings (nott()calls), because recurrence descriptions are domain-derived from structured data, not lookup keys. When extending the recurrence editor, stay within the supported RRULE subset (isSupportedRrule); arbitrary RRULE strings from outside callers are not guaranteed to render correctly in Korean. Architecture note: rrule was previously rejected for DST complexity — Korea's no-DST single-timezone neutralizes that entirely; iCalendar strings stored in the DB remain portable should TZ support ever be needed.Match history is sourced from PARTICIPATION, not current club membership.
useMatchHistoryreadsrsvps.findPlayedSessions(userId)(every session the user confirmed / no-showed for — INCLUDING guest sessions in clubs they aren't a member of, AND pickups) →sessions.findByIds(...)→ matches. NEVER source a player's match history from their current-member clubs (the oldsessions.findPastshape) — that silently DROPS guest + pickup matches and retroactively appears/disappears matches as the user joins/leaves clubs. The "played" gate is the match-level filter (status==='completed' && both scores non-null && user-in-match), NOT a session-status pre-filter — so a scored match in a session later cancelled mid-play still appears. Noteuse-player-stats-detail+use-activity-datastill source fromfindPast(member-club stats are the intended scope there) — only the HISTORY list is participation-sourced.Every match-history categorization dimension is DURABLY SNAPSHOTTED at write-time, never derived from mutable CURRENT state (the snapshot-at-write principle — a fact computed from current club_members / current friendships / current ELO bands silently drifts as the user/relationships/bands change). The durable sources per dimension:
membership(member/guest/pickup) ←rsvps.joined_as_member(00233 BEFORE INSERT trigger).nature(friendly/competitive/tournament/league) ←matches.nature, snapshotted from the session at the→completedtransition (00234snapshot_match_completion_contexttrigger — fires on completed becausesubmit_match_scoresets completed directly, skippingin_progress);deriveNature(match, session)readsmatch.nature ?? <session fallback>(fallback only for pre-migration rows). friends-vs-strangers ←matches.friend_pairsJSONB (the accepted-friendship[lo,hi]edges among the players at completion, viewer-agnostic); the records-history friends scope readsentry.hadFriendInMatch ?? <current-friends fallback>— NEVER intersect against the live friends list alone (friendships are hard-deleted, so the live list is unreconstructable history). tier-at-time ←elo_history.tier_before(the player's 5-tier label from the row's ownrating_before, set by 00235snapshot_elo_tier_beforeBEFORE INSERT trigger); an opponent's tier-at-time is THEIR ownelo_historyrow, so no per-match tier columns. ELO→tier bands have ONE server-side source:private.tier_index_from_elo/private.tier_from_elo(00235) —rsvps_enforce_tierconsumes it; never re-inline the900/1400/2000/2500thresholds in SQL again (the 3rd inline copy was the 00151/00229 7→5-tier drift). When adding a new dimension: snapshot it at a GUARANTEED transition trigger, forward-only (NULL on old rows = honest "unknown" — never backfill a mutable-derived value, which manufactures a wrong durable fact), and thread it Match-entity → mapper →MatchHistoryEntry.Player figures: another person's count 경쟁 only, one's own count every match (batch 66, 2026-11-15).
get_player_match_history(00776) reads a match as rated whenprivate.match_rating_is_appliedsays so (anelo_historyrow of reasonmatch, the guard the rating step itself uses); a friendly session and a skipped outcome (walkover, suspended) are completed yet not rated. For any viewer but the subject, or withp_rated_only, the record, the rows andtruncatedare that one rated population; the subject's own read keepsrecordover every finished match and addsratedRecord. A surface that prints another person's 총 경기 or 승률 reads it rated only and says 경쟁 (common.competitiveMatches,common.competitiveWinRate); a person's own tiles say 총 경기 / 승률 over the fullrecord, and their 기간 기록 separates 경쟁 and 친선 (record-detail/kind-rows.ts): 경쟁 means "counted for the rating" (ratedRecord), and the 친선 row holds everything else (recordminusratedRecord). A ranking row's W-L-D is rated only for every row (00778). The viewer's own head-to-head, heatmap and share card stay on every match. A new reader of another person's figures goes through this function, never a second count.
Mutations & query hooks
- Creating a mutation →
createMutationHookfactory. Never rawuseMutation(except optimistic updates, which callrefuseWriteWhileOffline()). The factory accepts an optionalmutationKey: QueryKeyconfig — pass it as[<entity>, <action>](e.g.['rsvp', 'upsert']) so TanStack can deduplicate concurrent observers and so the AGENTS.md DATA-2 rule stays satisfied. Required only for mutations whose state needs to be observed cross-component; optional for fire-and-forget admin actions. createMutationHook'sofflineoption states the write's offline class (owner decision 16):'block'(the default) refuses atmutate()while offline with the typedOfflineWriteBlockedError(codeOFFLINE_WRITE_BLOCKED,presentation/utils/offline-write-blocked.ts), unreported and with no Toast;'queue'keeps TanStack's pause-and-replay and is limited to the reviewed read-state marks (useMarkThreadRead,useMarkPostRead,useMarkPostsRead). The durable queue isuseQueueableMutation, never this factory. A rawuseMutation(optimistic hooks) calls the one sharedrefuseWriteWhileOffline()inonMutate, before any optimistic patch (a background write such asuseRecordClubGrowthEventis dropped offline: replaying an impression later would count it at the wrong time). Every mutation hook is in exactly one of the three classes (AGENTS.md DATA-28;offline-class.gate.test.tsholds it with a known-positive canary per rule).- The persist allow-list law (EP-2, 2026-11-12). A query root reaches the device's disk (MMKV, 24 h) only if
PERSIST_TABLEinquery-persist-policy.tshas a row for it withpersist: true, a reason (the viewer's own data, or what every member already sees) and the offline screens that need it; a root without a row, a refused scope of a persisted root (allows) and a malformed key are never written, and an entry an older build wrote under a refused root is never restored and is deleted by the first sweep.query-persist-table.test.tscalls every key factory inquery-keys/**and fails when a root has no row (a new query forces the decision) or a row has no root, and lists per offline screen the keys it reads, which must all persist. The hidden class (staff data, a bank account, an ownership claim or a certification, a bearer grant, another person's money, a typed search term) ispersist: falseand its page withholds it offline (QueryBoundary offline="hidden", or anonlinegate). Scopes of a persisted root that stay in memory:club-join-requests/club(onlyminepersists),dues(onlymemberandplans),sessionPayments(onlyuser),attendance(onlysummary),guestApplications(only the viewer's own application),clubsbrowse lists and signed media grants,profiles/search,dm/signed-url, and an official's read of a member's trust record (['trustRecord', 'user', userId, clubId]with a club: it holds the member's 사유 인정 category and note, health information, 00754; the member's own record has no club in its key and stays cached, as does the requester's own request state,attendanceExcuse). Both account reads,clubBankAccounts(the club's) andsessionDepositAccount(a 매치's own; read only for the host or a confirmed seat, throughuseDepositAccount), arepersist: false: an account number is never in a signal payload, a push, a log, telemetry, an error message or a persisted draft. What the disk cache is today:beginSessionBootstrap(session-lifecycle.ts→clearQueryCacheMmkvStrict, since 2026-09-07) deletes every persisted query row at each start, so one account's cache can never show to another: the cache is written during a session and does not survive a cold start. Offline display after a restart is not provided; offline display while the app stays in memory is. An account-stamped, encrypted cache that restores for the same account (a key in the secure store, a secure random source:expo-crypto, a native module) is built after the next native build. AGENTS.md STORE-3. createMutationHook(...)is MODULE-SCOPE ONLY — never invoke it inside a component/hook body. Alwaysexport const useFoo = createMutationHook({...})at module scope. The factory's returned hook callsuseQueryClient+useMutation; callingcreateMutationHook({...})()during render builds a fresh hook function every render, and the React Compiler (enabled) memoizes/reorders that inline hook-returning call so the inner hooks are skipped between renders → an intermittent, lint-invisible"Should have a queue. You are likely calling Hooks conditionally"crash (the compiler healthcheck +rules-of-hooksboth pass — eslint doesn't recognize the factory's return value as a hook). If you need aqueryClient-dependent side effect (e.g. invalidating a sibling cache after a board-post write), define the factory at module scope and pass the closure via the per-callonSuccessoverride:const useFooBase = createMutationHook({...}); export function useFoo() { const qc = useQueryClient(); return useFooBase({ onSuccess: (r, i) => { /* uses qc */ } }); }. Lesson from 2026-05-25 —useUpdateSession+useCreateSessionboth did the inline-factory pattern and crashedEditSessionSheet/create-session intermittently for days; localized only viarecent_client_errors.component_stack_head(thedev_read_telemetrystream dropscomponent_stack).- A hook that travels as a value (through context, a prop or a registry) is bound to a name that starts with
usebefore it is called; neveruseContext(X)(). The React Compiler knows a hook by its name, so an unnamed call is cached as an ordinary function and skipped on later renders, which throws "Should have a queue. You are likely calling Hooks conditionally" (the second W5 build,useBottomChromeReport). Writeconst useProvidedReporter = useContext(X); useProvidedReporter();.packages/ui/src/__tests__/bottom-chrome-report-compiled.test.tscompiles the real file and renders it twice. The same holds for a hook inside a literal or an argument:{ email: useRef(null) }is cached as one value and the hook is skipped (the sign-in form, same build), so every hook call is a statement of its own.yarn check:compiled-hookscompiles every shipped file as the phone does and fails on either form. - Mutations whose target row is already cached → optimistic via
applyOptimisticPatches. Wait-for-server feels broken even on fast networks. - Pure CPU derivations of upstream query data →
useMemo, notuseQuery.useQueryis for I/O — its cache key churns when wrapping pure compute. - Don't fire the same
useQuerykey from two component levels — extra observer subscription cost per render with no caching benefit. Lift state up. - Fetching all open/locked sessions across user's clubs →
useClubUpcomingSessions. RSVP-filtered variant →useRsvpdUpcomingSessions. Never use the olduseUpcomingSessionsname. - A form that edits a server value holds it in
useServerDraft(server, toDraft)(F11, 2026-09-29). It hydrates on arrival, re-hydrates only while pristine, never lets a refetch replace a dirty field, andreset()adopts what the server holds now;markSaved()makes the saved edit the new baseline. Never key a draft on the query result's identity or hand-roll a "hydrated" flag. - Several writes of one kind → one batch mutation hook, never a repository call in a loop (F16, 2026-09-29).
useCreateRegularMeetsis the model: the loop lives in themutationFn, per-item failures reachcaptureCriticalError, and the entity's caches are invalidated once. "Hooks can't run in loops" is not a reason to bypass the mutation layer.
Ports, adapters & mappers
- Presentation hook touching Supabase → go through the registered port via
import { <port> } from '@/registry'. Neverimport { supabase } from '@/adapters/supabase/client'orgetSupabase()directly insidepresentation/hooks/**. The hex direction is presentation → registry → port → adapter; bypassing breaks the swap-adapter-for-tests contract and hides the SQL from architecture review. If the existing port doesn't expose the read/write you need, add a method to the port + implement in the adapter in the same commit (canonical example:attendanceRecords.findStrikesByMemberadded in commitd800eb8). - Auth session storage (
adapters/supabase/secure-storage.ts) is expo-secure-store behind an in-memory write-through cache — supabase-js reloads the session fromauth.storageon EVERY request (GoTrueClient.__loadSession), so without the cache each PostgREST/Storage/Functions call paid an Android Keystore decrypt (10–50 ms, serialized natively) before its first byte. Never bypass the cache with a directSecureStoreread of the session key; never give supabase-js another uncached async storage. - Handling adapter error →
handleError(). Never silent catch. Fire-and-forget →logger.warn() - A
.in(column, ids)read with a caller-collected id list goes throughselectInBatches(ids, (batch) => …)(base.adapter.ts, 2026-09-29). supabase-js sends.in()as a GET query string, so a few hundred UUIDs beside a wide projection pass the gateway's URL limit and fail as a bare "Failed to fetch". Batches of 100, deduped, merged; re-apply any global order/limit after the merge. RPC arguments ride in a POST body and never need it.@twomore/no-unbatched-in-filter(no per-file exemption); a genuinely bounded list (an enum, the viewer's own clubs) takes a justified inline disable. - Env vars are read as literal
process.env.NAME(2026-09-29). Next and Expo's Babel preset inline nothing else:process.envpassed whole, destructured, spread or indexed with a variable is empty in the browser and in a release app. Build an object from literal reads (apps/web/lib/sentry-env.ts).@twomore/no-dynamic-process-env; Node-run tool configs and tests are out of scope. - Coercing a drift-tolerant enum in a mapper (Protocol K) →
coerceEnum(schema, value, fallback)fromadapters/supabase/mappers/_assert. Never inlineschema.safeParse(value).data ?? fallbackfor enum fields. (Required enums useassertEnum+safeMapto drop the row; JSONB object schemas keep the inlineSchema.safeParse(value).data ?? DEFAULT— "enum" would mislead there.) - A read selects every column its mapper reads, and a mapper never defaults a NOT NULL column (2026-11-14). The gate
adapters/supabase/__tests__/select-list-parity.test.tsruns each registered mapper on a recording row and fails for any key its select list leaves out (a new_COLUMNSlist not in__test-utils__/select-list-registry.tsfails it too); a key a list omits on purpose must be optional in the mapper's row type (column-revoked or a lighter projection), and a NOT NULL column is read withrequireColumn(row.x, 'x')(mappers/_assert.ts), never?? default, so a read that dropped it is a typed failure (safeMapdrops and reports the row) instead of a default shown as the person's data (참가비 내역's won sign, a held seat drawn lapsed). An RPC bundle's explicit column list must carry the same keys:supabase/tests/session_bundle_row_keys.test.sqlpins the session bundles.
Telemetry, perf & debugging
client_perf_eventswrites are BATCHED (perf-event.supabase.ts, 2026-09-08): reports buffer in memory and flush as one multi-row insert every 5 s / 25 rows / on app background. A perf report is never its own request — every insert used to pay a session read + serialization and queue behind the 6-slot request gate at exactly the moment the slow screen's own queries were waiting (the diagnostic competing with the thing it diagnosed). Keep any new telemetry sink on the same buffer shape.- Stamping platform / app_version / app_dist on a
client_errors/client_perf_eventsinsert →...getAdapterTelemetryContext()fromadapters/supabase/_telemetry-context. Not forlib/sentry(its OTAupdateId+ release/dist source-map semantics differ). - Catching an unrecoverable error →
captureCriticalError(err, { context })from@/lib/sentry. NeverSentry.captureExceptiondirectly. Errors land inclient_errorsSupabase table + Sentry. Seedocs/best-practices/debugging-workflow.md - Telemetry carries no personal data (EP-4, 2026-11-12). One scrubber,
redactErrorText/redactErrorForReportinlib/redact-pii.ts, runs once inrecordCriticalErrorSharedLayers, so the on-device crash log,client_errorsand Sentry (native and web) all receive the same scrubbed error: tokens, email addresses, phone numbers, bank-account-shaped numbers, URL query strings and the row values a database error quotes (Key (email)=(…)) are replaced by markers; an error'scodeandhintsurvive.redactEventTextdoes the same for events the Sentry SDK captures by itself, andscrubNetworkBreadcrumb(lib/breadcrumb-scrub.ts) drops the query string of every fetch/XHR breadcrumb, keeping the method, the path and the status. The key list for objects (PII_KEYS, also in theno-pii-keys-in-jsonb-writesrule, parity-tested) covers credentials, transfer accounts, notes and message bodies, addresses and avatar URLs.handleErrornever copies the database'sdetailstext into an error message (it can quote a row value): it rides onDomainError.serverDetails, which nothing renders or reports (isAlreadyAcceptedErrorreads it). A mapper that drops a row (safeMap,mapPart) reports once per context per session with the mapper's name and the zod issue paths only (mapper-drop-report.ts); a failed secure-storage write is reported once (logger.report, a warning channel the Sentry module registers). The redaction never throws and never blocks the report. The writers keep inside the server's bounds ("Anonymous writes are bounded", in the SQL section below). - Tracing user actions →
addBreadcrumb(category, message, data)from@twomore/appat the start of every mutationFn. Stable categories:rsvp.toggle,score.submit,payment.mark-paid,payment.mark-waived,attendance.confirm,manner-tags.submit. When the next error fires, Sentry shows the 10 most-recent breadcrumbs as context. New mutation needs a new category — add it to the docstring inlib/sentry.tsso the enum stays the single source of truth. - Debugging a crash OR lag → run
yarn debug:logs(signs in as dev, reads the unifieddev_read_telemetrystream of errors + slow ops) BEFORE asking the user for screenshots.--since=2h,--kind=perf|error|query|paint|navigation,--route=X,--clear. Falls back toselect * from public.recent_client_errors(20)in the Supabase SQL editor. - Session-start prod-health check — the watchdog only writes to telemetry; NOBODY is paged. At the start of any session that touches prod (deploys, migrations, cron/edge work, or just resuming after days away), run
yarn debug:logs --since=24h --kind=errorFIRST and treat anyCRON HTTP FAILUREwatchdog FATAL as a stop-the-line incident before the planned work. An unread alert IS an ongoing outage: on 2026-06-10→11 the push+weather pipeline was down 19 HOURS with the watchdog faithfully writing hourly FATALs that no one read (cause:app_config.service_role_keyheld a revoked API key → every cronnet.http_post401'd "Unregistered API key" at the gateway). The 00202 watchdog closed the DETECTION gap (the 2026-05-17 outage ran 33h undetected); this rule closes the READING gap. - Capturing lag →
perf.reportSlow(operation, durationMs, { kind })(threshold-gated + throttled, fire-and-forget toclient_perf_events).perf.measure()auto-funnels;query-clientauto-times fetches. NEVER put PII in the perfcontext(counts/ids/query-keys only —redactPIIruns defensively). UX lag is captured at the@twomore/uiprimitive level via a parallel DI sink (perf-sink.tssetPerfSink/reportUiPerf, bridged toperf.reportSlowininitPerfReportersince ui can't importlib/perf):PillNavauto-times tab switches (interaction:tab),FeedList/GroupedFeedListcapture scroll jank when given aperfLabelprop (scroll:<label>), and<PerfProfiler id>(ReactProfiler) captures render storms (render:<id>) on high-churn subtrees. Coverage is now app-wide — everyFeedList/GroupedFeedListsite carries aperfLabel, and the two raw (non-virtualized)ScrollViewscreens (profile,home-feed) wireuseScrollPerf('<label>')+scrollEventThrottle={16}+{...handlers}explicitly. A NEW scrollable surface MUST follow suit:FeedList/GroupedFeedList→ add a kebab-caseperfLabel; a rawScrollView→ spreaduseScrollPerf('<label>'). High-traffic / list-heavy screens additionally wrap their scroll subtree in<PerfProfiler id="<screen-key>">so render storms self-attribute viarender:<id>events alongside thescroll:<label>jank events — use the same kebab key for both (e.g.perfLabel="clubs-mine"+<PerfProfiler id="clubs-mine">) so the two event streams correlate under one screen identifier inyarn debug:logs. Wrap the inner content YStack / FeedList — never the ScrollView/DetailShell chrome (the Profiler must enclose the rendered subtree, not the scroll machinery). Pair the wrap with the Wave-G playbook:React.memoon rows +useCallbackonrenderItem/keyExtractor/separators with stable-reference deps +useMemoon object/array props passed to memoized children + module-level constants for inline styles. Without that, the Profiler will faithfully report storms that are caused by missing memoization. - A rule that exists in BOTH TypeScript and Postgres is a bug waiting to happen — decide which kind it is. TypeScript cannot import a SQL constant and Postgres cannot import a module, so anything stated in both languages is two hand-maintained copies by construction. Three shipped bugs in 2026-09 came from exactly this and none was caught by a type, a test, or a review: the cancellation policy scored members by a window the app never showed them (00639), the push category labels drifted from the app's own vocabulary in production, and the client's pre-match rating preview computed numbers the server would not apply (H2). The rule now:
- A policy NUMBER lives in data. A table the database reads and the client receives through an aggregate it already fetches, with a compiled-in fallback.
private.rate_limit_policies(00641) is the template — one source, no gate needed, and ops can tune it without a deploy. - A policy ALGORITHM is computed in ONE place and read from the other. Prefer the server: expose a
STABLEread-only function built on the SAME helpers the authoritative write path uses, and deliver its result inside an existing bundle so it costs no extra request.00642_match_rating_preview.sqlis the template — it extractedprivate.match_rating_compute_deltassoapply_match_ratingsand the preview share one implementation, and its pgTAP previews a score, really applies it, and asserts the two are equal. That collapse deleted 311 lines of duplicated TypeScript. - A parity gate is the fallback, never the plan. Gates scale linearly with the number of rules and each depends on someone remembering to add one. Where a value genuinely must be written twice (a cold-start seed, a display default), a gate is right — but it must fail when a declaration cannot be FOUND, not only when values differ, or a rename passes it vacuously. Live gates:
check:cancel-policy,check:attendance-policy,check:elo-parity,check:cron-budget. - Never keep a constant "in case" behind a gate. When the ELO algorithm left TypeScript, four constants lost every consumer; they were deleted rather than guarded. A gate protecting values nothing reads is a parking lot with a lock on it.
- A policy NUMBER lives in data. A table the database reads and the client receives through an aggregate it already fetches, with a compiled-in fallback.
- Wall-clock duration measurements that can span an app background/suspend MUST cap implausible values. Any
Date.now() - start(orperf.mark→measure) duration whose start and end can straddle the OS suspending the app — asetIntervaldrift detector, an in-flight fetch timer, anything timing across anawaitthat may be backgrounded — measures the ENTIRE suspend (minutes) as one event on resume, emitting bogus multi-minute "blocks"/"slow queries" that swamp real signal AND (for the monitor) fire an error-level Sentry breadcrumb on every resume. Cap/skip durations beyond the operation's realistic upper bound. Canonical guards (both 2026-06-03 — the SAME bug found in two paths):packages/app/src/lib/js-thread-monitor.tsMAX_PLAUSIBLE_BLOCK_MS(5s — resetlastTickAt+returnwhenactualDeltaexceeds it) andpackages/app/src/presentation/providers/query-client.tsMAX_PLAUSIBLE_QUERY_MS(30s — queries are AbortSignal-timeout-bounded, so anything larger is a suspend artifact → skip thereportSlow). Any NEW wall-clock perf timer must add the same guard; not mechanically lintable — reviewer discipline. A plausibility CAP alone is not enough for a repeating monitor (2026-09-09): a SHORT suspend — notification shade, a glance at another app, screen off/on inside the cap — lands under the bound and is reported as a real multi-second block, which then outranks every genuine block inyarn debug:logs(the unattributed 4187 msvenues/[id]block of 2026-09-08 was exactly this: no GC delta, no interaction near it, sitting between two ordinary home-feed scrolls).js-thread-monitor.tstherefore ALSO gates onAppState: a latch set on any transition away fromactivediscards the next measured window even when the app is back toactivebefore the tick runs (a background round trip completing BETWEEN two ticks is invisible toAppState.currentState). Prefer discarding a real block over reporting a phantom one — phantom events cost more than they reveal. - Marking "screen fully displayed" → render
<Sentry.TimeToFullDisplay record />(importSentryfrom@twomore/app) inside the data-ready content branch of a primary screen — AFTER its Protocol-G readiness gate clears (theQueryBoundarycontent child, theelseof a not-found guard, or below anif (isLoading) return …). It measures the true full-display instant for Sentry Mobile Vitals (TTID is auto via the nav integration; TTFD requires this manual marker). It renders an invisible draw marker and is a no-op when no navigation transaction is active, so a tab-switch re-render won't emit a spurious span. NEVER place it in a skeleton/loading branch (would record skeleton paint as "full display") or unconditionally inside a multi-panerenderTabthat mounts >1 pane (gate to the default landing pane, e.g. home'sslice === 'today'). Wired onhome/profile/records/club-detail/spectator-scorecard; add to a new high-traffic data screen the same way.
Mock data & key rotation
- Mock data comes from the scenario builder, NOT static seeds (2026-06-13).
supabase/seed.sqlis DELETED.config.tomlseeds ONLYseed_public_courts.sql(real reference data — court names, addresses, coordinates; never touched by tests or scenarios). ALL user/club/session/meet/DM/payment/review/availability/challenge/feedback/stats mock data is generated RANDOMLY and IDEMPOTENTLY by theseed-scenarioedge function. Runyarn seed(base chain) oryarn seed:dense(dense chain) afternpx supabase db reset. Adding a new table: (1) add a randomized generator insidesupabase/functions/_shared/scenario-builder.ts; (2) add the table to the seed-scenariotruncatelist so re-seeding is idempotent; (3) wire the new data into the relevant chain. NEVER add a static row toseed.sql— the file is gone; the scenario builder is the single source of truth. - Seed media is real photos and logos from one catalog (2026-09-25). Every seeded gallery photo, post photo, banner and logo comes from
supabase/functions/seed-scenario/lib/media-catalog.ts: photos taken at real tennis courts and clubs (Unsplash and Wikimedia Commons licences, credited per photo) and original club emblems.node scripts/seed-media.mjs --local|--linkedbuilds them to the app's own upload spec (IMAGE_RESIZE_PRESETS,IMAGE_RESIZE_DEFAULT_QUALITY) into the privateseed-fixturesbucket, andlib/seed-media.tscopies them server-side into each club's own paths, the same buckets and path shapes the app's upload flows write. A fixture names its photo by catalog key next to the caption that describes it; a club's logo is the emblem of the place it is named after (derived from the generators' ownREGIONS). A fixture's content never changes under its key (a redesigned emblem takes the nextSEED_LOGO_SERIES): the refresh recognises seeded media as a byte copy of a fixture by ETag. Seeded public URLs carry?v=<fixture>and are written on the caller's public URL (run-scenario.mjssends it; the local functions runtime only knows its internal gateway). Never draw placeholder images or point a logo at a random face photo (i.pravatar.cc): both hid how real content looks on the surfaces under test.seed_media_refreshre-dresses an existing world without truncating. Tested byscripts/__tests__/seed-media.test.mjs. Seeds count rows with a GET (count: 'exact'+.limit(1)), neverhead: true: on the local stack's gateway a write sent right after a HEAD reuses a pooled connection the API has just closed and fails with 502 "An invalid response was received from the upstream server" (threefull_qafailures, 2026-09-25); idempotent media writes also retry that transient error (writeWithRetry). - Seeding perf: batch inserts, reconcile once per chain (2026-07-04). Multi-row generators (
matches,match_sets) must accumulate rows and issue ONE.insert(rows).select('id'), mapping returned ids back to their child rows — a per-row insert in a loop is the classic silent-slowdown trap (measured:live_showcase25.6s → 13.9s after batchingmatches).dev_reconcile_seeded_world(full-DB rating replay) is expensive and idempotent-safe to defer — a multi-step chain should passskipReconcile: trueon every step except the last so it runs once over the full dataset, not once per step (mirrors the existingskipTruncateflag). Deliberately serial paths (e.g.dense_compute's scoring, which avoids a real ELO race) are the documented exception. When a generator looks "dead" (zero rows silently seeded), verify its query columns and any CHECK-constrained vocab against the actual migration — three real bugs shipped this way:generateSessionPaymentsqueried a column that only exists on a different table (cost_per_personvs the realparticipation_fee) and wrote a Korean string into a CHECK-constrained enum column (payment_method);guest_applicationsinserts omitted a NOT NULL FK (club_id) with no backfill. None of the three raised an error — they either silently seeded zero rows or failed every insert. Grep the actual migration for the column/constraint before trusting a generator's output. - Rotating/regenerating Supabase API keys →
app_config.service_role_keymust be updated in the SAME operation. The pg_cron jobs build theirAuthorization: Bearerfrompublic.app_config(keyservice_role_key), independent of the edge functions'TWOMORE_SECRET_KEYsecret — rotating the platform key updates neither automatically, and a revoked key inapp_config401s ALL cron HTTP calls (push, weather, receipts) whilecron.job_run_detailsstill shows "succeeded" (dispatch-level). Verify by digest, never by value:select key, length(value), encode(extensions.digest(value,'sha256'),'hex') from public.app_config;and compare againstnpx supabase secrets listdigests. The sanctioned repair shape when the valid value exists only as an edge secret: a ONE-SHOT token-guarded edge function that copies its own env value intoapp_configserver-side and returns only{updatedRows, valueSha256, valueLen}— the secret never transits chat/git/agent context; delete the function (prod + disk) immediately after the digest verifies. Used 2026-06-11 to end the 19h cron outage. - EXT-9 force-update gate — the minimum runtime is one
app_configedit, no deploy.public.get_app_version_policy(p_platform)(migration 00669, SECURITY DEFINER,anon+authenticatedEXECUTE — the check runs before login) readsapp_min_runtime_<platform>/app_store_url_<platform>(platform=android/ios) straight offpublic.app_configand returns{minRuntime, storeUrl}. The client compares its ownUpdates.runtimeVersion(expo-updates) againstminRuntime(isRuntimeSupported,packages/app/src/domain/rules/app-version-gate.rules.ts) and swaps inUpdateRequiredScreenwhen it falls short (useAppVersionGate→AppVersionGate, mounted around<AuthGate />inapps/mobile/app/_layout.tsx). Fails open everywhere: web, a dev build (runtimeVersionnull), offline, a query error, and a missing/blank config value all resolve to 'ok' — never a hardcoded threshold in code. To raise the minimum:UPDATE public.app_config SET value = '<new-version>' WHERE key = 'app_min_runtime_android'(and/or_ios) directly — no migration, no deploy; clients pick it up on their next policy-query refetch (6h staleTime).
Tier display
- Displaying tier info →
EloTierBadge(packages/app/src/presentation/components/elo-tier-badge.tsx) /TrustTierBadge(packages/app/src/presentation/components/trust-tier-badge.tsx), both built on the sharedTierBadgeChrome(packages/app/src/presentation/components/tier-badge-utils.tsx) — a single flat$primarySubtlepill (no per-tier color/variant any more) plus each badge's own emoji map (ELO_TIER_ICONS, private toelo-tier-badge.tsx;TRUST_TIER_ICONS, exported fromdomain/entities/trust.entity.ts).tier-display.ts/TIER_ICONS/getTierColor/TIER_BADGE_VARIANTno longer exist — don't cite them. - Tier labels in UI →
tierLabel(tier)frompackages/app/src/utils/tier-label.ts. Never inlinet().ranking.tier[x]. - Formatting a club tier range →
formatTierRange(min, max)from@twomore/app—"실버~골드", a single label when both resolve equal, null when either is absent. Never inlinemin && max ? `${tierLabel(min)}~${tierLabel(max)}` : null. The wizard's "all levels" shortcut stays bespoke; the dash-separated SessionHeader variant this line used to except is gone (SessionHeader was deleted — no replacement dash-format exists). - Tier ordering (promotion/demotion comparisons) →
TIER_ORDERfrom@twomore/app(TIER_ORDER.indexOf(tier)), derived fromTIER_DEFINITIONS. Never hand-declare['bronze','silver',...]. - Fetching session weather →
useSessionWeather({ region, date })frompackages/app/src/presentation/hooks/queries/use-session-weather.ts. Never fetch weather directly from a component.SessionCardV2does NOT call this internally (zero-data-hooks projection, per Conventions › Session Surfaces) — it receives a pre-resolvedweatherLabelprop from the list's bulk model builder (useSessionCardModels);session-detail-screen.tsxis the current direct caller.
Ratings: replay, score entrant, level mark
- A rating replay never announces.
private.recompute_all_ratings(every correction, review resolution, exclusion and reopening) applies the completed matches in order withp_announce = false(00741, onapply_match_ratingsandmatch_rating_write_player_result): theelo_changedand tier notices went out when the match was scored, and re-emitting them would tell a player twice. The live score entry keeps the defaulttrue. The replay also leaves the level mark alone.p_announce = falseis the only guard: a repeated dedup key resurfaces a resolved notice as unread, so a replay that announced would re-open every old one.00744_windowed_rating_replay.test.sqlasserts the count oftrust_tier_changednotices is unchanged across a windowed replay. - A replay starts each player from their recorded start (00743).
player_ratings.seed_source(self_assessed|derived) names where a seed came from. A self-assessed row is reset to exactly whatseed_self_assessed_ratingwrote (rating = peak = seed, rd 350, 0 matches), never deleted; everything a match or the engine created is wiped and rebuilt, including the mixed-doubles blend seed (derived, re-derived at the player's first mixed match). Display state (confidence, state, source, tier, tier sub, tier shield) is derived and reset by the replay. The two writers ofseed_ratingmust setseed_source; a CHECK refuses a source with no seed; a seed with no source (a foreign writer) is kept as a given start; a new writer of a starting rating that is user input usesself_assessed. After the replay every completed scored match is applied silently, so the result equals the live path and a second replay changes nothing. Known limits, recorded not fixed: a self-assessed mixed seed that had already played before 00743 cannot be told from a derived one and is re-derived (the backfill's stated ambiguity); a player who self-assesses one discipline after already playing another gets a different mixed blend on replay than live (the seed has no timestamp). One replay under its lock is timed in release evidence (S9); the windowed replay (F3c) is designed, not built. - A correction replays a window, not the world (00744, F3c).
private.recompute_ratings_from(p_match_id, p_previous_at)re-applies only the matches at or after the corrected match's replay position (COALESCE(completed_at, created_at), created_at, id; a moved, excluded or reopened match widens the window to the earlier of its old and new position), under the same exclusive rating lock, silent, leavinglevel_celebrationsand bonus event times alone. Its result equalsrecompute_all_ratings()(the oracle, unchanged, and the repair path;dev_reconcile_seeded_worldkeeps it) on ratings, history, profiles, club counts, the three ledgers and the match flags (ledgerlast_played_atandupdated_atstamps excepted); a pgTAP parity file holds this. Callers:replace_match_score,apply_match_exclusion,apply_match_reopening,agree_to_correction(closed to signed-in users since 00769; service role only) (andapply_review_resolution,correct_own_scorethrough them). The before-state of each history row lives inprivate.elo_history_before_state(RLS on, no grants;elo_historyis client-readable), written bymatch_rating_write_player_result: a new writer ofelo_historymatch rows must go through it. The window falls back to the full replay, checked before anything changes, for seven named reasons returned withmode(windoworfull):history_without_before_state,history_of_uncounted_match,late_confidence_bonus,history_chain_broken,matches_played_gap,mixed_seed_origin_unknown,ledger_underflow. Before-state fills lazily (00744 runs no replay): history older than 00744 has none, so the first correction after it pays one full replay, which writes it; later corrections use the window. The history has a total order (00769): the one order-dependent read, the streak query inmatch_rating_player_delta, sorts by the replay key (historycreated_at, the match'screated_at, the matchid), so equal completion times never decide a rating and never force a full replay. Known limit: a divergence before the window that reaches a window player only through other players' state, with every window player's chain unbroken, is not detected; the next full replay normalises it.get_home_clubties go to the lowest club id. - A report names its subject, and an official never decides one about themselves (00745, F4).
content_reportsandmember_reportscarrysubject_kind(post | comment | session | message) andsubject_id, both or neither; the ids point at content, no text is copied (P4: a deleted subject has no excerpt).report_contenttakes them (seven arguments, no defaults); a chat report's subject is its message. An official reads subjects throughget_report_subjects(report_ids)(at most 200 ids, hintreport_ids_limit), a staff read that is persist-denied and not drawn offline. The club queue policyreports_admin_readexcludes a report whosereported_idis the viewer, and the decision refuses it (report_self); such a report waits for the platform queue. matches.score_entered_byis the person who settled the match (00741). The first settling write records it; NULL for every older row, seed and service write, so nobody self-corrects those. It decides the 10-minute door 점수 고치기 and nothing else; it reaches a client as a plainmatchescolumn (read with the row, and in the session detail bundle's match rows). A host who entered a score for others is the entrant.- The level mark (00742). A level-up is the NTRP numeral the app prints going up (
ntrpLevelNumeral(getSubTier(rating)), doubles basis; amixed_doublesmatch moves the mixed rating only and never the doubles Level).public.level_celebrations(user_id, celebrated_level, updated_at)holds one high-water mark per person: own-row SELECT, no client write;acknowledge_level_up(p_level)is the only writer and is monotone (it keeps the higher of the two, refuses a level outside 2.0 to 5.5 in halves). Pending is derived, never stored (pendingLevelUpindomain/rules/level-up.rules.ts: the newest doubleselo_historyrow whose numeral rose above the mark and is still held by the newest match's rating), so a correction that removes a climb removes the celebration. The mark is the baseline: a backfill gave every profile a row at its current numeral, a new profile gets one at the numeral of the 1000 default, andseed_self_assessed_ratingsets it to the seed's numeral when it inserts the doubles row (a 2.5 seed reaching 3.0 is pending; a 4.0 seed marks 4.0), so no baseline row means nothing celebrates. The seed is clamped to 600 to 1600, so a seeded start is at most 3.5: a strong new player's first celebration is 4.0, reached by play. - One SQL mirror of the numeral.
private.ntrp_numeral_from_rating(int)(IMMUTABLE, no client grant) mirrors the client's table for the backfill, the new-profile trigger and the seed;domain/rules/__tests__/ntrp-numeral-parity.test.tsreads the latest migration defining it and fails if a boundary differs. Any other SQL that needs a numeral calls it; never a second CASE.
Queueable write ordering
Original calls and durable replay use the same account/entity lane in presentation/cache/mutation-entity-sequence.ts. A mutation reserves its turn before asynchronous optimistic work, waits for durable same-entity predecessors and their persistence flush, and releases its physical lane only when the repository promise settles. Queue/account generations revoke authority but do not prove that an earlier server transaction rolled back. Add a natural entity mapping when introducing a queue key whose writes can conflict.
createQueueableCall(reader, replay, requestId, { entity, lifetime }) takes one options object: entity reserves the ordering lane turn, lifetime is an owner abort signal. A lifetime abort stops the call like an account change and releases the reserved turn (waitForTurn rejects on the combined signal; dispose() finishes the turn).
Same-account offline interruption runs rollback, error and reconciliation callbacks, but the interruption is not an error report: the global MutationCache.onError leaves an offline interruption breadcrumb and never calls captureCriticalError (query-client.ts). Stale-account/generation callbacks remain suppressed. Preserve the original request ID and never blindly replay an uncertain online commit. Client sequencing fixes demonstrated ordering failures, but global guarantees across an aborted server transaction require server-side intent versions or a ledger. Overlapping optimistic exact/prefix patches snapshot each canonical query only once, so rollback restores the state before the mutation.
Caching & cross-query writes
Persistent Supabase Realtime channels are retired. Live surfaces use focus-gated polling, DM uses native
refetchInterval, signals use push/cache-bust, and ordinary surfaces rely on staleTime + mutation invalidation. If a future realtime hook is introduced, it must live underpresentation/hooks/realtime/**, useuseFocusedEffect, and patch/invalidate only specific keys.Cross-query entity writes → use
writeX/writeXBackfillfrom@/presentation/cache. ManualsetQueryDatascattered across hooks is forbidden.One logical fact lives in ONE consolidation helper, not N hand-synced pipelines. When an entity's rows are cached under multiple keys (e.g. RSVP lives in
bySession+byUser+allByUser+bySessionIds), a mutation/realtime handler MUST NOT hand-enumerate that key set — it WILL forget one and desync (the 2026-05-27 bug: cancel updated the detail screen'sbyUserbut left the summary cards'allByUser/bySessionIdsstale). Instead route through the entity's single consolidation surface inpackages/app/src/presentation/cache/index.ts: an optimistic/realtimewriteX(client, entity)that fans the row across every cache shape (upsert by id-or-natural-key), and anxInvalidationKeys({…}): QueryKey[]builder that returns EVERY shape to reconcile (spread intocreateMutationHook'sinvalidateKeysor a rawonSettledloop). Today:writeRsvp+rsvpInvalidationKeys,sessionPaymentInvalidationKeys,attendanceInvalidationKeys, plus the existingwriteSession/writeProfile/writeClub. Adding a key to an entity → update its helper, and every call site is correct for free. Audit query: an entity with 3+ overlapping keys whose mutations enumerate keys inline is the desync smell.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 (PF-04, perf-data-integrity-audit-2026-09-17.md: migration 00650's
trg_sync_club_regionsrecomputes a club'sadm1_slug/adm2_slug/club_play_regionsas a side effect of everycourt_venueswrite, souseCreateVenue/useUpdateVenue/useDeleteVenuealso invalidateclubDetailBundleKeys.detail(clubId)+clubKeys.detail(clubId), not just their ownvenueKeys). This is the cross-entity sibling of the "one logical fact, one consolidation helper" rule above — that rule catches a hand-enumerated key SET for one entity; this one catches a hand-enumerated key set that spans two.Future realtime row events →
applyRealtimeListChange/applyRealtimePaginatedChangefrom@/presentation/cache.invalidateQueriesfallback only on mapping failure.Future realtime invalidation must target the SPECIFIC keys an event affects, not the
*.allroot prefix.A query-key factory omits an absent optional argument; it never fills it (
?? 'all'). A key made with fewer arguments is then the PREFIX of the key made with more, soinvalidateQuerieson the short form refreshes every variant; a filler makes the short form a sibling that matches nothing (the friends list stayed empty after an accept). A filler is legal only where the factory builds one query's own key and is never used to invalidate;query-key-filler-tails.test.tslists every filler and fails on a new one.Per-event hot-path work must be O(1), never O(total state). Any subscriber to a high-frequency source —
queryCache.subscribe, realtime callbacks, scroll/onScroll, store subscriptions — runs on the JS thread, often inside a React commit. Work there must be constant-cost: keep aggregate state in memory and mutate it incrementally; NEVER re-read + re-serialize the whole collection per event (JSON.parse/JSON.stringifyof a growing structure), and flush persistence on a debounce (ideally off the frame-critical path viarequestIdleCallback). Canonical bug (2026-05-22): the lazy-persister re-readManifest()+writeManifest()(parse+stringify the WHOLE manifest) on every querysuccess; with gcTime=30min the manifest grew all session, so JS-thread blocks grew 79→551ms as the user navigated. Fixed by holding the manifest key-set in memory + a debounced flush that only fires on a genuinely-new key. When you add a cache/realtime subscriber, ask: "does this do work proportional to total cached state per event?" — if yes, it's a progressive-slowdown bug.A hot-path zustand action must bail BEFORE
set()when the write is a no-op.set({...})with unchanged VALUES still creates a new state object and notifies every subscriber — withuseSyncExternalStorethat is a forced re-render (forceStoreRerenderin the stack is this signature). Any render-adjacent caller (a draft-sync wrapper, anonLayoutreporter, an effect) that writes unchanged values then echoes: write → notify → re-render → write → maximum-update-depth (on-device 2026-08-24, wizardupdateFormData). Guard shape: compare incoming keys withObject.isagainst current state andreturn(orreturn statefrom the functional form — zustand bails on identical references) before setting. Applies toupdateFormDatain both wizard stores andsetBottomChromeHeight; apply it to every new store action a render path can reach.
PII, consent & data collection
- New data-collection feature → in the SAME migration, add a
public.purpose_catalogrow for(purpose_key, jurisdiction)withlegal_basis + retention_days + min_age + is_sensitive. Update the privacy policy entrustment table inpackages/app/src/config/legal/documents/privacy.ko.tsif a new vendor is added — any text change there bumps the document'sversionand needs a newpolicy_versionsmigration row carrying the hashyarn check:policy-hashes --printprints (is_material=truewhen material per PIPA §30 → forced re-consent); the gate fails the build until the row exists. Then updategetRequiredConsentsForJurisdiction(j)inconsent-matrix.tsto include the new purpose in the relevant jurisdictions. Thelint:pi-schemascript enforces the migration-side discipline; the policy-text update is reviewer-checked. Never collect personal data without a corresponding active grant inuser_consent. - Reading personal data → before SELECTing a PI column (
birth_year,phone_e164,region,gender, location coordinates), verify the user has an active grant for the relevant purpose. HelpergetActiveConsent(userId, purposeKey)will live in@twomore/appwhen multiple call sites need it; until then, document the consent gate inline in the adapter. - Consent revocation surface → every consent MUST be revocable from
SettingsConsentManagementScreenWITHOUT losing service access. Required consents (terms,privacy,cross_border_transfer) are revocable only via account deletion — that is intentional per PIPA §37; the withdrawal mechanism for required consents is the delete-account flow. - Cross-border vendors → if you add a new US-hosted service (Sentry replacement, payment processor, analytics SaaS), update three places: (1) entrustment table in
documents/privacy.ko.ts, (2) its cross-border transfer section with the vendor's PI manager contact, (3) the newpolicy_versionsrow the hash gate demands (material). The Jan-2025 KakaoPay/Apple KRW 8.3B fine establishes §28-8 disclosure as the top PIPC enforcement target. - No PII in JSONB columns →
signals.payload/signals.context/notification_events.data/client_errors.context/user_consent.evidence/matches.match_rulesare the project's 6 JSONB writeable surfaces. Each is genuinely heterogeneous so JSONB is the right shape (signals have 13 categories with different fields; client_errors carry arbitrary call-site context; ISO/IEC TS 27560:2023 explicitly specifies user_consent.evidence as free-form). But: never write PII values into these columns. PII =name/displayName/display_name/phone*/email/kakao*Id/lat/lng/latitude/longitude/birth_year/birthYear/commenterName/commenter_name. Write the user'suserIdinstead, and resolve the display name at read time viaprofiles.findByIds(...). Migration00192codifies this with: (a) trigger functions rewritten to writeactorUserIdonly, (b) backfill cleanupUPDATEstripping PII keys from existing rows, (c)CHECK constraint signals_payload_no_pii_keysblocking the named PII keys at DB level. The PII key set matchespackages/app/src/lib/redact-pii.ts'sSENSITIVE_KEYSso client + DB stay in sync. New JSONB writers (RPCs / triggers / adapters) must follow the same pattern; if a denormalized name is needed for UX, do read-side enrichment.
Faces (profile photos)
Owner ruling 2026-10-11: non-members see no faces. Inventory and decisions: faces-members-only-inventory-2026-10-11.md.
- The rule is one predicate:
public.can_view_face(p_viewer, p_target)(migration00691). A face reaches V only if V is T, V and T share an active club (club_members.is_active), V and T are accepted friends, or T opted in topublicand V is signed in. An anonymous viewer never gets one.profile_visibility->>'avatar'picks the audience; an ABSENT key meansclub_members= a shared club or a friend,friends= friends only,private= nobody else,public= any signed-in viewer (an explicit opt-in, never a default). The four conditions are necessary; an explicit narrowing is honored. A block in either direction (private.is_blocked) closes every level,publicincluded (00768, PRIV-4): the first clause after the self check, so a public profile does not leak past it; the block is silent (the value isNULL, as for a stranger, and neither person is told). - Every SQL projection of
avatar_urlcalls it and nothing re-derives the rule. Today:private.mask_profile_row(behindget_public_profile_view(s),search_public_profiles,get_rating_leaderboard,get_profile_viewand the club, session and home bundles),get_club_discovery_summaries,get_public_share_previewandprivate.venue_owner_restricted.get_own_profile()is the only self read. A new function that returns a face writesCASE WHEN public.can_view_face(<viewer>, <target>) THEN avatar_url END. Enforced bysupabase/tests/00691_faces_members_only.test.sql, a catalog scan (with canaries) that fails when a function mentionsavatar_urlwithout the predicate, or emits a wholeprofilesrow as JSON. - The predicate is not client-executable. A caller-chosen viewer would be a friendship/clubmate oracle (the reason
can_view_profile_fieldis revoked too); callers are SECDEF and passauth.uid().service_rolekeeps EXECUTE. - The column is closed:
SELECT (avatar_url)onprofilesis revoked fromanonandauthenticated(same shape as 00539 / 00680: table-level revoke, then an explicit column allow-list). A client never selects it or embeds it;packages/app/src/adapters/supabase/__tests__/profile-face-reads.test.tsfails the build on.from('profiles').select('…avatar_url…' | '*')or aprofiles(…avatar_url…)embed. Writes (update,insert … do nothing) need no SELECT. - Storage: the
avatarsSELECT policy lists only the caller's own objects (<uid>-…, and the legacyavatars/<uid>-…). Rendering uses public URLs, which never consult RLS: a URL that was shown works until the object is deleted, so a new surface must never expose a URL outside this predicate and the client deletes the replaced object. - The client treats NULL as "show the initials".
Profile.avatarUrl: nullmeans none uploaded OR outside the audience; the card seam shows real photos only, so a non-member's discovery card has no stack. The settings preview (settings-privacy-screen.tsx) restates the audience rule for display only; the server decides. - The default in the app: 설정 → 프라이버시 shows the photo row at "클럽 멤버와 친구" until the user picks otherwise.
Profile activity fields (migration 00767) and the server-health reader
- The audience rule is one SQL function:
private.profile_audience_allows(viewer, target, key).public.can_view_faceis that rule withkey = 'avatar'; the profile's primary club, attendance rate, session count and last session usekey = 'activity'. A new gated field reuses it with its own key; never a second spelling of the rule. Absent key =club_members(self, a shared active club or an accepted friend);friends= friends only;private= self only;public= any signed-in viewer; an anonymous viewer never. A block in either direction answers false at every level (00768). - Enforcement:
private.mask_profile_row(behind every profile projection) returns those four fields asnulloutside the audience,get_public_share_previewreturns a session count of 0, andSELECT (primary_club_id, global_attendance_rate, total_sessions_confirmed, last_session_at)onprofilesis revoked fromauthenticated(select_hiddenininvariant_profile_column_grants). The owner's own copy comes throughget_own_profile.patch_profile_visibilityaccepts the keyactivity; the privacy settings page has its row (주 클럽과 출석 현황), default 같은 클럽 (reaches friends too). - The client rule: a hidden value draws nothing: no number, no label, no "아직 클럽이 없어요" for another person (the page cannot tell "no club" from "hidden from you"), no chip. Only the viewer's own page says "no club".
- The block clause lives in both spellings of the audience (00768).
private.profile_audience_allows(face, activity) andpublic.can_view_profile_field(region, district, play regions, gender, birth year) each answer false for a blocked pair right after the self check;private.mask_profile_rowprobes the pair once per row and skips the gated reads, so a list pays one probe per person. Still visible to a blocked pair, by decision (the fields no audience gates): display name, ratings and tier, nationality, 구력, kata division, elite background; and the surfaces that never ask an audience rule: club posts and comments, the club roster, session participant lists, group chat threads and existing direct-thread history. A new gated field reuses a rule; a new surface that shows a person decides about the block on purpose. - The leaderboard keeps a blocked pair; search does not (00772).
private.rating_poolhas no block clause, so a blocked pair read the same rows and ranks as a stranger (a block is silent; the face and gated fields stay masked by the rule above).search_public_profilesstill hides a blocked pair from each other (00713): it is the door to a friend request and a chat, and closing that door is what a block is for. - Set scores and reactions follow their match (00772).
match_setsandreactionsare readable only through a match the caller can see (EXISTS (SELECT 1 FROM matches ...)under the caller's own RLS), never to every signed-in person; a new table that hangs off a match reads the same way. - A signed-out caller reads nothing from
sessions(00772).anonholds no column grant on the table. A signed-out page reads through the SECURITY DEFINER previews (get_public_share_preview,get_club_invite_preview), and the signed-out request wrapper (adapters/supabase/request-access.ts) refuses every other read; a column granted toanonlater needs a signed-out page that reads it, named in the migration. - Club media lists to the club's officials (00772). The
club-mediabucket is public (a public URL still works); listing and signed URLs need an owner or admin of the folder's club, the test the write policies use. An unknown invite code is a refusal (P0001, hintjoin_invalid_invite_code), not an HTTP 500 (P0002); the otherP0002sites are a pre-launch item (docs/release-evidence.md). - Required consents come from the catalog (batch 63). 동의 관리 puts a purpose under 필수 when the consent matrix (
getRequiredConsentsForJurisdiction, the source sign-up reads) marks itrequired, never bylegal_basis; a required row shows no 철회 and says it ends only with 회원 탈퇴. A required consent is withdrawn only by leaving the service:revoke_consentraisesP0001, hintconsent_required, for a purpose the catalog marks required (00774), and the page words that refusal as the 필수 line on its row;delete_account_atomicwrites the ledger itself and never calls it. - Trust is unaffected: the trust record reads none of these
profilescolumns. public.dev_read_server_health(p_swallowed_limit)(developer account AND theallow_dev_rpcsswitch; refused for anyone else; SECDEF,search_path = '', PUBLIC and anon revoked): the swallowed-error sink (source, SQLSTATE, schema-object names only, no user id or value) and the watchdog's own verdict per cron job. Read byyarn qa:health(local by default;--hostedalone prints what it would call;--hosted --runmakes the read-only call) and by the dev panel's Server tab. It never takes a service key.
Invite codes (members-only)
Migration 00711 (2026-11-08). A club's invite code is the join gate of an invite_only club: join_club_by_invite_code and its target-bound sibling admit a code holder without approval. Before 00711 any signed-in user could read the code of every discoverable club, raw or through the club page bundle.
- One read, for members:
public.get_club_invite_code(club)answers an active member of that club (public.is_club_member); anyone else, and a club that does not exist, gets42501with HINTclub_invite_code_members_only. The client reads it throughClubRepositoryPort.getInviteCode(useClubInviteCode,clubInviteCodeQuery); its cache rootclubInviteCodestays in memory (query-persist-policy).useShareClubInvitereads the code when the member shares and says so when it cannot. - No club row carries it:
SELECT (invite_code)onclubsis revoked fromanonandauthenticated(the faces shape),CLUB_COLUMNSand the club, home and session bundles name their columns without it, andClubhas noinviteCode. Aselect('*')onclubsfails for every signed-in user. Enforced bysupabase/tests/club_invite_code_members_only.test.sqlandpackages/app/src/adapters/supabase/__tests__/club-invite-code-reads.test.ts. - A new
clubscolumn needs its own grant (§ SQL footguns below). The joins andlookup_club_by_invite_codeare SECDEF and keep matching on the column; the lookup returns the row only to someone who typed the code. - A signed-out visitor sees which club a link is (00759, batch 52).
public.get_club_invite_preview(p_code)is granted toanonandauthenticatedon purpose (named, with this reason, in the anon allow-list of00026_secdef_acl_hygiene.test.sql). It returns exactly six keys, the ones a discoverable club already shows publicly:clubName,region,district,levelBand,joinMethod,activeMemberCount. Never an id, logo, description, member, the code itself or dues; an invite-only club shows no more than a discoverable one. SQLNULLfor an unknown code, an archived club, a non-club row and a code outside 4 to 10 trimmed characters (the last is answered before the cap is charged). It is charged against a global cap of 2,000 calls per Korea day (private.public_ingress_policiesrowclub-invite-preview, both ingress CHECK lists widened) and fails closed: a spent cap raisesP0001with hintinvite_preview_cappedand reveals nothing, so the page falls back to exactly the page without a preview. At 32-bit codes a prober at the cap finds any club with probability 2,000 x clubs / 2^32 per day (0.047% at 1,000 clubs). One abuser can spend the cap and turn the preview off for the day (accepted); per-IP fairness is a later step. The app asks it signed out only, never while auth loads, once per settled code of 4 to 10 characters (400 ms debounce, never on a partial keystroke), and a failed read, the cap, offline and an unknown code all draw no preview and no error line. - Guessing is throttled for signed-in accounts (00759).
lookup_club_by_invite_codeallows 15 calls per user per hour (hintrate_limited_invite_lookup);join_club_by_invite_codeandjoin_club_by_invite_code_for_clubshare 5 completed joins per user per hour (hintrate_limited_invite_join); both areprivate.rate_limit_policiesrows, separate counters. An honest mistyper needs 2 to 4 lookups and one join, far below either; probing is capped at 360 lookups a day per account. Signatures and return shapes are unchanged (installed builds). A raising call rolls back its own charge, so the join counter bounds completed joins only; that is why a join by code succeeds only for a club the caller found through the lookup in the last 30 days: the lookup writes a receipt (private.invite_lookup_receipts, one row per user and club, cascading fromauth.usersandclubs, no client grant,PI-EXEMPT; the caller's receipts older than 30 days are deleted in the same call). Without a receipt, both joins raise the one wrong-code refusal (private.raise_invite_code_not_found:P0002, 'Club not found', no detail, hintjoin_invalid_invite_code), byte for byte what an unknown code or a non-club row raises, before any approval, capacity or archive refusal, so a guess through the join gives no signal. The app already looks up first (the join page, and the visitor home's join runs the same cached lookup). A seed or script that joins by code must look the code up first or plant a receipt in a fixture. Receipts of a withdrawn person stay until a hard delete (the 30-day anonymization does not remove them; recorded for the engineering pass). - A code never reaches a log. Both code-keyed queries (preview, lookup) sit under the memory-only cache root
clubInviteCodeand carrySECRET_QUERY_KEY_META: the query cache's error hook logs and reports only the key's root, never the key. Any new query whose key holds a secret (a code, a token) carries the same meta.
Public share preview (anonymous read)
public.get_public_share_preview(kind, id) (anon and authenticated, one function, one set of arguments) never returns stored free text or a position (00758, batch 52): a session or match preview carries title / sessionTitle as the derived title only (private.derive_session_title; installed builds reject a preview without one, so the keys stay), venueName is the linked court venue's name or null, and the stored title, the typed place (location) and the coordinates are not in the payload. A session with a typed place and no linked venue shows its derived title. Held by supabase/tests/share_preview_derived_title.test.sql (a canary session titled SECRET-TITLE at SECRET-PLACE yields neither string anywhere in the payload, for the session and its match, as anon and as a signed-in caller). The app's parsers (public-share-preview.ts) read both the new shape and an old-shaped payload and keep none of the dropped keys.
SQL: SECDEF & migration footguns
- RETURNS TABLE in plpgsql SECDEF functions → every column reference inside CTE bodies (especially GROUP BY + SELECT lists) MUST be qualified with table aliases when the return column name matches an underlying column name. PostgreSQL raises
42702 "column reference is ambiguous"at function-call time, NOT atCREATE OR REPLACE FUNCTIONtime — so the error only surfaces in production. Codified in migration00183—signals_pick_pushableis the canonical fix shape (all internal CTE refs qualified ass.recipient_user_idetc.). The ESLint plugin cannot catch this; it requires reviewer discipline on SQL migrations. - SECDEF function discipline → every SECURITY DEFINER function MUST (a) set
search_path = ''to prevent search-path injection attacks, (b) REVOKE EXECUTE FROM PUBLIC and explicitly GRANT only to the principals that need it (service_role,authenticated, etc.), (c) gate onauth.uid() IS NOT NULLat the top of the body if the function reads any user-keyed data. Default PUBLIC EXECUTE on a SECDEF function that reads sensitive config (like00101'sget_app_configwhich returned the service-role key) is a catastrophic data-exfil vector — authenticated users can call it via PostgRESTrpc()regardless of RLS. Fixed in00184. Thelookup_club_by_invite_codepattern (migration00146) is the canonical REVOKE + GRANT shape. As of00209,publicno longer auto-grants EXECUTE to PUBLIC for newly-created functions (ALTER DEFAULT PRIVILEGES … REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC), so a new function migration that omits an explicitGRANT EXECUTE … TO authenticated[, service_role]is UN-callable by the client — and silently so (the failure surfaces at runtime, not apply time). Always add the explicit GRANT in the same migration. Cron/edge-only functions GRANTservice_roleonly (cron runs as thepostgressuperuser, bypassing grants; edge functions useservice_role) — neverauthenticated, neveranon. Anonymous EXECUTE is denied except for the intentional public projections and RLS predicates explicitly covered by00026_secdef_acl_hygiene.test.sql. The ownership addition permits only namedanononget_venue_owner_certifications(uuid[]): one to one hundred requested venue IDs return booleans and a check time, without claimant identity or proof. This exact-signature exception does not permit PUBLIC execution, other overloads, raw ownership tables or private helpers. Run the Supabase Security Advisor periodically — the four categories it flags here (rls_policy_always_true,function_search_path_mutable,anon_security_definer_function_executable,public_bucket_allows_listing) were all closed in00207–00210(2026-05-27). - A SECDEF function that checks nothing about its caller is not callable by
authenticated(00760, batch 52). Three were:recent_client_errors(every user's client error rows),increment_naver_api_budget(spends the shared map and search budget) andsignals_resolve_by_dedup_key(resolves anyone's notice). EXECUTE is revoked from PUBLIC,anonandauthenticated;service_roleand the owner keep it. Every SQL caller runs as the owner (the resolver is called by SECURITY DEFINER functions owned bypostgres:signals_on_dues_update,signals_on_dues_delete,sync_session_payment_on_rsvp,acknowledge_session_refund; the budget by an edge function on the service key,naver-cost-guard.ts; the error read by nothing in the app, scripts read the table).supabase/tests/00760_close_three_open_definer_functions.test.sqlasserts a signed-in user is refused each and the internal paths still work.get_club_discovery_summariesalso pinssearch_path = ''(ALTER only, output unchanged). - Trigger functions and helpers are not callable by clients (00761, wave 1). EXECUTE is revoked from PUBLIC,
anonandauthenticatedon the 27 public trigger functions (a trigger function is checked for EXECUTE when the trigger is created, never when it fires, so a member's own write still fires it) and onget_session_club_id,dev_inspect_cron_status,affected_region_keys,sido_key_from_labelandsignals_is_in_quiet_window, whose only callers are owner-run definer functions. Policy helpers (is_club_*,dm_thread_messageable,get_match_club_id,can_react_to_match,has_pending_join_request) keep their grant: RLS evaluates them as the caller. Invariant tests:invariant_secdef_authenticated_caller(I-1: every public definer function open toauthenticatednames its caller, callsprivate.*, or is on an allow-list with a reason per entry; four entries are open items for wave 3),invariant_secdef_hygiene_canary(I-8: a known-positive for the PUBLIC-EXECUTE andsearch_pathgates) andinvariant_anon_write_surface(I-9:anonmay INSERT into exactly three telemetry tables, each with its ingress guard). - Anonymous writes are bounded (00761).
client_errorsandclub_growth_eventstake the ingress guard ofclient_perf_events(private.guard_*_ingress: size bounds, a per-source rate throughrate_limit_policies, a daily global cap throughpublic_ingress_policies; actionsclient-errors60 a minute and 50,000 a day,club-growth-events120 a minute and 100,000 a day). Server bounds: message 1..8,000 characters; stack and component stack 16,000; route 500; app version and build 64; context a JSON object of at most 32,768 octets; growth metadata an object of at most 2,048 octets.platform = 'cron-watchdog'is refused toanonandauthenticated(42501): only the database itself and the service role write it. The app's writers stay under half of every bound (client-error-row.ts: message 4,000, stack 8,000, route 500, context 16,384 octets with the largest values replaced by a marker;club-growth-metadata.ts: 1,024 octets), andtelemetry-bounds-parity.test.tsreads the migration and fails when either side moves alone. A row over a bound is refused (22023) and the report is lost, so a new telemetry field gets a writer bound in the same change. - One developer gate (00761).
private.is_dev_caller()is true only for the developer account AND whileprivate.dev_rpcs_enabled()(app_config.allow_dev_rpcs) is on; it is the only place the account is named.private.assert_dev_caller(fn)raises{fn} is dev-account only(42501, HINTdev_account_only); the nine developer functions (dev_read_telemetry,dev_clear_telemetry,dev_emit_test_push_signal,dev_inspect_cron_status,dev_mock_pay_session,dev_read_content_reports,dev_resolve_content_report,support_get_user_errors,get_user_consents's cross-user path) all use it. With the switch off every one refuses, even for the developer account. A new developer function calls the gate and never names the account. The production project runs with the switch off and the developer account disabled (a launch-day item,docs/release-evidence.md). - A pgTAP test reads and writes only its own fixture. Every row it counts or deletes is scoped to the fixture (its ids, or
created_at >= now(), which inside the test's transaction means rows this test created); a whole-tableDELETE/TRUNCATEor an unscopedcount(*)failscheck:pgtap-hermetic(exempt only by a property the script verifies: a permission probe insidethrows_ok/lives_ok, a listed reference table, or a count passed as an argument). A global result the test then filters must be complete: a picker or feed that returns the oldest N rows (signals_pick_pushable(100)) let a seeded database's older rows crowd the fixture out, so pass a limit that covers every row. Why: tests that read global state passed on a fresh CI database and failed on any seeded local stack, and a whole-table wipe locks every row, so two suite runs at once deadlocked. Fixture ids are fixed by design: run one suite at a time against a database. One stated exception: where the RPC under test owns a bucket the test cannot redirect (00689: the ingress RPCs stamp today's KST day, and the prune helper is table-wide over expired days), the test sets that footprint aside with a scopedDELETEinside its transaction, which ROLLBACK restores. It never refuses to run on rows a used local stack left there:00689did, and one day of local app use failed it on that day, six days later, and the next morning until the 04:23 prune (2026-10-28). - A trigger function that writes a table other than its own runs as SECURITY DEFINER, and a denormalized count belongs to its trigger alone. An invoker trigger runs as whoever fired it, so its write passes through the OTHER table's RLS as that user: the post counters (
club_posts.comment_count/reaction_count/read_count) updated 0 rows whenever a plain member commented, reacted or read someone else's post, and the count never moved (fixed in00671). A count column is zeroed on INSERT for every writer and reverted on any client UPDATE (author and admin included), so neither a seed nor a PATCH can put a number there that its rows don't back; seeds insert the rows and let the triggers count. Enforced bysupabase/tests/trigger_cross_table_writes_definer.test.sql: a catalog scan that also follows calls into invoker helpers (three levels), with a canary for the direct and the delegated write; it is textual, so dynamicEXECUTEis out of its reach. CREATE OR REPLACE FUNCTIONis two distinct footguns — both surface only at call time, never at migration-apply time, sosupabase db resetpassing does NOT clear them. (1) Changed arg list spawns a new overload.CREATE OR REPLACEonly replaces a function with the EXACT same signature; adding/removing/retyping a parameter creates a SEPARATE overload and leaves the old one live. When every param is defaulted, the two then collide for the lower-arity call (42725 "function … is not unique"); worse, the orphan can keep a deprecated body reachable. When you change a function's signature, DROP the prior signature explicitly in the same migration (DROP FUNCTION IF EXISTS schema.fn(<old arg types>);). Found 2026-05-24:recent_client_errors(int)orphaned by00191's 2-arg form, andprivate.submit_match_score(uuid,int,int,int)orphaned by00084'sp_outcomeaddition (the orphan still carried the pre-v2 ELO body) — both dropped in00201. Apg_procscan grouping by(nspname, proname) HAVING count(*) > 1overpublic+privateis the audit query; it must return 0 rows. (2) Re-deriving from a stale base silently reverts a later fix. When a hardening/refactor migration re-issuesCREATE OR REPLACEfor a function, it must start from the function's CURRENT (latest-migration) body, not the original.00194's search_path pass re-derivedsignals_enforce_immutabilityfrom the00107original (service_role-only bypass) instead of the00109fix (auth.uid() IS NULL OR service_role), silently re-breaking signal dedup re-emits (signals_emit'sON CONFLICT DO UPDATEruns withauth.uid()NULL — the headline emits are clean INSERTs so it shipped silently; only re-emitted/aggregating signals from cron broke). Restored in00200. Before re-issuing a function,grep -rl "FUNCTION <schema>.<name>" supabase/migrations/and diff against the HIGHEST-numbered prior definition.- The current definition of any database object is in the committed snapshot, offline (EP-1, wave 5).
docs/schema/**is generated from the local database (publicandprivateaspg_dump --schema-onlyentries, plus the cron job list and the storage buckets and policies; never row data) byyarn db:snapshot; migration files are history, the snapshot is the answer to "what does X do now". - Look it up with
yarn db:def <name> [--type function|table|trigger|policy|…] [--schema public|private]: it prints the function (with its grants and comment), or the table with its constraints, indexes, policies and triggers. Start everyCREATE OR REPLACEfrom this output, never from the last migration that touched the function. yarn check:schema-snapshotfails when the snapshot differs from a fresh dump of the local database (so a migration lands with a refresheddocs/schemain the same commit); likecheck:supabase-typesit runs only withSUPABASE_USE_LOCAL=1and skips with a message otherwise. Invariants I-4 (invariant_raise_contract: a refusal names its ERRCODE, and a HINT when it is P0001) and I-6 (invariant_trigger_order: the pinned trigger order onrsvps,matches,sessions,club_members,session_payments) are pgTAP ratchets.profilesandclubscarry column allow-lists, not table grants (00691, 00711). The migration that adds a column to either writesGRANT SELECT (<column>) ON public.<table> TO authenticated, or every client read of the new column fails with42501;club_invite_code_members_only.test.sqlfails until a newclubscolumn has its grant. A SECURITY INVOKER function that reads either table names its columns:SELECT *orc.*needs every column, so it fails for everyone once one column is closed (00711 rewrote the club, home and session bundles for this).member_reportscarries a column allow-list too (00736): who filed a club report (reporter_id) and the link to the platform report (content_report_id) are read only by SECURITY DEFINER code, never by a client, so a club's officials cannot learn who reported a member. A row policy alone does not hide a column: when a table's rows are readable by a role but one column must not be, close the column in the grant, and assert it in pgTAP (one_report_pipeline.test.sqllists exactly the closed columns, so a new column there fails until it is granted or named as closed).- Extension functions are schema-qualified in migrations (
extensions.gen_salt(...),extensions.crypt(...),extensions.digest(...),extensions.uuid_generate_v4(...)).supabase db pushapplies each file with a search_path that does NOT includeextensions, whiledb resetandmigration up --localresolve them — so an unqualified call passes every local gate and then fails on the linked project mid-chain, leaving the earlier prefix applied (2026-09-05:00590's fixturegen_salt('bf')failed after00585–00589had landed). Enforced byyarn lint:migration-extension-calls(DATA-14, inyarn check). - An idempotency guard (
IF NOT EXISTS, or a DO-blockIF NOT EXISTS (SELECT FROM pg_constraint WHERE conname=…)) silently SKIPS a constraint/table replacement, not just a re-create. A "fix" migration that intends to TIGHTEN or replace an object whose name already exists becomes a NO-OP under such a guard — it leaves the OLD definition live, andsupabase db reset(fresh DB) PASSES because the guard takes the create-branch there, so the bug only exists in environments that predate the object (i.e. prod). To replace a constraint,ALTER TABLE … DROP CONSTRAINT IF EXISTSthenADDit unconditionally (normalize offending rows FIRST so the new CHECK validates). Found 2026-06-03: 00151 intended to re-pinclubs_min_tier_check/max_tier_checkto the 5-tier enum but itsIF NOT EXISTSguard left the original 00093 7-tier check live in PROD for months (allowedplatinum/diamond, blockedgrandmaster) — only a runtimepg_get_constraintdefprobe caught it; fixed unconditionally in 00229. Same class as theCREATE TABLE IF NOT EXISTSthat silently skipped over a pre-existing v1 table and broke 00124. Verify a constraint/table "fix" actually took by probing the LIVE definition (pg_get_constraintdef(oid)/\d), never by trusting thatdb resetpassed. - Secret-value verification in chat / docs → NEVER paste the literal value. Use
SELECT length(value) FROM <table> WHERE key = '<x>';ordigest(value, 'sha256')to verify identity. Chat transcripts are durable; an exposed secret stays exposed until revoked. Lesson from 2026-05-19 rotation pause: aRETURNING length(value) AS value_lenmismatch triggered a debug paste of an sb_secret in chat, which then required regenerating the key from scratch. The canonical safe pattern isRETURNING key, length(value) AS value_len;and watch for trailing-newline traps that bumpvalue_lenby 1 (some SQL editors append\non paste). - Edge function service-key reads → use
Deno.env.get('TWOMORE_SECRET_KEY'). DirectDeno.env.get('SUPABASE_SERVICE_ROLE_KEY')reads are forbidden by@twomore/no-legacy-service-role-key-in-edge-functions; the legacy reserved name was retired after the Path B rotation. New edge functions should also gate Bearer-token auth guards on the project-prefixed var (seesimulate-activity/index.ts+seed-scenario/index.tsfor the auth-guard pattern).SUPABASE_*names are reserved by Supabase and can't be set via the CLI — always pick a project-prefixed name for custom secrets. - Soft-delete contract → setting
deleted_aton a row is ONLY half the work. Every read of that row's table MUST add.is('deleted_at', null)(Supabase adapter pattern) — omitting it leaks soft-deleted profiles onto every leaderboard, member list, match-history opponent name, and search result. Every write that creates the soft-delete MUST also (a) clear any sensitive columns that drive ongoing behavior (push_token, etc.) and (b) revoke any consent grants if PIPA-relevant. Easy to add the soft-delete and forget either read or write side. Use the canonicaldelete_account_atomicSECDEF RPC pattern (migration00186) for full atomicity:deleted_at+ push_token clear + consent revocations +pi_access_logaudit write in a single transaction. - A
CHECK ... NOT VALIDstill refuses every later UPDATE of an old offending row, so a migration cannot leave one behind (a status cron over such a row would abort its batch). The money checks (00764) go throughprivate.validate_constraint_or_record: it validates, and when an old row offends it DROPS the constraint and records the table, the constraint and the count of rows (never a value) inprivate.swallowed_errors; a later migration adds the constraint after the rows are repaired. The migration'sADD CONSTRAINTholds its ACCESS EXCLUSIVE lock to commit, so the validation scan runs under it too.
Write serialization
Transaction advisory locks, one key per domain, defined once in migrations 00681 and 00684 (private.lock_*, never ad-hoc pg_advisory_xact_lock calls). Every key is (classid, objid); unrelated domains and unrelated sessions never share a key.
| Helper | Key | Exclusive | Shared |
|---|---|---|---|
lock_rating_replay(excl) | (2147483000, 1) | recompute_all_ratings, agree_to_correction, dev_reconcile_seeded_world, seed maintenance | score submit/verify/agree_to_call/corrections, rating application, player_ratings and matches.verified_by writes |
lock_session_roster(session, excl) | (2147483001, hashtext(session)) | RSVP and guest-application writes (row trigger), match slot/round writes, sessions.status writes, complete_session/forfeit/cancels, waitlist admit | score work on that session's matches and its completion cascade |
lock_departures(excl) | (2147483002, 1) | club_members deactivate/delete, profiles delete/anonymize, clubs purge, purge_archived_clubs | score submit |
lock_dm_thread(thread) | (2147483003, hashtext(thread)) | dm_messages INSERT/UPDATE/DELETE (BEFORE ROW trigger), every messaging RPC (read receipt, participants add/remove, rename, leave); also takes departures shared first | — |
lock_push_claims() | (2147483004, 1) | signals_claim_pushable, signals_mark_pushed, signals_record_push_receipts (receipts also hold replay and departures shared) | — |
lock_refund_tick() | (2147483005, 1) | tick_session_refund_obligations (also departures shared first, then each overdue session's roster key) | — |
Rules:
- Order: rating replay, then departures, then refund tick, then session roster, then row locks (UUID order). The DM-thread and push-claim keys are leaves: nothing holding one takes a roster, rating or exclusive departures key, so they may sit under a roster key but never above one. Score entry points call
private.lock_match_score_write(match), which takes the three shared keys in that order. - Upgrade: only
lock_rating_replayforbids shared to exclusive (P0001rating_gate_upgrade_forbidden); take exclusive first.lock_session_rosterallows the score-owned completion cascade (a write from inside another trigger) to reuse its shared session key. - Row triggers are a backstop: BEFORE ROW fires after the tuple lock on UPDATE/DELETE, so RPCs that write rosters take
lock_session_roster(p_session_id, true)as their first statement. - A new write path that touches rosters, ratings or departures joins an existing key; a new domain gets a new
classidand a row here. Never reuse(2147483000, 1)for unrelated work (one key for everything was the 00682 draft; it serialized unrelated sessions). - Payments are roster writes: a
session_paymentsINSERT/status UPDATE (BEFORE ROW trigger) andsettle_manual_session_payment/acknowledge_session_refundtakelock_session_roster(session, true)before the parent/payment tuples, so cancellation, confirmation and acknowledgement of one session queue on one key while two sessions never do. - A DELETE retraction on
dm_messagestakes its thread key in a BEFORE ROW trigger (after the message tuple lock), so the one key holder that could wait on a message tuple, the read-receipt UPDATE, usesFOR UPDATE SKIP LOCKEDand never waits on a retraction. - pgTAP
00681_write_serialization.test.sqland00684_write_serialization.test.sqlassert the held keys per entry point throughpg_locks;scripts/db-concurrency/**proves the interleavings on a disposable database (seedisposable_guard.py: loopback AND the CI-onlyprivate.disposable_database()marker, else it refuses).
Sharing
- Handing a file to the person (the 장부's CSV) → the
FileSharePort(fileSharein@/registry,ports/services/file-share.service.port.ts): the web adapter downloads a Blob, the native adapter (file-share.native.ts:expo-file-system+expo-sharing) writes the file to the cache directory and opens the OS share sheet. Features use the hooks (useDownloadClubLedger,useCanShareLedgerFile), never the registry, and the native adapter is reached only on native (the web build resolvesfile-share.native.web.tsand stubs both modules). The native path needs a build that holds the modules and has not run on a device (docs/release-evidence.md, S18). - Building a share-card → use
buildSessionShareCard/buildMatchShareCard(and futurebuildXxxShareCardsiblings) from@/presentation/utils/share-cards.ts. Configuration-driven — the card pulls fields from the entity. Never compose share text inline. - Sharing a session → the
useShareSessionhook (or a futureuseShareXxxsibling;useShareMatchretired with the old match board, W4 batch 12 —buildMatchShareCardwaits inshare-cards.tsfor the live pages); never callShare.share()directly from a component. - Sharing a club →
useShareClubInvite(a member: the code, read when the member shares) oruseShareClubPage(a visitor: the page link, never the code).
No direct write to a challenge (follow-up batch C, 2026-11-12)
inter_club_challenges has a members' SELECT policy and no INSERT, UPDATE or DELETE grant for authenticated or anon: a challenge is written by propose_challenge, respond_to_challenge, cancel_challenge and private.settle_challenge (the settle job) alone, and the app's repository exposes propose, respond, cancel, standings and no create. The two helpers a policy calls (private.is_challenge_counterpart_member, private.holds_tagged_seat, private.owes_tagged_session_fee) return a boolean about the caller only and are the narrow exceptions on the SECDEF ACL list. A new write path to the table is a new function on the pgTAP list in 00755_challenge_lifecycle.test.sql, never a policy.
Failures stay visible: session renewal, keys, the server's sink, the withdrawal walk (engineering pass wave 2, 2026-11-12)
- Session renewal. A failed renewal is classified in the auth adapter from the installed auth library's own error classes (
@supabase/auth-js2.117.2:AuthRetryableFetchErrorfor no network, a timeout and a 5xx,fetch.jshandleError /_request;AuthApiError429;AuthRefreshDiscardedErrorwhen a sign-out raced it;AuthUnknownError, an answer that was not the auth service's own JSON) and leaves the port as aDomainErrorcodedAUTH_REFRESH_UNAVAILABLE(isAuthRefreshUnavailable,ports/services/auth-refresh-error.ts). Only a refusal the service decided (a 400 or 401 such asinvalid_grant,refresh_token_not_found,AuthSessionMissingError) counts toward sign-out. An unavailable answer keeps the session, is retried on the minute tick, never counts, and is reported at warning severity once and then at most every 10 minutes. A session already expired at start signs out on the first refusal, whichever tick brings it. Never match a failure by its message text. - Idempotency keys. One helper,
newIdempotencyKey()(lib/idempotency-key.ts,expo-cryptoon a device, the platform's Web Crypto on the web). A key that guards a write never comes fromMath.random(); a key already queued on a device is replayed as stored.Math.randomremains only for ids that guard nothing (a toast id, an optimistic post id, an upload file name, a crash-log id, the mock payment service). - The confirm Sheet closes, then awaits the button's action and does not catch its rejection. An action that returns a promise presents its own typed failure (a mutation's
errorSurface, a typed catch) and lets a defect reject, which reaches the app's unhandled-rejection report (Sentry's React Native integration enables Hermes' rejection tracker). - Server:
WHEN OTHERS. NoWHEN OTHERS THEN NULL. A block that must continue (a trigger that must not abort its statement, a batch job) callsprivate.record_swallowed(where, SQLSTATE, SQLERRM)first: the sinkprivate.swallowed_errorsstores the place, the SQLSTATE and only schema-object names quoted by the message (constraint,function,trigger,relation,column,index), never message text or a value, and is purged after 30 days byprune_telemetry. A validator of untrusted input narrows toraise_exception OR data_exception. A permanently failing item is recorded at most once an hour per item.invariant_no_silent_when_otherslists the one reviewed exception (seed_score_matches). Nobody sees the sink unless the operator reads it: it is a private table with no screen, read with SQL (SELECT source, count(*) FROM private.swallowed_errors GROUP BY 1, release-evidence S32), so a failure recorded there is not a failure a person is told. That is deliberate where the operation must not fail (the withdrawal ends the person's sign-in sessions throughend_sign_in_sessions_of_user, which records aninsufficient_privilegeand goes on, because a withdrawal must not fail on that), and it makes the read after each push part of the closure (S32). - Server: the cron watchdog counts failed runs (alerting after the job's threshold in
private.cron_expected_intervals: 1 for a job running daily or less, 3 for the rest), reports a job whose last success is older than 1.5 times its interval, and reports a job with no interval row. A new cron job registers its interval in its own migration (invariant_cron_expected_intervals). Known gap: a run stuck inrunningis not detected. - Server: the withdrawal walk. At day 30
anonymize_withdrawn_profilesanonymizes the profile, scrubs theauth.usersrow in place (placeholderwithdrawn-<id>@withdrawn.invalid, no phone, no password, empty metadata, still banned), and deletes the person's rows in everyauthtable with auser_idcolumn (private.delete_auth_rows_of_userfinds them at run time, so a later auth version needs no change; a table the owner may not delete from is recorded and skipped) and the audit-log entries naming the person. It also deletes the person's private match notes and invite lookup receipts, nulls feedback notes, invited phones and the club nickname. Posts, comments, messages and reviews stay under the anonymized id until the legal check (S24) says otherwise. A new table with a key toprofilesorauth.usersdecides its row in this walk in the same migration.
Trust at the boundary, the membership predicate, lookup limits, and who words a failure (engineering pass waves 3 and 4, 2026-11-12)
- A bundle result is validated, reporting first. The four bundle RPC results (club detail, home snapshot, session card, session detail) pass through
parseBundle(name, schema, data)(adapters/supabase/bundle-wire.ts) againstbundle-schemas.ts: every key the RPC emits is required, rows are checked to be objects (the mappers still check columns), the one tolerant field isthumbhashes. A mismatch is reported once per bundle per session with issue paths only (ids and indexes shown as*) and the value is returned as before; enforcing it is owed (seedocs/release-evidence.md, S11). A new bundle adds its schema, not a cast. The data client RETURNS a failed query's error: a read that wraps it intry/catchnever sees it, so a failed read must go throughhandleError(the weather adapter did not, and a failed read read as "no rows"). - Membership has one predicate. A policy that asks "is this person an active member (or admin) of the club" calls
public.is_club_member/public.is_club_admin, which testis_active; it never repeats theclub_memberssubquery, so a deactivated member loses access on its own, not because another table's policy hides the row. Every policy reads the caller as(SELECT auth.uid())so the planner evaluates it once. Enforced bysupabase/tests/policies_use_the_membership_helpers.test.sqlandinvariant_policy_shape.test.sql(with canaries). - Enumerable lookups are limited.
search_public_profilesneeds two characters, caps its limit at 50 and is charged toprofile_search(40 per 10 minutes, hintrate_limited_profile_search);count_clubs_by_nameis charged toclub_name_probe(30 an hour, hintrate_limited_club_name_probe, and it raises: a probe that is refused is never read as "name available");has_pending_join_requestanswers only for oneself or an official of the club;get_season_standingsonly for an active member;guest_rsvp_gate_contextonly for the caller's own id or a member of the session's club. A new function that answers a question about someone else's data carries a per-person limit or a membership gate, held bylookup_limits_and_gates.test.sqlandinvariant_rate_limit_liveness.test.sql. - The app words a failed write; the server's sentence is never shown.
failureMessage(error, { hints, codes, otherwise })(presentation/utils/failure-message.ts) is the one reader: a line by the refusal's HINT, then its CODE, thenotherwise, then the app's own lines for the failure classes every write meets (UNAUTHORIZED,FORBIDDEN, the network codes,OFFLINE_WRITE_BLOCKED), thencommon.genericError. It never returnserror.message(a test pins it). The factory's default message isfailureMessage(err); a feature's table of refusals (clubRefusalWords,sessionRefusalWords,dm.refusal) is a thin table over it. - Every mutation hook names its
errorSurface(required by the type):helper(a line under the field or on the CTA band),dialog(the app's oneWriteFailureDialog,@twomore/app, over the page),notice(a Notice on the page, or nothing) ortoast. A Toast is for a write whose failure can land after the person left the page (queueable writes, a venue adopt from a closed overlay) or that no page presses yet, each with a one-line reason in the hook. A background write the person cannot redo (a read mark, a delivery audit line, an unmount cleanup) shows nothing and is reported:errorSurface: 'notice'withhapticOnError: false; the factory still reports it.check:error-toast-growthratchetserrorSurface: 'toast'anderror.messageread inpackages/features/**down, never up.
Who may write: the authority closure (security pass, 2026-11-13, migration 00765)
- A membership row comes only from a join function.
club_membershas no INSERT grant and no INSERT policy for a client;handle_new_club(the founder),join_club_member_checked(join_open_club,join_club_by_invite_code[_for_club],apply_to_clubanddecide_join_requestfor a person's own application) andanswer_club_nominationwrite it. A person becomes a member only by their own act (N13): a nomination (nominate_for_membership) and a guest conversion (confirm_guest_conversion) create a pending offer (club_join_request, sourceadmin_nominated);decide_join_requestrefuses to accept one (hintdecide_nominee_must_accept); the offered person answers throughanswer_club_nomination(request, accept). One rule for an offer that ends without a yes: when anadmin_nominatedrequest leavespendingasrejectedorcancelled(the person declines, an official withdraws, the club stops asking for approval), the triggerrelease_guest_when_offer_endsreturns the guest row fromproposedtoactive;remove_club_guestcancels the guest's waiting offer in the same transaction, so a removed guest cannot accept afterwards; the officials' counts (pending_join_requests_count,pendingApplications,signals_open_requests) leave offers out (they wait for the person, not the official);confirm_guest_conversionanswersnominated,self_request_pending(the guest's own request blocks the offer) orconverted. A client UPDATE ofclub_membersis a column grant forauto_rsvpalone (the notify level and the group have their own functions). - A key column never moves under a client's hand. A trigger,
z_forbid_key_columns(private.forbid_key_column_change), refuses a changedsession_id,club_id,user_id,event_idorprofile_idon every table in the key-column table (supabase/tests/invariant_key_columns.test.sqlbuilds it from the catalog and fails on a new unguarded pair). It is a trigger and not a column REVOKE because PostgREST's upsert names every payload column inON CONFLICT DO UPDATE SET: a revoked column breaks the upsert even when the value is unchanged, while the trigger lets an equal value pass. Its discriminator is the existing one (CURRENT_USERinauthenticated,anon,authenticator), so a SECURITY DEFINER function, which runs as its owner, can still move a key. A new table that a client may UPDATE and that carries such a column needs the trigger (or a reasoned row in the test's allow-list). - Entitlements, contributions and scores are a function's.
subscriptions,club_challenge_contributions,match_setsandmatch_score_callshave no client INSERT, UPDATE or DELETE (SELECT stays);increment_challenge, the score functions and the billing path write them.invariant_fk_index_and_write_grantspins the tables a client may still write by name and command, and ratchets the grants that no policy backs at one (duesINSERT, reviewed). A post reaction is switched in place and only a member reacts:club_post_reactionshas INSERT and UPDATE policies that ask for the person's own row in a post of a club they belong to (the upsert isON CONFLICT (post_id, user_id) DO UPDATE), the count trigger fires on INSERT and DELETE only, and the key guard holdspost_idanduser_idso a switch cannot move a reaction to another post. - The anonymous role reads two columns lists, nothing more.
anonholds SELECT on 16 named columns ofsessionsand 16 ofclub_media(no host, rules or money column, no uploader); the signed-out pages call SECURITY DEFINER functions (get_public_share_preview,get_club_invite_preview) and read neither table. A policy expression runs with the caller's column rights, so hiding a column fromanonbreaks a policy that names it: theclub_mediametadata read and the gallery bucket's read are split into an authenticated policy (unchanged) and an anonymous one (the public clause only).invariant_anon_columnspins the lists and proves whatanonand a non-member read. profilesUPDATE is a column list.deleted_atandanonymized_atare not client-writable: withdrawal isdelete_account_atomic, which also ends the person's sign-in sessions (private.end_sign_in_sessions_of_user). A newprofilescolumn needs its ownGRANT UPDATE (<column>)as well as itsGRANT SELECT (<column>), or a client write of it fails with42501.invariant_profile_column_grantspins everyprofilescolumn as client-writable or server-only (the client-writable list equals the live UPDATE grant), the columnsauthenticatedcannot read (the SELECT list), and thatanonreads none; a canary adds a column and shows the test names it. The same rule forclubsisclub_invite_code_members_only(every column but the invite code).- A push token belongs to one profile. A trigger clears the same token from every other profile when one registers it, and
clear_my_push_token()is the signed-in person's own clear (sign-out calls it first while online; offline it is skipped and the next registration clears it). - A rate-limited replay refunds its charge once.
correct_own_scorerefuses cheaply, charges the limit, takes the replay lock, re-reads, and gives the charge back (private.refund_rate_limit_charge, one charge, never below zero) only when that locked re-read refuses.
See also
- AGENTS.md — DATA-1..DATA-27, KR-5 (machine-enforced data and consent constraints).
- Best Practices › Security — PIPA compliance, RLS, consent, and SECDEF audit.
- Best Practices › State & Architecture — Zustand, TanStack Query, caching strategy.
- CLAUDE.md — orchestration core + the pointer index back to this doc.