Skip to content

CLAUDE.md Milestone Archive

Status: Archived Last reviewed: 2026-06-27

This archive preserves the dated milestone ledger that previously lived in CLAUDE.md. It is chronology, not current instruction. For current state, read Current Project State, Current Documentation State, and AGENTS.md.

Operational State

Last milestone (2026-06-26, commit 81d12de3, migration 00303, runtime 0.6.1): Manual payment confirmation — session-host authority + debuggable fee flow (adversarial-audit fix). Origin: owner deferred PortOne to nearer-production and asked to debug the MANUAL confirmation system; an adversarial audit of the confirm path surfaced one root issue + downstream gaps. Root issue: payment read+confirm was gated on club owner/admin role (00059/00145), but a session's host is its created_by — for a 번개 (or any non-admin-created fee session) the host is NOT a club admin, so they could neither see the 정산 roster nor mark anyone paid: the participation fee they set was unconfirmable (directly contradicting "번개 can require fees"). Fix = payment authority is now session HOST (created_by) OR club dues-manager (canManageDues) (owner-chosen). 00303 adds ADDITIVE permissive session_payments host SELECT+UPDATE RLS policies (no re-derivation of the admin path — minimal blast radius), probe-verified 8/8 (host read+confirm; stranger blocked; admin preserved; payer reads-own-but-can't-self-confirm); it ALSO drops the stray 00059 FOR ALL "writable by admins" policy that 00145's unquoted DROP had silently missed (00059 created the quoted-with-spaces name; the underscore DROP was a no-op — the intended admin-DELETE removal never took effect in prod; caught by a live pg_policies probe). Client: the 정산 entry + screen gates OR in session.createdBy === userId; markPaid stamps method='manual'; the host roster now surfaces each member's 송금 완료 (submitted-but-unconfirmed) as a warning badge replacing the generic 결제 대기, with submitted rows sorted first, so the confirmer sees whose transfer to look for. New dev-panel seed session_fee_manual stages a fee 번개 hosted by the dev account with idle / 송금완료 / paid / paid-no-show holds (deployed + ran green on preview) so the whole flow is debuggable on-device. PortOne stays DEFERRED (the Phase B scaffold is dormant by construction — registry on the mock adapter, edge fns 503 until secrets — nothing to revert). 00303 applied to preview + probe-verified (prod untouched per the standing directive). Gates green: typecheck 0 · 2,796 tests · lint:strict 0/0.

Prior milestone (2026-06-25, commits 52504748 + dc526561, migrations 00301–00302, OTA group 5f28e889, runtime 0.6.1): Dual-rail session payments — 10-minute reserve hold + manual 계좌이체 self-attest + mock checkout, with the PortOne v2 server rail scaffolded (migration count 302). Continues the participation-fee arc (Phases 1–3 below) by adding the PAYMENT mechanics on top of the reserve-then-pay gate. The model supports BOTH rails on ONE session_payments hold — no new RSVP enum, no second table; the hold row's method + status + submitted_at back every state. (1) Phase A (00301, OTA 5f28e889, commit 52504748) — hold window + manual/mock checkout: session_payments gains method ('manual'|'portone') + submitted_at + portone_payment_id; sessions.payment_hold_minutes (default 10). The unpaid-hold expiry (00300) is re-issued with a FROM-RESERVATION window — LEAST(hold.created_at + make_interval(mins => payment_hold_minutes), session_start) — and made idle-only (submitted_at IS NULL), so a member who taps 송금 완료 STOPS the clock (the host's confirm latency no longer races the expiry); cron tightened :45-hourly → */5. New SECDEF attest_session_payment(p_session_id) (member marks 송금 완료 → sets submitted_at, method='manual') + dev_mock_pay_session(p_session_id, p_user_id) (DEV-account-only mock-paid, gated to dev@twomore.test via ERRCODE=42501). Client: new PaymentServicePort + a pure-JS mock adapter (createMockPaymentService{status:'paid', paymentId:'mock-…'} after 600ms; OTA-safe, no native dep) wired in the registry; useAttestSessionPayment + useSessionPaymentCheckout mutations (checkout calls payment.requestPayment → on paid, sessionPayments.devMockConfirm). sessionPaymentGateState widened to free|reserved|submitted|confirmed; session detail's hold UI branches on it: reserved → a 10-min useDeadlineCountdown + 결제하기 (mock checkout) + 송금 완료 (attest); submitted → a 확인 대기 callout. So the whole fee gate is debuggable end-to-end TODAY for the dev account with zero PortOne credentials and no EAS build. (2) Phase B (00302, commit dc526561, server+doc only — no OTA) — PortOne v2 server rail (scaffold): the trusted mark-paid path. public.mark_session_payment_paid_via_portone(session_id, user_id, payment_id, paid_amount) SECDEF service_role-only, idempotent (flips only a still-pending hold → a webhook racing the client-verify lands one paid row), defense-in-depth re-checks paid_amount = participation_fee (amount_mismatch else) — probe-verified (paid / re-call no-op / wrong-amount reject). Two Deno edge fns: verify-portone-payment (client posts its user JWT + {sessionId, paymentId} → server independently GETs api.portone.io/payments/{id}, asserts status='PAID' + customData.{sessionId,userId} === caller, then calls the RPC — the client NEVER marks paid) and portone-webhook (deploy --no-verify-jwt; Standard-Webhooks HMAC-SHA256 verify over ${id}.${ts}.${rawBody} + 5-min replay guard → re-fetch payment → RPC; this is what makes 가상계좌 auto-confirm without a host). Both read the service key from TWOMORE_SECRET_KEY (the legacy SUPABASE_SERVICE_ROLE_KEY fallback is now an ESLint error — @twomore/no-legacy-service-role-key-in-edge-functions) and return 503 not_configured until PORTONE_V2_API_SECRET / PORTONE_WEBHOOK_SECRET are set — safe to deploy early. Setup guide at docs/guides/portone-payments.md (console steps, supabase secrets set, deploy, the native-adapter last mile + OTA-safety guard, the verify-swap). Deferred (needs owner/external action): the real @portone/react-native-sdk native adapter + EAS build (native dep → new runtime), PortOne console config (store/channel/API+webhook secrets), deploying the two edge fns, and swapping useSessionPaymentCheckout from devMockConfirmverify-portone-payment. All migrations 00301–00302 applied to the preview linked project + probe-verified before commit (prod untouched per owner directive — "stay on preview for now"). Gates green: typecheck 0 · 2,796 tests · lint:strict 0/0.

Prior milestone (2026-06-25, commits 4b4b6aa7 + 49f7ade7 + 96584b3d, migrations 00298–00300, OTA groups 9e299cf5 + f35dbbf3 + 86d41b5e, runtime 0.6.1): Session participation fees — host-managed per-person fee for club sessions + 번개 (Phases 1–3, migration count 300). Origin: some club sessions incur extra cost beyond membership dues; 번개 can be free or paid; hosts need to configure the fee, track who paid, and handle paid-no-shows. Product decisions (owner-chosen): reserve-then-pay — an RSVP holds the slot (결제 대기) and the host confirms manual payment (계좌이체); paid-no-show = strike + keep the fee — normal no-show strike fires, fee stays paid, roster flags the row, refund is the host's off-app call. NO payment processor (Portone is a future phase). (1) Phase 1 (00298, OTA 9e299cf5, commit 4b4b6aa7) — config + surfacing: sessions.participation_fee (fixed per-person ₩; 0 = free, >0 = fee-gated) + payment_deadline_hours (used in Phase 3). participation_fee is the CANONICAL fee gate, DISTINCT from the legacy cost_total (post-hoc session split); SessionInfoModel.cost.perPerson reads participation_fee first (CostInfo.feeRequired flags a real gate), falling back to the cost_total split — the 참가비 chip on card + detail updates automatically. Generated types got a SURGICAL add for the two columns (committed file is stale per convention; full regen deferred). Create-session step 4 is kind-aware: 번개 = a per-person 참가비 amount (blank = 무료); club = free by default (membership covers) + a "별도 참가비" toggle revealing the amount field. Phase 1 is config + display only — no gating yet. (2) Phase 2 (00299, OTA f35dbbf3, commit 49f7ade7) — reserve-then-pay gate: SECDEF trigger private.sync_session_payment_on_rsvp ON rsvps auto-creates a session_payments HOLD (status='pending', amount=participation_fee) when an RSVP is confirmed on a fee session (ON CONFLICT DO NOTHING preserves an already-paid row); leaving confirmed releases an UNPAID hold only (paid rows are kept — money changed hands). A confirmed RSVP is 결제 대기 (reserved) while pending, 참가 확정 once paid or waived — no new RSVP enum; the existing session_payments row backs the state. Probes: confirm → pending hold created; cancel → unpaid hold released; paid → survives cancel. Client: pure helper sessionPaymentGateState(fee, paymentStatus)free|reserved|confirmed (exported from @twomore/app); session detail shows the viewer's 결제 대기 chip (SessionPaymentStatusBadge pending) + a $warning 송금 안내 callout (amount + "송금 후 호스트 확인 시 확정 · 송금 안내는 공지 확인"); the 정산 screen is fee-aware (live auto-roster with 결제 완료/대기 summary, no manual "정산 내역 만들기" CTA on fee sessions, empty state until participants RSVP; the legacy free-session split flow untouched). (3) Phase 3 (00300, OTA 86d41b5e, commit 96584b3d) — auto-expiry + paid-no-show: private.expire_unpaid_session_holds() + an hourly pg_cron (:45). For each confirmed-but-unpaid hold past its deadline ((date+start_time) AT TIME ZONE COALESCE(iana_timezone,'Asia/Seoul') per 00287 — TZ-aware composition — minus payment_deadline_hours), it: (1) pre-seeds an EXCUSED attendance record (strike 0) so the 00127 cancel trigger doesn't penalize a lapsed reservation; (2) cancels the RSVP (00299 drops the hold); (3) BEST-EFFORT promotes the next waitlisted member to confirmed (exception-guarded — a 00230 tier-band-blocked promotion just leaves the slot open). Probes: deadline-passed hold → rsvp cancelled + hold released + waitlist B promoted + given a fresh hold; future-deadline + paid holds untouched. Paid-no-show (client, derived from session_payments.paid + rsvp no_show): the 결제 현황 roster flags a 불참 Badge (warning) on a paid no-show — fee stays paid. Deferred: push signal to the user whose hold expired (polish; noted in-commit). Caught + fixed an EN i18n 'sessions''events' vocabulary slip in Phase 1. Gates green: typecheck 0 · 2,796 tests · lint:strict 0/0.

Prior milestone (2026-06-25, commit ef4ae123, migration 00297, OTA group 29035a33, runtime 0.6.1): DM image messages + venue-card background fix (Batch 6). Closes the media/images item deferred at end of Batch 5. (1) Migration 00297 — dm_media (applied to preview project + probe-verified): new PRIVATE dm-media storage bucket (10 MB limit, image MIMEs); storage.objects RLS gates both read and write to thread participants via is_dm_participant(split_part(name,'/',1)::uuid) (path-prefix pattern — thread_id as first segment, mirroring club-gallery-media / 00255). Images served via short-lived signed URLs (public URL would leak DM images). dm_messages gains attachment_path / attachment_width / attachment_height; dm_messages_content_check relaxed to allow empty content when an attachment is present (text rows still require ≥1 char). An image message is a normal text-type row so all three INSERT triggers fire: rate-limit, preview-update, push. dm_update_thread_preview (re-derived from 00293) shows 📷 for a caption-less image. Probes: image insert (empty content + path) → accepted, preview=📷; empty-text-without-attachment → rejected by CHECK; non-participant → storage RLS denies. (2) Client: StorageServicePort gains createSignedUrl(bucket, path, expiresInSeconds) (adapter previously only did public URLs). DmMessage entity carries attachmentPath/Width/Height (embedded in the existing 5s-poll SELECT). dm.sendImageMessage uploads to dm-media/<thread_id>/<uuid>.<ext> then INSERTs. useSendDmImage (networkMode: 'always' — binary upload can't be offline-queued; fails fast with a toast). useDmSignedUrl(path) — resolves the private URL per image (50-min staleTime < 60-min signed URL expiry). DM thread screen: ImagePlus compose-bar button (expo-image-picker — already a native dep, OTA-able); new DmImageBubble renders the signed image at its aspect ratio with optional caption below; load errors degrade to a placeholder glyph. (3) Venue-card background fix: VenueCard outer YStack was transparent, so its info area showed screen $background (surface.secondary) while sibling session-detail cards are $card (surface.primary) — a visible two-tone mismatch. Added backgroundColor="$card". Safe across both consumers. (4) ESLint-cache gotcha (recurring): agent-run cached lint:strict passed; fresh pre-push run caught missing @freshness on useDmSignedUrl and missing networkMode: 'always' on useSendDmImage; both fixed before push — same pattern as Batches 4b and 5. Gates green: typecheck 0 · lint:strict 0/0 · 2,796 tests.

Prior milestone (2026-06-25, commits 7beeaea0 + d046db80, migrations 00294–00296, OTA groups 4b73b79b + ac418f97, runtime 0.6.1): DM safety + reactions + rejoin fix (Batch 5a–5d). Closes the DM/chat feature arc. (1) Migration 00294 — dm_safety (5a+5b, OTA 4b73b79b): new user_blocks + content_reports tables (separate from the club-scoped member_reports; RLS: own-rows-only; writes via SECDEF). block_user/unblock_user/get_blocked_users/report_content SECDEFs (search_path='' + REVOKE PUBLIC / GRANT authenticated; report_content has a 10/hr rate limit). private.is_blocked is symmetric. Block enforcement: initiate_dm_thread raises dm_blocked; a dm_block_guard BEFORE INSERT trigger rejects sends into a blocked direct thread (system rows exempt). Message DELETE was backend-complete since 00220 — 5b is UI-only. Probes: block stops initiate+send; unblock re-enables; report+block-list work. Client: new SafetyRepositoryPort + supabase adapter + registry wire; useBlockUser/useUnblockUser/useBlockedUsers/useReportContent hooks; useDeleteDmMessage mutation; thread-screen long-press → delete (own, <1 min) or report; 1:1 header ⋯ menu → block/report; reusable ReportModal (5 categories + optional detail); blocked members' messages hidden in group threads; inbox hides 1:1 threads with blocked users; new 차단한 사용자 settings screen at /settings/blocked. (2) Migration 00295 — dm_reactions (5c, OTA ac418f97): dm_message_reactions table with thread_id denormalized (avoids a per-row subquery RLS join on every reactions fetch). toggle_dm_reaction SECDEF (add/remove, returns {emoji,count,reacted}; is_dm_participant gate + 60/min rate limit). Probes: ON→reacted=true count=1; OFF→reacted=false count=0; non-participant→42501. Client: reactions embedded in the existing message SELECT (5s poll); useToggleDmReaction (non-optimistic, write-through); quick-emoji strip (👍❤️😂😮😢🎾) atop long-press panel; reaction chips beneath each bubble (grouped + count + highlighted-when-mine). (3) Migration 00296 — dm_rejoin_fix (5d, BACKEND-ONLY): add_thread_participants used ON CONFLICT DO NOTHING, making re-adds of departed members (is_active=false) a silent no-op. Changed to ON CONFLICT DO UPDATE SET is_active=true, last_read_at=now() WHERE is_active=false — reactivates the member + resets their read cursor; active members' cursors untouched. RPC body re-derived from the current 00293 version (same 3-arg signature — no overload churn). Probe: leave→inactive; admin re-add→reactivated + member_added system message. Deferred (chat is now feature-complete for a club-app supporting feature): media/images (PRIVATE bucket + signed-URL adapter extension + device-validation needed — deferred as its own batch per the "defer device-validation pieces" principle); full-text search (messenger-grade, out of scope); typing indicators (requires realtime WebSocket — retired app-wide, poll-based typing is an anti-pattern). Process incident: a parallel sub-agent clobbered another's uncommitted work on dm-thread-screen.tsx + dm.ts during 5a; detected via post-agent inspection (missing file + 0 i18n markers); re-created via a sequential agent. Lesson codified: never let two agents touch the same file when the first's work is uncommitted. Gates green: typecheck + lint:strict 0/0 + 2,796 tests (ESLint cache gotcha caught at pre-push again — same pattern as Batch 4b).

Prior milestone (2026-06-25, commit 5c99917e, migration 00293, OTA group 6ea4909a, runtime 0.6.1): DM in-thread system messages + date separators + add-members (Batch 4b). Continues the DM/chat arc immediately after Batch 4 (commit 1a05983c, migration 00292). (1) Migration 00293 — dm_system_messages (applied to preview project via db push + probe-verified): dm_messages gains message_type ('text'|'system') + system_event + system_meta JSONB (ids / group title only — no personal-name fields; rendered strings composed client-side to stay within the JSONB no-PII rule). The five group RPCs (create_group_thread, add_thread_participants, remove_thread_participant, rename_group_thread, leave_thread) each call new SECDEF helper private.dm_emit_system to INSERT a system row recording the lifecycle change. All three dm_messages INSERT triggers — rate-limit, preview-update, push fan-out — SKIP system rows (a join/leave must not consume the actor's rate budget, overwrite the inbox preview with a raw token, or fire a "new message" push). RPC bodies re-derived from their current 00292/00290 versions (same signatures; no overload churn). Behavioral probe: text message → preview updated + 1 chat signal; 3 system events (rename/add/remove) → neither preview nor signals touched. (2) Client: DmMessage entity/mapper/adapter carry messageType/systemEvent/systemMeta. Thread screen refactored to a discriminated-union ChatItem (date | system | message) with FlashList getItemType — avoids recycler-pool collision per the live-tab lesson. Date separators (오늘/어제/날짜) inserted between messages from different calendar days via toDateString() + formatRelativeDate. System lines (centered muted text: ○○님이 들어왔어요/나갔어요/내보냈어요/단톡방 이름을 바꿨어요) resolve names — including departed members — via useProfilesByIds merged over the active-participant map (resolveNameunknownUser fallback). New DmAddMembersScreen at /messages/add-members?threadId= + routes.dmAddMembers factory + admin "멤버 초대" button in the participants sheet. Deferred: distinct added-to-group push signal + rejoin flow. (3) Lint-cache gotcha: agent's cached lint:strict passed; the fresh pre-push run caught 4 errors (inline getFullYear/getMonth/getDate in the date-key builder → toDateString(); bare Korean 외 N명 template literal → moved into the system.added i18n template; unused eslint-disable) — fixed + amended before the successful push. Gates green: typecheck + lint:strict 0/0 + test suite.

Prior milestone (2026-06-24, commits c5a3cb03 + 1a05983c, migration 00292, OTA group 72f4497a, runtime 0.6.1): DM group membership (Batch 4) + draggable dev FAB. Continues the DM/chat arc (prior Batch 2+3: commit f32bc211, OTA fd92bf97, migration 00291). (1) Migration 00292 — dm_group_membership (applied to preview project via db push): closes the pickup-roster group-chat gap — create_group_thread + add_thread_participants previously couldn't include confirmed co-participants who share no friendship with the caller. Fix: both RPCs gain an optional p_context_session_id parameter; when the caller is themselves confirmed in that session (private.is_confirmed_participant STABLE helper), any co-confirmed participant qualifies regardless of friendship. Old 2-arg overloads DROPPED first (avoiding the 42725 ambiguous-overload footgun documented in CLAUDE.md Data rules). New admin RPCs: remove_thread_participant (kick — never removes another admin) + rename_group_thread; both SECDEF search_path='' + REVOKE PUBLIC / GRANT authenticated. Behavioral probes: with-context adds the stranger co-participant (2 participants); without-context correctly rejects. (2) Client: createGroupThread/addParticipants signatures gain contextSessionId?; new useRemoveThreadParticipant + useRenameGroupThread hooks; session-group-chat entry point threads sessionId through routes → new-group screen → createGroupThread({ contextSessionId }). dmKeys.threadById(threadId) prefix key added (staleness fix — rename/add/kick invalidations previously missed useDmThread's sub-key, leaving the thread-screen header title + member count + avatar cluster stale after admin actions). ParticipantsModal gains admin-only inline rename + per-member kick (useConfirm destructive). (3) Draggable dev FAB (commit c5a3cb03): the dev-panel FAB was position:absolute bottom-right and overlapped UI elements; now react-native-gesture-handler Pan + Reanimated translate, minDistance(8) so tap still opens the panel, clamped on-screen, offset persisted to MMKV dev-panel-prefs store (fabOffsetX/Y, version 1→2). Gates green: typecheck + lint:strict + test suite (2 new hook tests).

Prior milestone (2026-06-19, commits 2c967b28 + 5a9dfbba + fe86a516 + 54e65fc4, OTA groups 7b624fa5 + a2d154da + 7202ebe8, runtime 0.6.1): SessionInfoModel + gate-ordered session detail + summary-card cost cue. Research-driven arc: adversarial join-decision analysis first, then implementation. (1) SessionInfoModel (2c967b28): new pure domain module at packages/app/src/domain/session-info-model.ts. The canonical "what a session IS" model: a fixed set of DECISION GATES a user clears before participating, in abort-risk-descending order — eligibility → when → where → conditions → kind → who → cost → urgency → action. buildSessionInfoModel(input) resolves a Session (+ venue / weather / rsvps / viewer / club / host context) into the gate values; it is a pure function (no i18n labels, no presentation). SESSION_GATE_ORDER is the canonical display order. SESSION_SURFACE_PROJECTION maps each surface (card / compact / detail) to its gate subset — surfaces consume the model instead of re-deriving gate values ad-hoc. 15 unit tests. (2) Detail wave A — gate-order flip + cost / 공지 / ELO-meaning / rain-verdict + weather fix (5a9dfbba, OTA 7b624fa5): session detail restructured from the prior PEOPLE-leads / "engagement-first" ordering to the gate model's LOGISTICS-first order: SURFACE 1 "일정 정보" = identity (generated title + status chip + RSVP badge + classification chips + 참가비 cost chip + 공지 when present) → venue tile → weather (condition + rain-risk verdict from conditions.rainRisk); SURFACE 2 "참여 · 경기" = host card + participation hero (count / fill / roster) → matchups. Each surface is rendered as an iOS-grouped-list card with internal hairline dividers — no outer SectionBlock titles. Added: affectsElo explainer line ("경기 결과가 ELO 점수에 반영돼요" / "친선 경기는 ELO에 영향을 주지 않아요"); binary rain-risk verdict so outdoor sessions clearly flag rain-out risk; weather section relocated to LOGISTICS surface (was in PEOPLE surface, separated from the venue it modifies). (3) Detail wave B — host card on all sessions + cancellation line + per-player tier (fe86a516, OTA a2d154da): PickupHostCard extended to appear on ALL session types (not only pickups) with a simplified identity row when session.publicInviteEnabled is false; cancellation commitment line ("N시간 전 취소 시 규정 적용") from cost.cancellation when the club has penalty tracking enabled; per-player ELO tier badge rendered in the participation roster so the viewer can quickly scan whether they're compatible with the player pool. (4) Summary-card cost cue (54e65fc4, OTA 7202ebe8): SessionCard now carries a per-head 참가비 cost chip (the gate-4 gap that was missing from the tight projection) — the cost is visible at browse time without tapping into the detail. Derived from cost.perPerson via buildSessionInfoModel. Deferred: where.distanceKm (viewer→court distance) — the model has the GAP-FILL slot but viewer-location persistence is not yet wired; will populate once location permission flow lands. Gates green: typecheck + lint:strict + 2,666 tests.

Prior milestone (2026-06-13, commits 9492890c + 5d66d019 + ec23cb64 + 4785f84d, migration 00266, OTA group dcbfdeca, runtime 0.6.1): Status-colored date cells + full-RRULE wizard step + multi-day week strip + scenario-builder as single mock-data source. Four-part session, all shipped to prod. (1) Status-colored DateTile on session-primary cards (commit 9492890c): DateTile gains stripBackgroundColor/stripTextColor props; new sessionStatusDateTone(status, myRsvpStatus?) utility returns a {bg, text} token pair sourced directly from the status-badge palette — live=red, confirmed=brand-green ($primarySubtle/$primary), open=teal, locked=gray, completed=success-green, cancelled/draft=muted. SessionHeader restructured: the leading dot + date-text combination is REPLACED by a left status-colored DateTile (month/day + start-time caption) with the title/chip/place column to its right — the status color leads the eye instantly before any text is read. Club cards keep the old SessionStatusDot + text-date pattern (SessionCompactRow dateStyle='dot') because the session is auxiliary to the club identity; session-primary cards (SessionCard, 경기 list rows) lead with the colored tile. sessionStatusDateTone / sessionStatusDotColor / SessionStatusDot all barrel-exported from the status module. (2) Create-club wizard step-3 captures full recurrence (commit 5d66d019): the create-club wizard's step 3 (create-club-step3.tsx) now collects the complete RRULE spec — 매주/격주/매월 frequency + multi-day selection + week-of-month (for monthly/biweekly) + start/end time — with a live Korean preview ("매주 화·목 09:00~11:00"). The step derives primaryDay/primaryTimeSlot from the captured spec so downstream code stays consistent; after creation it updates the 00264-trigger-created meet with the full captured RRULE so no double-meet is created. (3) Multi-day week strip on browse cards (commit ec23cb64, migration 00266): private.rrule_byday_keys(rrule_text) TEXT[] helper extracts BYDAY weekday keys; get_club_discovery_summaries now returns regular_meet_days TEXT[] (union across all active meets). ClubDiscoveryStats.regularMeetDays + buildWeekStripProps(days, primaryDay?) (maps TEXT[] → activeDays[] index array with primaryDay fallback for clubs pre-migration). Club browse cards render all active weekdays in the strip, not just the single primaryDay; single-meet clubs are visually identical to before. (4) Scenario builder is the single source of mock data (commit 4785f84d): supabase/seed.sql is DELETED; config.toml seeds only seed_public_courts.sql (immutable real reference data — court names, addresses, coordinates). All user/club/session/meet/DM/payment/review/challenge/availability/feedback/stats mock data is now generated RANDOMLY and IDEMPOTENTLY by the seed-scenario edge function. Added 7 missing entity generators: dm_threads+dm_messages, session_payments, court_reviews, club_challenges+club_challenge_contributions, member_availability, session_feedback, player_monthly_stats — all schema-verified + wired into the dense chain. New yarn seed (runs the home_comprehensive chain) and yarn seed:dense (runs the dense chain) npm scripts. seed-scenario redeployed. Gates green throughout: typecheck + lint:strict + 2,666 tests (2,143 in @twomore/app + 475 in @twomore/eslint-plugin + 48 in @twomore/clubs — clubs suite grew with multi-day strip tests); migration 00266 applied to prod; OTA group dcbfdeca published (runtime 0.6.1, android+ios).

Prior milestone (2026-06-13, commits 3f2f6021 + ad33f4c8 + 5c11d294 + 9231f41a + 5013df1f, migrations 00263–00265, OTA groups beee9a03 + 77c98739 + 6c176f26 + 36dd95f9 + 296ccb78, runtime 0.6.1): RRULE-based club recurrence model + club-card UI polish arc (B/C/D). Two independent arcs shipped end-to-end. (1) RRULE regular-meets foundation (commits 3f2f6021 + ad33f4c8, migrations 00263–00264, OTA group 36dd95f9): the production recurrence model for club regular meets. Architecture decision reversal: rrule was previously REJECTED (foundational-schema-decisions.md glicko2/rrule/IANA-tz REJECTED for this stack) citing DST complexity and maintenance risk — Korea's no-DST single-timezone neutralizes the DST argument entirely, making a mature iCalendar-standard library the correct choice over a bespoke weekday enum. Adopted rrule npm package (pure-JS, Hermes-safe, OTA-deployable). 00263club_regular_meets table: rrule TEXT (iCalendar RFC5545 subset — FREQ/INTERVAL/BYDAY/BYSETPOS + dtstart phase anchor), start_time/end_time, court_venue_id FK, venue_label, label, is_active, sort_order, exdates[] held (absent; forward-reserved for individual exclusions); RLS members + discoverable-viewers read / admins write; private.sync_club_primary_schedule AFTER INSERT/UPDATE/DELETE trigger keeps clubs.primary_day + clubs.primary_time_slot as a denormalized cache (source of truth is club_regular_meets); backfill seeds one weekly meet per existing primary_day; RETIRES the dormant club_schedule_slots table. 00264 — AFTER INSERT trigger on clubs: auto-creates the new club's first weekly meet from primary_day on creation. Client layer: new RegularMeet domain entity + Zod schema + port/adapter/mapper/registry + useClubRegularMeets query family + create/update/delete mutations (21 new tests). RRULE helpers at domain/utils/recurrence.ts: recurrenceSpecToRrule / rruleToRecurrenceSpec / regularMeetDays / isSupportedRrule / nextOccurrence — all timezone-safe (Korea single-TZ/no-DST, no tz-aware expansion needed). Korean formatter at presentation/utils/recurrence-format.ts: describeRecurrence / describeRegularMeet (produces "격주 토요일 09:00~11:00" — deliberately i18n-free domain strings; not t() calls). RecurrenceEditor ModalPanel in club settings + RegularMeetsSection admin view. Public club profile renders the multi-meet list. seed-scenario seeds varied biweekly / monthly / 2nd-venue meets so the multi-meet path exercises end-to-end. (2) B/C/D polish arc (commit 5013df1f, migration 00265, OTA group 296ccb78): B — SessionStatusDot reusable component + sessionStatusDotColor utility (richer status palette than the minimal card left-strip: open=teal / locked=gray / completed=green / cancelled·draft=muted + live=red PulseDot + confirmed=green, coordinated with SessionStatusBadge; the card left-strip stays the minimal live/confirmed signal via sessionStatusStripColor). C — SessionCompactRow gains dateStyle prop'tile' uses the heavy DateTile (session-primary surfaces: 경기 lists, club-detail 다음 일정) vs 'dot' uses SessionStatusDot + text date via t().common.monthDayWeekday (club cards where the session is auxiliary content, not the main event). D — clubTagLabel mapper — club tag chips previously rendered raw canonical values ('beginner_friendly' etc.); new clubTagLabel(tag) maps canonical values → i18n labels with fallback-raw for legacy free-form. Migration 00265 also DROPS the 'social' and 'training' canonical tags from the tag picker and strips them from existing clubs via array_remove (both duplicated the focus enum and added no new information); chipGroups axis order reordered: focus → composition → tags. seed-scenario updated to use only canonical tag values. Gates green throughout: typecheck + lint:strict + 2,653 tests (2,143 in @twomore/app + 475 in @twomore/eslint-plugin + 35 in @twomore/clubs). New dep: rrule (pure-JS, OTA-safe).

Prior milestone (2026-06-05 → 06-11, commits 87042bc06bcba44a — 85 commits, migrations 00238–00258, draft PR #11, continuous preview OTAs, EXTERNAL-AGENT BATCH: OpenAI Codex): Production-readiness hardening — ModalPanel replaces bottom sheets app-wide + signal-pipeline restoration + exact-count/keyset read paths + club growth feature + deterministic hosted Maestro smoke. This batch was authored by Codex on release/prod-readiness-hardening (branched off main at b907669b); Codex updated AGENTS.md (STYLE-3/4) + feature docs but NOT this file — this paragraph reconstructs the batch from the 85 commits + PR #11 + club-growth-completion-slices.md. Seven arcs. (1) ModalPanel supersedes Tamagui Sheet on every product surface — new compound primitive modal-panel.tsx (centered RN <Modal> transparent/fade, maxWidth 640 + maxHeight 88%, parts ModalPanel.Overlay (backdrop, dismiss-on-press default) / .Frame (built-in X close) / .ScrollView; NO drag handle, NO snap points); all 19 product sheet surfaces converted — most in-place keeping their *-sheet.tsx filenames (manner-tags, score-call/edit, tier-promotion/info, playability-info, swap, match-profiles, referee-actions, add-round, live-round-editor, match-rules-editor, push soft-prompt, post-session-steps), four renamed -sheet-modal (dues-action, edit-field, challenge-accept, transfer-ownership), ApplyToClubSheet deleted (inlined into public-club-profile-screen); ConfirmSheet re-implemented on ModalPanel with dismissOnOverlayPress={false} (public useConfirm API unchanged; the 2026-05-23 portal caveat is OBSOLETE for product code — RN Modal keeps the React tree, so useConfirm works inside ModalPanel content); the dev panel keeps the ONLY legal Sheet. New ESLint rule @twomore/no-bottom-sheet-outside-dev-panel (bans Sheet imports/JSX, gorhom, <ModalPanel.Handle>, legacy modal/snapPoints/dismissOnSnapToBottom/animation props on ModalPanel — everywhere except dev-panel/); AGENTS.md STYLE-3/4 rewritten accordingly. (2) Signal-pipeline restoration (00239–00242) — four silently-broken durable-push paths fixed: session-created no longer pushes to its own creator (00109 regression); score-call raise/delete now emits/resolves match_score_pending_verification per pending participant; referee-transfer RPCs re-pointed from the DROPPED private.create_notification (calls were failing) to signals_emit with new type match_referee_transfer_proposed (5-min expiry, accept/decline resolve); private.check_auto_call emits new match_sudden_death_needed for tied time-cap matches (+2 resolving triggers + a sweep-query fix); signal-system spec updated. (3) Exact-count + keyset-pagination read paths (00243–00251)get_dm_unread_count, get_post_reaction_summary, signals_unread_visible_count + signals_acknowledge_visible_unread SECDEFs replace capped client-side counting (the DM badge undercounted heavy users; reactions fetched 500 raw rows); ~12 keyset/partial indexes (signals feed, DM inbox tabs, club_members active roster, attendance ×4, member_availability, session_payments); adapters gain cursor pagination (created_at|id) + hooks useActiveSignalsFeed/useSignalUnreadCount/useDmThreadsByStatus/useDmUnreadCount. (4) Data-model fixes00252 elo_history RLS now lets match PARTICIPANTS read rows for club-less pickup matches (were invisible to H2H/history); 00253–00254 mixed_doubles added to match_format enum + backfill of doubles rows in mixed sessions. (5) Club growth feature (00255–00258 + full hex chain) — clubs gain tagline/focus/tags/photo_policy; new club_media table + private club-gallery-media storage bucket (member/public-approved read, admin write, path-prefix RLS; admin sees pending/rejected after the 00258 hardening pass); get_club_discovery_summaries RPC (member/slot/next-session per card); club_growth_events analytics (+record_club_growth_event / admin-gated get_club_growth_analytics with period-over-period + profile-completeness %); 2 new purpose_catalog rows; 18-assertion pgTAP RLS test (00016). Client: ClubMedia entity/port/adapter (30-min signed URLs), useClubMedia query family + media mutations + growth-event recording wired into the public club profile; admin GallerySection, new ClubAnalyticsScreen (/clubs/[clubId]/analytics, feature-gated); club cards lead with approved featured media. Ledger: club-growth-completion-slices.md — slices 1–6 COMPLETE, 7 (hosted club smoke) + 8 (release evidence packet) IN PROGRESS (hosted EAS Maestro runs stalled/canceled; no completed device-level proof yet — open risk, do not claim complete). (6) UI standardization + redesigns — new SectionHeader primitive (block/list variants; SectionBlock now delegates to it — its icon prop REMOVED, title color $textMuted$text); new SelectionChip (canonical selectable data/filter pill); DropdownChip/RangeChip rebuilt on SelectionChip + ModalPanel with controlled open; SearchBar gains rightAccessory. Home feed migrated raw ScrollView → virtualized FeedList (disputed-matches header + recommendations footer + HomeFeedSection-titled temporal sections, $5 section rhythm; month view = rolling 28-day window). Recap rebuilt infographic-first: WeekRecapCard now SegmentedDonutChart + ActivityMetricCalendar + MatchHistoryScopeSelector (props: +rangeStart/periodDays; attendanceRate/friendStubs/onShare dropped), taps through to period records pre-filtered via query params; RecordDetailScreen gains 7-dimension MatchListControls + range/initialScope; NEW records/day drill-down route; records screens gain format/nature/membership scope controls (incl. doubles team display). Club discovery: 8-field DiscoverFiltersState + ClubDiscoveryControls (filter icon in SearchBar) + centralized membership filtering; pickup finder gains 시군구 district filters (region:district encoded values, district chip auto-opens on region select). Web: all charts get SVG .web.tsx variants (+ NEW SegmentedDonutChart/StackedBarChart components), feed-list.web.tsx (FlatList/SectionList), real expo-location/expo-updates/sentry stubs — the Next.js app renders these screens. Per-icon deep imports (@tamagui/lucide-icons/icons/X) adopted across migrated files + ambient types/tamagui-lucide-icons.d.ts. (7) Testing/CI/tooling — Maestro moved to apps/mobile/.maestro/ (EAS workflow at apps/mobile/.eas/workflows/maestro-e2e.yml); deterministic hosted smoke via tab.* testIDs (flows 01/02/08 CI-active; 03–07 deferred pending seeds); the EAS before_maestro_tests hook seeds club_growth via scripts/run-scenario.mjs; layered scenario builder — _shared/scenario-builder.ts (ConfigurableScenarioBuilder, density presets, gender-aware mixed-doubles pairing) shared by seed-scenario + simulate-activity, scenario-chains.json as the SINGLE chain catalog consumed by BOTH run-scenario.mjs and the rebuilt dev-panel ScenariosPane (11 presets + density/format/edge sliders); check:supabase-types now Prettier-normalizes generated output (regen MUST pipe through prettier or it false-drifts); yarn size uses --source-maps external; debug:logs filters implausible perf rows; edge functions hardened (send-push + check-push-receipts cron-secret now FAIL-CLOSED, static-map input validation + 7-day CDN cache, export-user-data surfaces sub-query failures as 500, search-venues dual-path rate limiting via new 00238 fingerprint keys). Deployment state: migrations through 00258 verified aligned on prod (2026-06-11, supabase migration list --linked); seed-scenario redeployed; redeployment of the OTHER modified edge fns (send-push, check-push-receipts, static-map, export-user-data, search-venues, simulate-activity) is NOT confirmed in the record — verify before relying on the hardening; preview OTAs published continuously by Codex (latest group d5128319, 2026-06-11, runtime 0.6.1). Gates green: typecheck + lint:strict + 2547 tests (2070/122 app + 470/46 eslint-plugin + 7/3 clubs).

Prior milestone (2026-06-04, commits f2fbbb4a69fec5, migrations 00236–00237, OTAs f4615c57 + 2403962c): Match-history as a sound statistics foundation — rating-writer unification + full recompute + read-path consistency. Origin: owner asked to inspect how match histories are STORED + QUERIED since all statistics (ELO, rankings, H2H, partners, nemesis) derive from them. A 6-mapper + adversarial-verify architecture inspection Workflow (wf_6393815c) found the storage spine sound (raw matches + append-only elo_history + rsvps participation ledger) but the WRITE + READ paths broken. Write path (the critical hole): a match had two finalization flows and only one computed ratings — admin score-entry (submit_match_score) wrote elo_history/profiles/club_elo_ratings/player_matchups, but the live "call score → players agree" consensus flow (agree_to_call) flipped the match to completed+verified and NEVER ran the rating engine. So every consensus-finalized match produced ZERO ELO + ZERO aggregate impact — invisible to every statistic. 00236 extracted the post-auth engine verbatim into an auth-free, IDEMPOTENT private.apply_match_ratings(match_id) (reads scores off the row) that BOTH paths converge on (submit_match_score keeps auth+score-write then delegates — same 5-arg signature; agree_to_call runs it after finalizing — consensus IS the auth); also fixed the singles branch never writing profiles.elo_rating (a singles-only player was stuck at 1200). Probe-proven: admin + consensus produce IDENTICAL deltas, idempotent double-apply is a no-op. 00237 — full recompute (owner: "Full is the right move. None of the data we have is real"): private.recompute_all_ratings() wipes all match-derived rating state and replays every completed scored match in chronological order through the unified engine + the in-sequence confidence bonus; invoked once in-migration (no-op locally pre-seed; 273 prod matches replayed on db push). Read path: C1 (ee75fe0) — useHeadToHead sourced from member-club findPast (excluded guest matches, diverged from history + the player_matchups aggregate on the same screen) → repointed to the participation source (rsvps.findPlayedSessions). B-rank + C2 + D (a69fec5, one agent): the 전국/national leaderboard ranked the viewer's PRIMARY CLUB not the nation → new profiles.findTopByRating + useGlobalLeaderboard (all rated players, ≥1 match, club-agnostic); lifetime 총 경기 was counted from the 50-row-capped useEloHistory → now from the server-maintained counters (eloMatchesPlayed doubles + new singlesMatchesPlayed singles, uncapped); removed the inert 시즌 tab (season_records has no match-completion writer → always empty; data layer kept dormant). E (recompute-on-edit) investigated + dismissed: update_live_match_score hard-guards on in_progress and completed matches can't be deleted (RLS gates session-delete to drafts), so no finalized score can change to leave stale ELO. Gates green throughout (typecheck + lint:strict + 1985 tests); 00236+00237 applied to prod.

Prior milestone (2026-06-04, commits 7368fdbed91b36, migrations 00232–00235, OTAs 877d5559 + tail): Durable match-history capture — "store every dimension at write-time so matches can be categorized/organized later" (owner directive). Two arcs. (1) Membership + waitlist (00232–00233): assign_waitlist_position trigger (ordered 대기 positions) unblocks the deferred locked→대기 신청하기 CTA; session_style += league + rsvps.joined_as_member snapshot (member/guest/pickup via BEFORE INSERT trigger) — fixes the bug where a player's guest + pickup matches were entirely absent from history (it was sourced from CURRENT-member clubs via findPast; now participation-sourced via rsvps.findPlayedSessions) and where membership would retroactively flip as the user joined/left clubs. MatchHistoryEntry gains membership + nature; records-history gains membership/nature filter rows + chips. (2) Completeness audit + durable snapshots (00234–00235): a 6-mapper + adversarial-verify Workflow (wf_b34409d0) audited the FULL match data model for the snapshot-at-write principle (every categorization fact must be frozen at write-time, never derived from mutable current state). Confirmed durable: membership, opponent/partner identity, match-type, scores, win/loss, ELO-delta. Three verified gaps closed: nature was read-time-derived from mutable session.style/is_friendlymatches.nature snapshotted from the session at the →completed transition (the GUARANTEED transition — submit_match_score sets completed directly, skipping in_progress; trigger seam chosen accordingly); friends-vs-strangers [CRITICAL — actively drifting, triggerable from shipped UI] intersected each match against the LIVE friends list (and friendships are hard-deleted, so unreconstructable) → matches.friend_pairs JSONB freezes the accepted-friendship [lo,hi] edges among the players at completion (viewer-agnostic); the history friends scope now reads hadFriendInMatch ?? <current-friends fallback>; tier-at-time was never stored (only derivable by running TODAY's mutable bands over a historical rating) → elo_history.tier_before set by a BEFORE INSERT trigger from the row's own rating_before (decoupled from the 280-line ELO RPC — no re-issue risk), via new single-source private.tier_index_from_elo/tier_from_elo helpers that rsvps_enforce_tier now consumes (dropping the 3rd inline band copy — the exact duplication behind the 00151/00229 7→5-tier drift). All forward-only (NULL on old rows = honest "unknown"; never backfill a mutable-derived value). Refuted (no action): the "played matches can be erased via CASCADE" alarm — RLS blocks deleting any non-draft session, no SECDEF bypass; but its minor rec landed — the history query's brittle completed-only session pre-filter was dropped (the match-level scored+completed filter is the real "played" gate, so a match in a session cancelled mid-play now appears). Read layer threaded Match→mapper→MatchHistoryEntry (nature/friendPairs/tierBefore/scoreStatus/hadFriendInMatch) + a disputed chip via canonical ScoreStatusBadge. All locally validated (db reset + probes: nature snapshot, friend-edge capture, tier_before band boundaries, tier-eligibility block/pass parity after the refactor) before db push. Codified as the "every dimension is durably snapshotted at write-time" Data rule. Gates green: typecheck + lint:strict + 1985 tests (98 suites).

Prior milestone (2026-06-03, commits 63ffd6ac4a158c, migrations 00228–00231, OTA 9e4cd3a5): Foundational-schema batch — international-standard identity/tier/region foundations wired into the app (steps 7–10 of the foundational-schema decisions doc). Grounded in the prior research deep-dive (foundational-schema-audit.md + foundational-schema-decisions.md: library-backed where mature — iso-3166/country-flag-icons/openskill ADOPTED, glicko2/rrule/IANA-tz REJECTED for this stack). Four migrations, all locally validated (db reset green + structural and behavioral probes) before db push to prod. 00228 — profiles.nationality_code (ISO-3166-1 alpha-2, NOT NULL DEFAULT 'KR', non-sensitive/always-public; ATP-broadcast flag foundation): re-derives get_public_profile_view (verbatim 00221 body + the one always-visible field); Profile entity/Zod/mapper thread nationalityCode (?? 'KR' coercion covers both direct reads + the findPublicView JSONB path); ProfileUpdate untouched — nationality is IdP-derived in production (본인인증, [[project-verified-identity-signup]]), never a free-form picker; flag rendering stays hidden until international leagues (every near-term user is 'KR'). PI-EXEMPT attestation: rides on the existing identity purpose, demo-default only. 00229 — tier-column integrity (the headline find): a runtime probe caught that 00151's IF NOT EXISTS guard had silently left the original 00093 7-tier clubs_min_tier_check/max_tier_check live in prod — so a club still ALLOWED app-invalid platinum/diamond and, critically, COULDN'T set grandmaster at all. Normalized legacy values + DROP/re-ADD both constraints unconditionally to the 5-tier enum (also pins profiles.display_tier); marks the inert Glicko singles_rd/doubles_rd columns RESERVED; drops the dead never-scheduled grow-rd edge function (local dir + deployed prod fn). 00230 — server-side club tier-band RSVP enforcement (the band was advisory until now): private.rsvps_enforce_tier() BEFORE INSERT/UPDATE OF status trigger (RSVPs are a direct .upsert, so a trigger is the only seam) blocks confirm when the user's ELO tier is outside the session's club band; grandfathers existing confirmed RSVPs (only runs on new confirm; skips when OLD.status='confirmed'); skips pickups/no-band/unrated; ELO→tier bands inlined from TIER_DEFINITIONS; raises P0001 HINT='eligibility_tier'. useRsvp.onError maps that HINT → new errors.rsvpTierBlocked Korean message (graceful degradation; the full client CTA pre-check via evaluateEligibility is a tracked follow-up). Behavioral probes: block-low / block-high / in-band / grandfather all pass. 00231 — region triple-modeling fix: public_courts.region was seeded with Korean labels ('서울') while clubs/sessions/profiles store english SIDO keys ('seoul') — so picking a public-court venue set session.region to a Korean label, silently breaking the region filter. Idempotent migration UPDATE (Korean→english, short+long forms) for prod + seed_public_courts.sql edited (seed runs after migrations, so the UPDATE alone wouldn't fix a fresh reset); new regionLabel(region) helper (english key → Korean label via SIDO_OPTIONS, passthrough) wired at the 5 venue-region DISPLAY sites only (assignment/comparison sites keep the raw key). generated.types.ts got a surgical nationality_code add — the committed file is stale (missing dm_* from 00220+), so a full CLI-matched regen is flagged as a separate deliberate pass rather than smuggled into this batch (a wholesale local regen produced a 4000-line CLI-reformatting diff). Gates green: typecheck + lint:strict + 1985 tests (98 suites). db push applied 00228–00231 to prod; grow-rd deleted from the prod project; OTA 9e4cd3a5 (runtime 0.6.1, android+ios) shipped the regionLabel/useRsvp/nationalityCode JS changes — the region-display fix is coupled to 00231 (without it the live client would show the raw seoul key). Follow-on commits (OTA 3c5e2cb3, runtime 0.6.1): cce4e32client RSVP CTA tier pre-check (session-detail builds an EligibilityGate from club.minTier/maxTier, evaluates via evaluateEligibility(getTierFromElo(elo)), renders a cooldown-style disable+explain gate naming the required band BEFORE the round-trip; reached only on a new confirm — confirmed/waitlisted viewers grandfathered by earlier branches; new sessionDetail.tierBandRequired i18n; getTierFromElo exported from @twomore/app). c02bf1cATP Phase A-v2 guest chips: useLiveSessionData gains additive participantProfiles (members + a useProfilesByIds fetch over match slots + confirmed RSVPs, kept OUT of the ready gate) + memberIds; session-detail + spectator-scorecard thread per-match guestIds (participant ∉ memberIds) through to MatchScoreboard so guests show their name + chip instead of "—". Both gate-verified (typecheck + lint + 1985 tests). ac2519d (OTA c0eddaef) — two dev seed scenarios so the above can be visually QA'd (the catalog couldn't exercise either): home_match_day_guests (live match where 2 guests — real profiles NOT in the club — play the viewer's match → guest chips) + home_tier_gated (gold+ club, silver viewer → disabled tier-band CTA); seedHomeBase's silver–gold band + seeded elo 900–1900 keep guest RSVPs in-band, and the tier-gated scenario bumps RSVP'd members to gold first so the 00230 trigger doesn't block the seed; registered in the edge fn + dev-panel match tab + ko/en labels, seed-scenario redeployed (Deno bundle clean). Visual QA then surfaced 3 bugs, all fixed (OTA bb7bcda0): (1) the home live card's match summary still used members-only text rows — guests showed "—", no MatchScoreboard → refactored all 3 sections (진행 중/최근 완료/다음 라운드) to MatchScoreboard + guest chips, profileMap/eloMap/guestIds threaded from useLiveSessionData (484742a); (2) the Button primitive had no disabledStyle so disabled gates (tier-band, cooldown) looked/felt enabled → added disabledStyle: opacity 0.4 (e185586); (3) a non-member could open the full club-detail (sessions/board) — sessions_select RLS already blocked their session reads so it was a UX gap, not a leak → added the non-member→clubPreview redirect mirroring the preview's member→full bounce (c3efdfd). Remaining follow-up: the verified-IdP-onboarding purpose_catalog row for nationality (Phase 8 auth); DM-coverage seed scenario (blocked by the 00224 rate-limit trigger's auth.uid() check — needs a jwt-claims exec path). Match-card chip follow-on (commits c9fd7fce923119, OTAs 282d2957+0009ae09, USER-VERIFIED on device): the guest-chip work then iterated through layout + noise tuning — MatchScoreboard moved to one row per player (avatar + name + tier badge + chip per player, team score on the right), briefly carried club-role/호스트 pills, then settled on floating ONLY the 게스트 chip (role/host/member pills read too busy — an all-members doubles match showed a 멤버 pill on all four; only 게스트 = participant ∉ club members carries real signal). Kept: the one-row layout; a new opt-in Badge alignSelf prop (BadgeContainer's baked alignSelf:'flex-start' pinned pills to the TOP of alignItems:center rows → pass alignSelf="center"); and owner i18n 대표→회장 (동호회 leaders are 회장, not the corporate 대표 — also used by the club-members screen). The roleMap/hostId composer plumbing was fully removed end-to-end. Backend-only e923119 retired the root-cause footgun: generateUserSpecs now takes a deterministic emailPrefix (default 'player'; the guest batch passes 'guest') so a scenario composing two user batches can't collide — index-based emails restart per call, so without distinct prefixes the second batch's emails overlapped the first and createAuthUser returned the existing users (guests silently became members → no guest chip ever rendered). Single-batch scenarios stay idempotent across re-seeds. Perf sweep — render-thread sluggishness (2026-06-03, commits 424dd61e959d53, OTAs 73c3fdfa+019e8cae+019e8cd3, telemetry-driven + a 20-agent adversarial Workflow audit wf_f019599a): user reported app-wide sluggishness ("removing realtime didn't fix it"). The dev_read_telemetry perf stream + a verified 17-finding audit (8 confirmed, 7 overstated, 2 false-positive) established the felt lag was render-thread blocking, not network — realtime removal was an orthogonal axis. Phase 1 (424dd61, 5 app-wide fixes): js-thread-monitor background-gap cap (kills the bogus multi-minute "blocks" + the error-level resume breadcrumb spam) + a 2s breadcrumb throttle so the detector stops amplifying the jank it measures; UrgencyLine (session-meta-section) self-terminates its 1s proximity ticker (was hardcoded-on → re-rendered every second forever after a session started); Skeleton shimmer → one native-driver Animated.loop (was a setInterval(1s) PER instance → N timers per loading screen); query-lazy-persister per-success entry write coalesced + deferred off the success-commit path (was a synchronous JSON.stringify+mmkv.set each success); useScrollPerf returns a memoized handlers object (was a fresh identity each render). Observability (88009db): PerfProfiler gains an optional commitThresholdMs, set to 50ms on the home feed so sub-120ms render storms attribute to render:home-feed instead of only the generic js-thread:block. Phase 1.5 (e959d53): the query-timing path had the SAME background-gap bug (an in-flight fetch spanning a suspend → 297-second "slow query" artifacts) — guarded with MAX_PLAUSIBLE_QUERY_MS (codified as the new "wall-clock measurements must cap implausible values" Data rule). Verified-overstated/dropped: the "7 queries fire on every resume" alarm (the foreground catch-up IS correctly isStale-predicated, not the blanket-refetch anti-pattern) + 2 false positives (React Compiler handles the memo; the crash-cause guess was wrong). Deferred — session adapter 2→1 round-trip via a SECDEF RPC: tracing the real code (not the audit's quick proposal) showed SESSION_COLUMNS carries embedded club/venue PostgREST joins the SHARED session mapper depends on, so the RPC needs a custom nested-JSON return + cross-path mapper coordination — disproportionate blast radius for an intermittent resume-latency win; reconsider once on-device measurement shows whether resume latency is user-impactful. On-device effect pending a device cold-start onto the new bundle (telemetry confirmed the device was still on pre-Phase-1 bundles at ship time — the structural fixes are sound regardless, but the before/after delta is unmeasured). UI polish + Phase 2 screen-render fixes (2026-06-03→04, commits c70454f47ba68f, OTAs 019e8d6c+019e8d7d): three device-reported UI fixes — a center VS divider between the two MatchScoreboard team rows (reads as two competing sides; sits inside the card's own gap so it doesn't muddy the between-match gap); the club home-tab create-session CTA moved from the FeedList header to the screen BottomCtaBand (gated admin + home tab — the canonical single-primary-action placement, no longer sandwiched between the next-session card and the session list); and the club preview ScrollView got the missing paddingTop:16 (the identity hero jumped flush to the AppHeader title once loaded; the loading skeleton already had it). Then Phase 2 of the perf work — a second telemetry-driven, adversarially-verified Workflow (wf_24beb80c, 14 findings, 6 confirmed) targeting the SCREEN-level render cost Phase 1 left (the post-Phase-1 bundle showed scroll:scorecard-live 544ms, scroll:home-feed 164ms, js-thread:block ≤389ms, tab switches 265–310ms): scorecardgetItemType on the live-tab FlashList (the recycler-pool collision, now its own Components rule) + a stable renderItem reading scoreCallsByMatch through a ref so the 10s poll stops churning every visible row; home — defer the victory-native chart mount (SparkLine/MiniBarChart) behind a chartReady effect so the ~300ms synchronous D3 scale init is off the chip-tap commit (Skia TriRingProgress stays unconditional — worklet thread), cap LiveSessionCard display rows (live 4 / recent 3 / next 4; ~250 native views → ~80; stats + myMatch keep the FULL arrays), memoize the TriRingProgress Skia track path; tab-switchpreload='all''visited' on clubs/activity (the idle-mount gamble lost — the user ate the full pane mount on first tap anyway) + unmountChildrenWhenHidden on the PickupFilterBar Sheet (the 17-region picker was mounting on the cold tab). 2 findings dropped (React Compiler handles the EloTierBadge memo; a StyleSheet-in-render claim was a false positive). All gates green (1985 tests). On-device before/after still pending a cold-start onto OTA 019e8d7d. Status-adaptive session/club redesign (2026-06-04, commits 028e8d2ad7e090, OTA 019e908e, evidence-led + full-autonomy per user directive "always follow the evidence, build autonomously"): grounded in a 5-agent design Workflow (wf_61e450f1) + a web-research pass (Carbon/Material 3/NN-G status patterns, Just-Eat order-tracking IA, the discriminated-union "make invalid UI states impossible" practice, Spond/TeamSnap/Strava/Heja/Meetup/Partiful). Thesis: a destination surface (where the user decides to participate) + its summary card must communicate the entity's lifecycle STATUS instantly and adapt the whole surface to it (render-as-a-function-of-status, not boolean patches) — card+detail share ONE status language. Session — terminal-state SessionStatusBanner (completed→success/CheckCircle, cancelled→muted/Ban) atop the detail (028e8d2); the in_progress primary CTA flips 기권하기→라이브 스코어카드 보기 (forfeit demoted to the gear's destructive group — no analogue uses a destructive action as the live CTA) + the invisible cost line surfaced + a friends-going social-proof line + a 3-way style chip (tournament was mislabeled 경쟁) (31c1737); SessionCard mutes cancelled (opacity 0.55, bc06744). ClubClubJoinabilityBadge (a new canonical map-driven status badge in status-badges.tsx via CLUB_JOINABILITY_VARIANT: open→accent 가입 가능 / approval→info 승인 후 가입 / invite_only→neutral 초대 전용) on the discover card + preview so a non-member sees their join path BEFORE tapping; ClubStatusBanner (archived) atop club detail + preview; MyClubCard mutes archived (ad7e090). List screens (clubs my+discovery, 경기 my-sessions+discovery) inherit all of it for free — every one renders the single-source cards (verified). Codified as the "Status-adaptive surfaces" Components rule (terminal banner + card muting + per-state CTA + joinability-as-status-badge; never a raw <Badge variant="accent"> in feature code — the intent palette is sanctioned only via the map-driven wrappers, COMP-2). Gates green throughout (typecheck + lint:strict + 1985 tests). One deferred item: the locked→대기 신청하기 (waitlist-join) CTA needs RLS/trigger verification (can an RSVP upsert insert a waitlisted row on a locked session?) — possibly a migration — so locked stays at the chip (마감됨) + participation-card waitlist treatment (an active state, correctly NOT a terminal banner). On-device visual confirmation pending a cold-start onto OTA 019e908e.

Prior milestone (2026-06-01, EAS build 5b80d125 runtime 0.6.1): Unified network cascade + recap/onboarding redesign — workflow-driven (11-agent research deep-dive → 12 implementation commits). Three problem areas the user reported, all fixed with best-practice grounding (a Workflow deep-dive researched TanStack/TkDodo/Linear for network, Strava/Spotify/WHOOP for recap, NN/g/NextNative for onboarding before any code). NETWORK (N1-N6, OTA c0ca8da3) — unified 5-state cascade replacing 4 independent offline-detection implementations: new useNetworkState(queries) hook derives canonical NetworkPhase ('online'|'offline-stale'|'offline-cold'|'reconnecting'|'error') from TanStack flags (status=data-state, fetchStatus=execution-state per TkDodo); useCompositeNetwork helper gives every composer a uniform {ready,isError,isPausedOffline,phase} shape. QueryBoundary migrated — the core behavioral fix: offline-stale (offline WITH cached data) now RENDERS the cached children instead of wiping to OfflineEmptyState; only offline-cold (cache miss) shows OfflineEmptyState; the persistent NetworkBanner is the single global offline indicator. online-manager-setup gates on isConnected && isInternetReachable !== false (captive-portal courtside detection; !== false not === true to avoid flap during the null probe window). session-provider never force-logs-out while offline; deriveAuthState uses onlineManager.isOnline() as the primary offline guard. FeedList/GroupedFeedList gain isPausedOffline + offlineState props — fixes the "infinite load-more spinner offline" bug (offline footer strip when populated, offlineState when empty). Composers (useClubContext, usePlayerProfileData, useLiveSessionData) expose phase/isPausedOffline; session-detail + club-members + activity screens wired. NetworkBanner shows queued-mutation count inline ("연결이 없어요 · N개 전송 대기") + fires one "전송 완료" toast on reconnect drain-to-zero. RECAP (R1-R4, same OTA) — WeekRecapCard rebuilt as a 3-act social-framed narrative (Strava/WHOOP "one hero per beat"): Act 1 identity (displayLg count + N승M패 + ±ELO), Act 2 progress (TriRingProgress rings + SparkLine ELO trajectory), Act 3 social climax (friends-played, fallback new-opponents). MiniBarChart primitive CREATED in @twomore/ui (was doc-claimed but never shipped — doc drift reconciled). Card taps to routes.records. Share button removed. attendanceRate drilled from home views via cached useProfile. ONBOARDING (O1-O2 OTA d743337f; O3-O4 in build 0.6.1) — Button invisible-label ROOT CAUSE fixed app-wide (the "empty green square" bug, reported 3×): Tamagui useButton reads propsActive.color via useProps({noExpand:true}) which never expands variant styles, so the variant's text color never reached ButtonText (fell back to ambient $color → dark/invisible on green); fix: Button primitive now passes color explicitly per variant (VARIANT_TEXT_COLOR map) so every consumer renders the correct label color. 3 value-prop infographic cards (icon+Skia composition, no external assets, minimal text + Skip + 3-dot pager) replace the text-heavy welcome. handleFinish drops the user into the populated home feed (routes.home) instead of the terminal "번개 만들기/클럽 찾기" action fork; OnboardingStep4Done is now a brief "준비 끝! 🎾" beat. O3-O4 (native, needs build): react-native-spotlight-tour@4.0.0 + versioned coachmark-seen MMKV store + 3 just-in-time single-step coachmarks (FAB on idle-empty-home, live PulseDot on first in_progress, 매너 태그 on first completed session) + Settings "둘러보기 다시 보기" replay row. App version 0.6.0 → 0.6.1 (react-native-spotlight-tour native module → new EAS build). Migration count unchanged (no new migrations this batch). Tests 1901 → 1941. All gates green.

Prior milestone (2026-05-31, commit f509654, EAS build c1c8fee8 runtime 0.6.0): Engagement roadmap — Phase 8 (celebratory infographics) shipped + rigorous production audit closed. Phase 8 (5 chunks): @shopify/react-native-skia@2.4.18 + victory-native@41.21.1 installed; packages/ui/src/charts/ primitive library shipped (SparkLine with auto-trend color, MiniBarChart, TriRingProgress Apple-rings, CelebrationOverlay 24-particle Skia confetti); EloTrendCard flat bars replaced with GPU-threaded SparkLine; records HeroCard gains TriRingProgress (matches/10 + attendance rate + win rate rings); CelebrationOverlay wired via CelebrationGate in _layout.tsx consuming useCelebrationStore; home weekly/monthly pane replaced with Strava-style 5-beat WeekRecapCard (headline count + ELO spark + friends-played + top stat + share button); locked quantitative achievements gain ProgressBar + current/target progress derived from usePlayerStatsDetail; useAchievementUnlockDetection fires triggerCelebration on new unlocks; ActivityHeatmapCard (12×7 day grid, match-count color intensity) added to records screen; PersonalRecordsSection gains teaser mode (ghost placeholders + threshold copy) when matches < 5. App version 0.5.0 → 0.6.0; EAS build triggers new runtime for Android (softwareKeyboardLayoutMode:pan + native Skia + victory-native). Production audit (3-agent deep pass): 6 user-reported UX issues fixed (club banner initials fallback, keyboard safe-area handling, profile play-stats card replacing match list, tier i18n via tierSubLabel(), sheet dismiss-then-navigate, DM mark-read breadcrumb); security gaps closed (5 match_referee SECDEF REVOKE, static-map auth guard, profile PII column split OWN vs PUBLIC, friendship snippet phone/push_token stripped, export-user-data 5 missing PIPA §35-2 entities, privacy-screen placeholder phone); data integrity (migration 00219: 9 FK cascades on profiles); offline queue (confirm-attendance + GPS checkin); 16 MMKV stores versioned; 52 new tests (1849 → 1901). Migration count 221. All gates green.

Prior milestone (2026-05-31, commits f7ee2d7545a441, OTAs 08f1b9f0 + 5bfac7de + 02775bff + fcd2930a + 0692e830 + 705a2e64 + d3da022d + f722fe01, migration 00215): Engagement roadmap — Phases 5 (settings IA) + 6 (번개 lifecycle parity) shipped end-to-end in 9 sequential chunks. Phase 5 (4 chunks): collapsed the duplicate-route ProfileMenuCard 6 rows → 2 (Settings + Logout — single canonical entry-point per destination); restructured SettingsScreen into the Toss-grade 5-card / 21-row 4-tier hub (identity → frequent toggles → secondary nav → app info → destructive); developer-mode toggle now hidden behind a long-press affordance when off so new users don't stumble on it; FriendsSummaryCard NEW promotes friends to a primary profile surface (avatar stack of 4 most-recent overlapping ~10pt + "친구 N명" + chevron to /profile/friends) replacing the unbounded FriendsPreviewSection inline list; dropped the opacity={0.55} "준비 중" KakaoTalk Card and collapsed apps/mobile/app/legal/contact.tsx to a shim. Phase 6 (5 chunks) closed the 5-month dead-end where migration 00017 had landed a complete guest-application backend with ZERO consumers: chunk 1 wired the dead apply chain into session-detail's BottomCtaBand precedence (3 new branches for pending/rejected/no-application); chunk 2 added PickupHostCard between SessionOverview and SessionStatusParticipationCard for the trust-signal foundation, gated on publicInviteEnabled; chunk 3 (migration 00215) shipped decide_guest_application SECDEF (atomic approve-creates-rsvp — closes the gap where approval flipped status but never created the RSVP row) + cancel_pickup_session_without_strike SECDEF (pre-seeds excused attendance_records BEFORE cancelling RSVPs so the 00127 classify_rsvp_cancel trigger's ON CONFLICT preserves them and zero strikes fire) + new PickupHostDashboardScreen at /sessions/[id]/host for pending-app approve/reject + destructive cancel-pickup CTA; chunk 4 added PublicPickupPreviewScreen at /sessions/[id]/preview (host card + session basics + silhouette participant strip with grayscale $borderSubtle/$surfaceSecondary placeholders capped at 6 + district-level location only + apply CTA), 4-condition bounce useEffect short-circuits host/member/non-pickup/approved viewers to the full screen, activity-pickups-screen reroutes every pickup tap to the preview via in-parent onPress override; chunk 5 lifted date OUT of the 7-dimension pickup-filter sheet into a SegmentedTabs chrome row (DatePickupChromeBar — 5 tabs 전체/오늘/내일/이번 주/이번 달) + HomeNearbyPickupsCard self-gates on user.region has open pickups AND user has <2 confirmed sessions in next 14d using zero new query hooks. Migration count 214 → 215. All gates green throughout; 1848 tests pass across 91 suites in @twomore/app. Phase 7 (messaging DM primitive) is gated on open decision §1 about DM scope (text-only 1:1 + 요청 gate vs KakaoTalk handoff vs lite one-shot) — needs user confirmation before starting.

Prior milestone (2026-05-31, commits 5cbc3811ad6bb5, OTAs 31f6666f + 452c868a + 53fc1d17 + 33954128, migrations 00213 + 00214): Engagement roadmap — Phase 4 (club lifecycle) shipped end-to-end in 4 sequential chunks. Discover cards used to dead-end at the invite-code prompt; now the full lifecycle is wired. Chunk 1 — both MyClubCard + DiscoverClubCard lead with a 3:1 banner strip (uses clubs.banner_url Image or getBannerPresetColor fallback) + new ClubLogoBubble primitive (32px circle, logo_url or initials). Chunk 2PublicClubProfileScreen at /clubs/[clubId]/preview (parallel to PublicProfileScreen for players): identity hero + description + single JOIN CTA in BottomCtaBand; members landing on the preview bounce to full /clubs/[id] via appRouter.replace. Chunk 3 (migration 00213) — 3-tier join_policy enum ('open'|'approval'|'invite_only') on clubs + new club_join_request table (partial-unique on status='pending') + apply_to_club + decide_join_request SECDEFs + ApplyToClubSheet (optional 500-char message) + policy-aware CTA (approval → apply / pending / re-apply states). Chunk 4 (migration 00214) — clubs.archived_at + transfer_club_ownership (atomic owner→admin / new-owner→owner role swap + dual club_ownership_transferred signals) + archive_club + unarchive_club SECDEFs + purge_archived_clubs SECDEF helper + pg_cron daily 03:30 KST that hard-deletes clubs archived > 30 days (CASCADE handles child rows). New TransferOwnershipSheet (admins-first member picker with useConfirm destructive gate); DangerZoneSection now carries "대표 권한 넘기기" + "클럽 보관" (replaces the immediate-delete CTA — the new sanctioned path is archive→30-day-grace→hard-delete). The useDeleteClub hook stays exported for non-UI callers but is no longer wired into any feature surface. Chunk 5 (this commit) — Components rule (below) + screen-blueprint additions + Operational State narrative + migration count 212 → 214. The @twomore/no-direct-club-delete ESLint rule is the documented follow-up (the archive path is now the canonical route; doc precedes enforcement). All gates green throughout.

Prior milestone (2026-05-30, commits fa3c48622a488f, OTAs 04109757 + 6174767d + 235c174a): Engagement roadmap — Phase 3 (network resilience + offline UX hardening) shipped. Persistent NetworkBanner mounted at app shell — subscribes to TanStack onlineManager, renders thin amber strip with WifiOff + "연결이 없어요" when offline, nothing when online; mounted once in _layout.tsx so every screen inherits without per-shell wiring (diverged from per-MainTabShell/DetailShell mount because those live in @twomore/ui and can't import onlineManager — hex direction). New useIsOnline() hook extracts the subscription for non-queueable mutation gating. useLiveSessionData gains isPausedOffline (any query fetchStatus='paused' AND !isFetched) so screens that bypass QueryBoundary (the spectator scorecard) can branch; scorecard now renders a paper-backup EmptyState ("지금은 종이로 점수를 기록해 주세요. 다시 연결되면 입력한 점수가 자동으로 전송돼요") when offline-cold-start. Phase 14d queueable-mutation pattern expanded from 4 → 10 mutations: useUpdateDuesStatus, useMarkPaymentPaid, useMarkPaymentWaived (session_payments idempotency_key from 00143), useSendFriendRequest (idempotency_key from 00122), useAcceptFriendRequest, useDeclineFriendRequest (UPDATE/DELETE-by-PK naturally idempotent). Skipped with grounded reasons: useGrantConsent + useRevokeConsent (user_consent table lacks idempotency_key — server-side dedup gap), useConfirmAttendance + useExcuseAttendance (RPCs naturally idempotent but signatures don't accept idempotency_key — queue support would be safe-but-redundant). Paused-query telemetry: setupOnlineManager subscribes to onlineManager and emits network:offline-session perf events with elapsed offline duration (5s threshold filters radio handoffs), visible via yarn debug:logs --kind=perf.

Prior milestone (2026-05-30, commits 62eace921d779b, OTAs b215191c + 50107c62): Engagement roadmap — Phase 2 (identity-everywhere + privacy schema) shipped end-to-end. New PlayerIdentityChip primitive in @twomore/app/presentation/components/ (compact sibling to PlayerIdentityCard) migrates the leaderboard RankRow across the 3 tabs (global / club / region) + head-to-head OpponentRow; tier badge stays SEPARATE in RankRow because it's a distinct press target opening TierInfoSheet. matchup-item TeamSide deliberately stays plain Text per the rule (c) — dense scorecard rows with the parent Card already tappable. Migration 00212_profile_visibility.sql ships the privacy foundation: profiles.profile_visibility JSONB column with the auto-accepted defaults (club_members for region/district/playRegions; private for gender/birthYear), can_view_profile_field(viewer, target, field) SECDEF predicate (4-tier public/club_members/friends/private with self-bypass), and get_public_profile_view(target) SECDEF row builder that masks gated fields to NULL when the caller lacks access + NEVER returns phone/kakaoId/pushToken regardless of viewer. Adapter profile.findPublicView calls the row builder via PostgREST rpc + feeds the JSONB through the existing profileMapper. New SettingsPrivacyScreen lets users configure per-field visibility (SegmentedTabs picker for the 4 levels); PublicProfileScreen now consumes findPublicView via the new use-public-profile query hook so visibility is enforced server-side end-to-end. Korean copy uses 클럽 — the product-vocabulary i18n gate rejects the tab-only 모임 outside the tab. All search_path = '' SECDEF discipline + 00209 REVOKE PUBLIC/anon + GRANT authenticated.

Prior milestone (2026-05-30, commits a284c5a + 19de0ec, OTAs 2cec4e8e + a85a6c1d): Engagement roadmap — Phase 1 (public profile + skeleton sweep) shipped. PublicProfileScreen restructured to the canonical detail-screen archetype (identity card → records card, with a deliberate social-slot gap that Phase 5 fills); flowing cards drop their SectionBlock titles per the no-title-on-flowing-cards rule. BottomCtaBand sweep pins the FriendshipButton (public-profile) and the non-member Join CTA (club-detail) at screen root, matching the canonical session-detail "single contextual primary action" pattern (HomeTab.header's duplicate inline join button removed). venue-detail-screen consolidates a 5-button mid-screen action row into HeaderActionsMenu (navigate / view-map / open-booking-site / call-for-booking) + BottomCtaBand (book-alert toggle, variant flips by enabled state). post-detail-screen + record-detail-screen audited clean per the same chunk (post-detail's lone Button is the comment-composer send beside StyledInput — chat affordance, not a primary CTA candidate; record-detail is a read-only stats viewer).

Prior milestone (2026-05-30, commits 90edd41575d544, OTAs 43556523 + 9227249b): Engagement roadmap — Phase 0 foundations shipped. Offline-aware QueryBoundary + new OfflineEmptyState close the dead-skeleton symptom (TanStack's networkMode:'online' paused queries forever offline, and useScreenReady never short-circuited the 'paused' fetchStatus). New PlayerIdentityCard primitive in @twomore/app/presentation/components/ (promoted from MatchProfiles.PlayerRow — must live in @twomore/app, not @twomore/ui, because EloTierBadge/TrustTierBadge are domain-aware; hex direction forbids ui from depending on app). club-detail SegmentedTabs lifted to immediately under the header (hero card + next-session + non-member join CTA moved into HomeTab's header slot, killing the mid-screen tab placement that Material 3 / NN Group flag as a nested-tab anti-pattern; also clears the duplicate non-member join CTA — HomeTab.header already renders one). Scorecard RoundRulesStrip de-iced of decorative lucide icons (chip text already names the rule; "icons SPARINGLY" Components rule + Material 3 chip-icon-as-optional spec). Drives the roadmap at docs/architecture/engagement-roadmap.md (synthesized via Workflow wf_b7c1464e-80c: 10 agents, 8 themes, 9 phases). 7 open decisions auto-accepted per the researcher's grounded recommendations; user can interrupt to redirect any of them.

Prior milestone (2026-05-25, commit 4b06b58, no OTA — backend-only): Live push+weather outage diagnosed, restored, and the watchdog blind spot that hid it closed. Origin: provisioning the cron auth. Found via a dev_inspect_cron_status probe that every cron-dispatched HTTP call had been 403'ing — a total push + weather outage. Root cause: the edge CRON_SECRET was set but app_config.cron_secret (the value the cron reads to build the x-cron-secret header) was never provisioned, so the cron sent an empty header → mismatch → 403 on send-push (every 2 min), check-push-receipts, and all 6 weather jobs. The send-push fail-open guard was effectively enforcing (since the edge secret was set), and the weather guards are fail-closed — so both were down. Restored by the user setting app_config.cron_secret to match the edge secret (verified 403→200 within 2 cron ticks). The cron_watchdog (00195) was silent throughout because it only inspects cron.job_run_details (dispatch-level), and net.http_post succeeds at dispatch even when the response is 403/5xx — the exact blind spot that let the original 2026-05-17 33-hour outage run. 00202 closes it: private.check_cron_health() now also scans the last 30 min of net._http_response, and on ≥5 non-2xx writes a deduped client_errors alert (per-status breakdown + sample body) surfaced in yarn debug:logs. net._http_response has no URL (pg_net deletes the request-queue row on completion), but every net.http_post here is cron-originated, so a non-2xx burst is unambiguous. 00202 ALSO fixed 2 latent bugs in 00195 that meant the watchdog could never write ANY alert: client_errors CHECK constraints reject platform='cron-watchdog' (allowed ios|android|web|unknown) and severity='critical' (allowed warning|error|fatal) — both never surfaced because the dispatch-level path only fires on a 3x job-level failure that net.http_post dispatch never produces. Fix: widen the platform constraint to add cron-watchdog + switch alerts to severity='fatal' (existing top tier). Runtime-validated locally (healthy→0 alerts; 6×403→one fatal alert with {403:6} breakdown; deduped to 1; db reset clean).

Prior milestone (2026-05-24, commits 2384aa0875c424, no OTA — backend-only): DB-correctness sweep — restored the local db reset gate, then found + fixed 2 silent prod regressions + closed a function-overload bug class. Origin: production-readiness /goal loop. Restoring the local-validation gate (60f2307 softened 00193's fresh-DB-broken RAISE EXCEPTIONRAISE NOTICE; 2384aa0 repaired seed.sql — removed the dropped tiebreak_score column + SET LOCAL session_replication_role='replica') surfaced three real issues, all runtime-validated against the local DB before shipping and all applied to prod via db push:

  • 00200 — signals-immutability system-bypass regression (silent prod bug since 00194). 00194's search_path hardening re-derived signals_enforce_immutability from the 00107 ORIGINAL body (bypass = service_role only) instead of the 00109 FIX (auth.uid() IS NULL OR service_role), silently reverting it. signals_emit's ON CONFLICT (dedup_key) DO UPDATE (count-bump / re-surface) mutates payload+created_at+resolved_at+expires_at — all immutable cols — and runs with auth.uid() NULL (no JWT in the SECDEF context), so every dedup RE-EMIT raised. Shipped silently because the trigger is BEFORE UPDATE only: first-time emits are clean INSERTs (the headline push-pipeline verification only exercised those). The regression bit re-emitted/aggregating signals (dues reminders, weather warnings, count-bump social) fired from cron. Restored the auth.uid() IS NULL bypass while KEEPING search_path = ''. Runtime-proven: a local probe showed the dedup re-emit SUCCEEDS under 00200 (count→2) and RAISEs under a 00194 repro.
  • 00201 — 2 orphaned ambiguous function overloads. A pg_proc scan (public+private, HAVING count(*) > 1) found exactly two, both from CREATE OR REPLACE with a changed arg list spawning a new overload while the old one survived: (1) recent_client_errors(int) orphaned by 00191's 2-arg form → recent_client_errors(20) matched both → 42725 not unique (broke the documented dev SQL-editor fallback; the live readback uses dev_read_telemetry so app unaffected); (2) private.submit_match_score(uuid,int,int,int) orphaned by 00084's p_outcome addition — still carrying the pre-rating-v2 ELO body from 00070 — a latent landmine (all 3 live callers pass 5 args so the score path was safe, but a stray 4-arg call was ambiguous / could run deprecated v1). Dropped both; post-fix schema scan returns 0 ambiguous overloads.
  • ab0b03c — regenerated generated.types.ts from the live schema (53 migrations of drift; matches.tiebreak_score was split into team1/team2_tiebreak_score in 00147). yarn typecheck stays clean — the codebase was already coded against live, so this only closes the drift.

Also audited all 7 functions in 00194 + all 8 in 00188 for search_path = '' runtime failures (the class where an unqualified ref fails at call time, not apply time): all correctly qualify their public.*/custom-type refs; the 3 callable ones runtime-probed clean. 00194's only defect was the immutability LOGIC revert (fixed). Both CREATE OR REPLACE footguns (overload-spawn + stale-base re-derivation) codified as a new migration-discipline rule in the Data section.

Prior milestone (2026-05-20, commits b79b1062e6c6ae, OTA e1b18d57): Wave G — "laggy screens" perf sweep (3-agent audit → 5 fix waves). Origin: user reported persistent laggy screens "in many places" + delegated a full deep-dive. Three parallel audit agents (frontend renders / Supabase queries / Protocol A-L compliance) produced 62 findings; the highest-leverage closed in 5 commit waves: G1 (b79b106) killed 4 P0 render storms — club-dues-screen (unmemoized profileMap + inline renderItem re-rendering ~30 rows per realtime dues event), club-members-screen (4 bare O(N) derivations per sheet toggle → all useMemo, hoisted before early return), home temporal panes (HomeToday/Week/Month not React.memo'd → renderTab churn cascaded full re-renders), courts-directory (inline renderItem per search keystroke). G2 (2e6c6ae + migration 00197) bounded 3 unbounded SELECTs (club.getMembers +is_active+limit500, match.findBySession/findBySessions limit 200/2000, rsvp.findBySession limit 300) + throttled the auto-call-sweep cron from every-minute-full-scan to every-5-min with a 30s-quiet filter (the score-update trigger already handles the instant path). G3 (8942950) wrapped 10 adapter list sites in safeMap (Protocol K — one bad row no longer crashes the whole list query). G4 (b33f5a5) added Protocol H anticipatory prefetch (new prefetchPost helper + onPressIn wires on post/friend rows) + Protocol F placeholderData from list caches on useSession/usePostDetail/useVenue (no more skeleton-flash on list→detail nav). G5 (b33f5a5 + migration 00196) added 3 indexes for RLS subqueries (player_matchups.club_id, season_records.club_id, attendance_records.session_id). Migration count 195 → 197. Gotcha: a delegated agent's G3 safeMap edits + an earlier club-dues edit were silently reverted mid-session (IDE auto-discard); redone directly + committed immediately. Remaining audit findings (P1/P2/P3 — FTS index for club discovery, lazy Date-parse in mappers, FlashList estimatedItemSize, more bulk hooks) tracked but deferred.

Previous milestone (2026-05-19, commits 33bbb6f64453fb, OTA 0d3380e0): Pre-launch tightening + Path B rotation + Wave D hardening + Wave E v1.1 follow-throughs. Wave E (4 commits + OTA) closes the 4 highest-leverage v1.1 backlog items: E1 (fe60a94) restores name-bearing copy on the 2 signal types Wave D5 degraded — useProfilesByIds hook + resolveSignalBody profileLookup. E2 (64453fb + 00193) gates 4 weather edge functions on CRON_SECRET. E3 (595db3d + 00194) SET search_path = '' on 7 more SECDEF functions. E4 (f57c7ee) 17 money/auth/PII mutation tests. E5 (8c82d77 + 00195) cron-failure watchdog.

Previous milestone (2026-05-19, commits 33bbb6f80f0a7c, OTA 777b2d80): Pre-launch tightening + Path B rotation CLOSED + Wave D backend/frontend/infra hardening sweep. Day extended through three audit cycles: morning 4-agent audit (UX/error/security/perf) → Wave A (CRITICAL) + Wave B (HIGH) + Wave C (MEDIUM) → Path B service-key rotation closed end-to-end → late-afternoon 3-agent audit (backend security / frontend resilience / infra observability) → Wave D (3 CRITICAL + 5 HIGH + 5 MEDIUM closed in 4 commits). Wave D specifically closed 3 Sentry-blind error paths (auth bootstrap / realtime / backpressure), wrapped HomeTabIcon ErrorBoundary, added offline-queue to useToggleAutoRsvp, tightened RLS on player_matchups + season_records (cross-club leak), wired CI lint:pi-schema gate, added Sentry breadcrumbs to 4 critical mutations (GPS / dues / consent / delete-account), and added time-filter to recent_client_errors.

Audit headline findings (CRITICAL + worst HIGH):

  1. get_app_config SR-key leak (CRITICAL) — any authenticated user could call rpc('get_app_config', 'service_role_key') and receive the full bypass-RLS JWT. The function was PUBLIC EXECUTE with no principal filter. Fixed in 00184: REVOKE EXECUTE FROM PUBLIC + GRANT only to service_role. Service-role key rotation is a pending decision (see Next concrete steps).
  2. anonymize_withdrawn_profiles PIPA §21 violation (CRITICAL) — the UPDATE statement inside the nightly cron referenced three columns (bio, profile_image_url, phone_e164) that were renamed or removed in the 00161 migration. Every cron invocation had been throwing 42703 column not found silently since 00161 shipped — no withdrawn profile had been anonymized for ~6 weeks. Rewrote in 00184 against the actual profiles schema.
  3. pi_access_log write path missing (CRITICAL) — the table was created in 00167 but no SECDEF RPC wrote to it. support_get_user_errors was reading cross-user PII with no audit trail. KCC 안전성 확보조치 §29 requires ≥1-year audit log for PI access. Added record_pi_access SECDEF RPC in 00185 + audit-log writes in support_get_user_errors, get_user_consents admin branch, and export-user-data edge function. Sentry drops username / displayName from setSentryUser per PIPA §15.
  4. Under-14 birth_year escape via UPDATE (HIGH) — client-side under-14 block in onboarding could be bypassed by a direct API UPDATE. Server trigger reject_under14_birth_year (BEFORE INSERT/UPDATE, 00186) closes this.
  5. Soft-deleted profiles leaked across 7 read paths (HIGH)profile.supabase.ts leaderboard, member list, match-history opponent-name, and 4 other SELECT paths had no deleted_at IS NULL filter. Fixed with .is('deleted_at', null) sweep across all 7 adapter query sites in 00186.
  6. signInWithOAuth silent catch (HIGH) — mirrored the signInWithEmail bug fixed in the prior session. Silent catch masked OAuth login failures. Fixed: re-throws after logging.
  7. Protocol H prefetch missing on 25+ nav surfaces (HIGH perf) — 7 wired this round (clubs-my, friends, records-history, activity-matches, admin members + settings). Remaining ~18 deferred to Wave C.
  8. Profile screen duplicate hook observers (HIGH perf) — 4 query hooks were fired independently inside sub-sections AND at the screen root, doubling observer count and network calls. Fixed: hooks lifted to screen root; sub-sections receive data via Protocol B props.

Wave A — 3 CRITICAL fixes (commit 33bbb6f, migrations 00184 + 00185):

  • 00184: REVOKE get_app_config from PUBLIC; fix anonymize_withdrawn_profiles column references; drop dead dev_inspect_cron_status value_prefix leak.
  • 00185: pi_access_log INSERT policy + record_pi_access SECDEF RPC + audit-log writes in support_get_user_errors + get_user_consents admin branch + export-user-data edge function.
  • sentry.ts + sentry.web.ts: setSentryUser drops username / displayName (PIPA §15 — name fields are PI and must not be sent to a US processor without explicit purpose consent).
  • Production-env guards on seed-scenario, simulate-activity, grow-rd edge functions (were callable in production, now reject non-local invocations).

OTA: group b9cc97c7-ef3d-418d-b897-c872febef475 against 33bbb6f.

Wave B — 25 HIGH fixes across 3 commits:

B1 (commit 3e82e06, migration 00187):

  • ActivityIndicatorSkeletonHero + SkeletonCard on club-settings + club-dues (Protocol G).
  • null → 5× SkeletonRow during search re-fetch on add-friend + courts-directory.
  • Contact-screen FAQ moved to i18n (5 Q/A pairs, ko + en).
  • 7 Protocol H prefetch wires: clubs-my, friends, records-history, activity-matches, admin members + settings.
  • 00187: dropped dead submit_manner_tags plpgsql function (referenced v1 manner_tags columns, never callable since 00124).

B2 (commit 7f8a216, migration 00186):

  • reject_under14_birth_year BEFORE INSERT/UPDATE trigger on profiles.birth_year — server-side gate mirrors the client-side onboarding block.
  • delete_account_atomic SECDEF RPC — deleted_at + push_token clear + consent revocations + audit log in a single transaction. useDeleteAccount switches to this RPC.
  • profile.supabase.ts soft-delete sweep: .is('deleted_at', null) added to 7 read paths (leaderboard, member list, match-history, club profile, partner candidates, public profile, club-members).
  • useCancelRsvp gains offline-queue branch (RSVP_CANCEL_KEY + registered executor) — was the only queueable mutation without an offline path.
  • useRsvp throttle leak fixed: rollback patches now execute before throttled early-return.
  • signInWithOAuth catch re-throws (mirror of signInWithEmail fix).
  • replay-mutation-queue adds MAX_REPLAY_ATTEMPTS=5 + MAX_REPLAY_AGE=7d caps — prevents indefinite replay of stale entries.

B3 (commit 5b8bde3):

  • Parallel Promise.all in storage-migration.ts — saves 200–500ms on first-launch-after-upgrade (was sequential awaits).
  • Optimistic auth bootstrap in session-provider: reads cached profile via queryClient.getQueryData before deriveAuthState — saves 150–400ms cold-paint on warm cache.
  • Profile screen lifts 4 query hooks + passes via Protocol B prop to FriendsPreview / AchievementsPreview / Attendance / MannerTags sub-sections (eliminates duplicate observer cost).
  • club-members-screen.tsx: useMemo on Map/sections/grouped + useCallback on renderItem — FlashList no longer re-renders on every parent state tick.
  • applyPickupFilters: 7 chained .filter calls collapsed to single-pass predicate AND.
  • query-lazy-persister manifest type: string[]Set<string> (O(1) .has vs O(n) .includes per query observer mount).
  • ui/text.tsx: displaySm / displayMd / displayLg Text role variants (20/28, 28/36, 36/44 700) — fills the gap between pageTitle (24) and marketing hero copy.

OTA: group a825bae0-e81a-4fee-a666-a2a1516ed481 against 5b8bde3.

Migrations applied: 00184, 00185, 00186, 00187. Total: 187 migrations on live DB. Wave C (migration 00188 SECDEF search_path hardening + AbortSignal.timeout sweep + search-venue injection sanitization + redactPII helper + display-text role sweep) is in progress; will land separately.

Previous milestone (2026-05-19, commits e982f113f80935): Push pipeline diagnosis + fix — 33h of silent delivery failure traced and resolved. Origin: user reported "I can't log in" on the new 0.5.0 build. What appeared to be an auth issue expanded into a chain of five progressive diagnoses revealing that the entire push notification pipeline had been silently broken since the 0.5.0 build landed — signals were emitting, the cron was running, but no push ever reached the device. Five layers fixed in sequence:

  • e982f11 — surface signInWithEmail errors: SessionProvider.signInWithEmail was swallowing all errors in a silent catch block. Re-throwing immediately unlocked the rest of the diagnosis. Pattern lesson: auth mutation paths must never swallow errors — always re-throw after logging.
  • bd16b0f / ab722df — app.config.js function form for GOOGLE_SERVICES_JSON: app.jsonapp.config.js function form so GOOGLE_SERVICES_JSON env var is interpolated correctly at build time. Static app.json cannot reference env vars.
  • 120295f — polyfill-safe fetch timeout: send-push edge function used AbortSignal.any([...]). Hermes on RN 0.83 Android does not ship AbortSignal.any. Replaced with AbortController + setTimeout pattern that works on all JS runtimes. Pattern lesson: never use AbortSignal.any in Hermes-targeted code without a feature-detect.
  • 5131eb6 — correct DEV_USER_ID: hardcoded UUID (9427aabc-...) in diagnostic RPCs never matched the actual dev@twomore.test auth row (a45bfa0c-...). Pattern lesson: server-side constants referencing auth.users rows must come from a single migration or env var, never duplicated across SECDEF RPCs.
  • 5754a94 — dev_emit_test_push_signal unique dedup per tap: fixed immutability trigger by generating a unique dedup_key per tap (timestamp + random suffix) so repeated presses actually emit new signals.
  • bddead1 — dev test push severity=critical + 24h expiry: ensures the test signal bypasses the frequency cap so it is always picked up in the next cron tick.
  • 402139e — fix signals_pick_pushable ambiguous column (ROOT CAUSE, migration 00183): RETURNS TABLE declared a return column recipient_user_id; CTEs inside the function body also selected recipient_user_id from the signals table. PostgreSQL raised 42702 "column reference is ambiguous" at every call — the cron ran every 2 minutes and appeared healthy in cron.job_run_details but every PostgREST invocation returned HTTP 500 and dispatched zero pushes. Fix: qualify all internal CTE references with explicit table aliases (s.recipient_user_id). Pattern lesson: RETURNS TABLE column names that collide with underlying table column names produce ambiguous references inside CTE bodies. PostgreSQL does NOT raise this at CREATE OR REPLACE time — only at call time. Any RETURNS TABLE function SELECTing from tables sharing output column names must qualify ALL internal refs.
  • 3f80935 — generate icon assets from brand mark: scripts/generate-icon-assets.mjs generates four icon variants from the brand mark SVG: icon.png (1024×1024 app icon), adaptive-icon.png (Android adaptive 1024×1024 foreground), notification-icon.png (notification icon 96×96 white-on-transparent), favicon.png (web 48×48).

Diagnostic instrumentation: dev_inspect_cron_status SECDEF RPC (exposes cron.job + cron.job_run_details + net._http_response over PostgREST, gated to DEV_USER_ID) retained as a permanent production diagnostic — the only way to inspect cron execution history without direct DB access. Final state: pipeline verified end-to-end. dev@twomore.test received real production signals (club_member_joined, session_created) on the Android device after 00183 landed. Migrations 0017700183 all applied to live DB. Server-only — no OTA needed for the DB fixes; 120295f (AbortSignal fix) is the latest OTA (group 598ce80a-c1c9-497f-8309-69d0b0fd18fb).

Previous milestone (2026-05-18, commits a8ca8e4af57b65): Pre-build pack — native config + Tier 2 cleanups. Origin: user direction to pack as much as possible before triggering a new EAS Android build (required for 0.5.0 native changes). Three commits, no new native modules beyond the expo-notifications plugin already in 0.4.0: a8ca8e4 (version 0.4.0 → 0.5.0, bundle id com.ivorybridge.twomore, FCM googleServicesFile path + .gitignore entry, Android permissions explicit, iOS Korean localization strings, notification icon placeholder commit); aa72f0b (check-push-receipts edge function + 20-min pg_cron + migration 00175; export-user-data edge function updated to include user_consent rows — two distinct PIPA audit trails: signal_consent_log + user_consent); af57b65 (onboarding wizard useLogSignalConsentuseGrantConsent swap so user_consent is the single source of truth from signup; ConsentHistoryScreen purpose-label JOIN to purpose_catalog; policy URL constants swapped from static https:// links to twomore://legal/* in-app routes; migration 00176 updates stored URLs). PI-schema enforcement codified: scripts/lint-pi-schema-changes.mjs (14 SQL fixture tests, yarn lint:pi-schema, wired into pre-push + lint-staged). CLAUDE.md + AGENTS.md updated with notification classification rules, PIPA discipline, and new DATA-13, COMP-7, ARCH-9 constraints.

Older milestone (2026-05-18, commits f1b49fab0503dd): Consent system + notification hardening — research-driven launch sweep. Origin: user direction to build from best practices from the ground up before public launch. Three deep-research streams (Korean regulatory landscape, notification best practices, cross-jurisdictional consent architecture) ran first. Research found that Android channels were silently broken in production (only 'default' existed but send-push set 13 different category channelIds), confirmed ISO/IEC TS 27560:2023 as the authoritative consent-record schema, and identified the Jan-2025 KakaoPay/Apple §28-8 enforcement action (KRW 8.3B) as the cross-border transfer disclosure template. Nine commits across three waves:

Wave 1 — Push pipeline observability + PIPA consent schema (f1b49fa · 25e2f10 · ababbdc):

  • f1b49fa — Push receipt tracking: push_ticket_id + push_delivery_status columns (migration 00165); frequency caps in signals_pick_pushable() (system ≤10/day, session ≤5/day, social ≤3/day) + push_token_updated_at on profiles (migration 00166).
  • 25e2f10expo-notifications plugin in app.json; supabase adapter creates 13 Android channels matching the signal_category enum with correct importance tiers. Closes the silent-discard bug.
  • ababbdc — PIPA consent ledger: 7 migrations (00167–00173) — purpose_catalog (17 KR purposes), policy_versions, user_consent ledger (ISO/IEC TS 27560:2023–aligned), pi_access_log, marketing-data 2-year anonymisation cron, KR seed, 3 SECURITY DEFINER RPCs (grant_consent, revoke_consent, get_user_consents).

Wave 2 — Soft-prompt UX + consent domain layer + legal screens (ea15522 · 8f8c4f0 · 6b71765):

  • ea15522useNotificationSetup soft-prompt refactor: OS prompt no longer fires at cold boot; new PushPermissionSoftPromptSheet surfaces on first RSVP; 7-day token refresh; InteractionManager AppRouter-ready gate.
  • 8f8c4f0 — Consent domain entities (Purpose, PolicyVersion, UserConsent, UserConsentDisplay) + Zod schemas + getRequiredConsentsForJurisdiction('KR') matrix selector (4 mandatory, 3 optional) + CURRENT_POLICY_VERSIONS constant.
  • 6b71765 — PIPA §30 11-section privacy screen rewrite (statutory 개인정보 처리방침 structure); §28-8 cross-border transfer table (Supabase/Sentry/Expo Push as US entrustees); 13-article terms screen rewrite.

Wave 3 — Consent port/adapter + onboarding refactor + settings screens (9589323 · c1a128a · b0503dd):

  • 9589323ConsentRepositoryPort + supabase adapter + registry wire; useUserConsents / useConsentHistory query hooks; useGrantConsent / useRevokeConsent mutation hooks.
  • c1a128a — Onboarding wizard refactored 4 → 6 steps: new birth-year step with under-14 blocking gate; data-driven consent step from getRequiredConsentsForJurisdiction('KR'); migration 00174 adds profiles.birth_year.
  • b0503ddSettingsConsentManagementScreen + ConsentHistoryScreen + routes wiring; dual-write in handleTogglePush to both signal_consent_log + user_consent ledger.

OTA: group e216716a-95a3-49e8-8bdb-f943183ea25c published against b0503dd. Migrations 00165–00174 (10 total) applied to live DB. Runtime bumped to 0.4.0 (new EAS build required for the 13-channel Android manifest change — JS-layer changes OTA on 0.4.0).

Older milestone (2026-05-14, commit 30b2609): Mapper resilience (Protocol K) — safeMap boundary + safeParse fallbacks + DB cleanup. Origin: production crash "undefined is not a function at anonymous" when tapping the live banner. Diagnosis traced a ZodError thrown by eloTierSchema.parse('platinum') inside clubMapper.toDomain, called from .map(toDomain) over useMyClubs results. The Hermes "anonymous" frame was the arrow-function map callback. One legacy 7-tier DB value crashed the entire club list query. Systemic fix across 22 files:

  • safeMap(rows, mapper, context) helper in base.adapter.ts drops individual rows whose mapper throws, logs logger.warn + Sentry addBreadcrumb('adapter', 'safeMap:<context> dropped row'). Wired into 35 adapter list call sites across 11 adapter files. Single-row findById / .maybeSingle() paths deliberately NOT wrapped — failures there should propagate so screens render error states.
  • 9 .parse().safeParse(...).data ?? <fallback> conversions across 4 mapper files: club.mapper.ts (region, preferredFormat, memberComposition, primaryDay, primaryTimeSlot, minTier, maxTier), profile.mapper.ts (gender), session.mapper.ts (publicInviteApproval, visibility), post.mapper.ts (type). The remaining .parse() calls are required-no-default fields — safeMap drops those rows.
  • Tests updated for the 4 changed mappers — "throws on invalid X" → "coerces to null/default per Protocol K" (~10 cases). Test count holds at 1749.
  • Migration 00151 (00151_cleanup_legacy_tier_values.sql) sets any clubs.min_tier/max_tier value outside the 5-tier enum to NULL + adds CHECK constraints. Applied to live DB.
  • Phase 6c live-banner prefetch re-enabled in home-live-alert-banner.tsx — the crash cause is now eliminated at the mapper boundary.

OTA: group cad5f231-916f-442d-b0ca-8bf3aece0d4b published against 30b2609. Migration 00151 applied to live DB. No native modules — OTA-able on 0.3.0 runtime.

Older milestone (2026-05-14, commits e866840 · d7e5668): Foreground UX Protocols — screen-level readiness gating + IA move + anticipatory prefetch (Phase 6). Origin: user feedback after Phase 5 — "Sections of 기록 & 프로필 screens still load separately from other sections… the top ELO ranking banner always loads while the other sections lag behind… clicking the live banner card from the home tab also causes a lag." Their framing: "are we conflating network protocols (background) with UI/UX interactions (foreground)?" Yes. Four new Foreground UX Protocols (G/H/I/J) added to the Data rules alongside the existing Network Protocols A-F:

  • Protocol G — Screen-level readiness gating: Combine isFetched flags across critical-path queries at the screen root; render a unified skeleton until ALL settle. Never let individual sections cascade-reveal independently.
  • Protocol H — Anticipatory prefetch on onPressIn: Press intent fires ~100ms before navigation; destination's cache is warm by the time the transition completes.
  • Protocol I — Foreground vs background separation: Network protocols shape WHAT data is fetched. UX protocols shape WHAT THE USER SEES. Do not solve one with the other.
  • Protocol J — Information architecture: screens have one job: 프로필 = identity + social + settings. 기록 = analytics + records + leaderboards. Data that fits 기록 does not belong on 프로필.

Three concrete applications:

  • 6a — Screen-level readiness gating (packages/app/src/presentation/hooks/use-screen-ready.ts): New useScreenReady(queries[]) helper combines isFetched flags. Critical-path queries lifted to screen root on profile-screen.tsx + records-screen.tsx (TanStack dedups so sub-sections hit warm cache); unified SkeletonHero + 3× SkeletonCard renders until everything settles. Before: cascading reveals as each section's independent query resolved. After: single skeleton-then-content commit. usePlayerStatsDetail extended to expose isFetched (was isLoading-only).
  • 6b — PersonalRecordsSection IA move: Moved from packages/features/profile/src/personal-records-section.tsxpackages/features/records/src/personal-records-section.tsx. Now reads matches as a prop (not userId + useMatchHistory internally), so records-screen passes scopedMatches — biggest win / streak / busiest week / best ELO swing now respect the active scope chip (우리 클럽 / 친구 / 지역 / 전국). Pure Protocol E selector pattern. Before: scope-blind on 프로필. After: scope-aware on 기록.
  • 6c — Anticipatory prefetch for spectator scorecard (packages/app/src/presentation/cache/prefetch.ts): New prefetchSpectatorScorecard(client, sessionId) fires session + matches + rsvps + clubProfiles in parallel (clubProfiles chains after session resolves for clubId). Wired onto HomeLiveAlertBanner via onPressIn. Before: cold-cache mount lag on tap. After: instant paint from cache.

13 files changed, 409 insertions(+), 158 deletions(-). No migrations — all OTA-able on 0.3.0 runtime.

All workspace gates green: typecheck clean, lint:strict 0/0, 1748 tests pass across 81 suites.

Older milestone (2026-05-14, commits e92e1c0 · 4250049 · d464354): Network protocols + N+1 sweep (Phase 5 series). Audit origin: chip-tap + scroll lag was still present after LiveTab virtualization — an Explore-agent trace found ~120 parallel HTTP requests firing on a 40-match LIVE scorecard (40 × useMatchScoreCall + cross-session realtime noise + broad invalidation refetching the full match list). The same pattern existed across SessionCard (~150 req per 50-session feed) and ChallengeCard. The Phase 5 series formalizes the canonical fix into six composable Network Protocols (A-F) in the ### Network protocols Data rule subsection, then applies them everywhere. No migrations added — all OTA-able on 0.3.0 runtime.

  • Phase 5/0 + 5a + 5e (e92e1c0, OTA group 0884812e-2908-42b9-a7f5-9436d0912e9e) — Three pieces bundled: (1) Six Network Protocols (A-F) codified in CLAUDE.md Data rules. (2) 5a: Realtime match score-call subscription was unfiltered, firing for every session on the channel, then broadly invalidating matchKeys.bySession(sessionId) on every event. Now filters client-side by membership in the session's matchId set (read from cache) and drops the broad invalidation — per Protocol C (targeted invalidation) + Protocol E (client-side membership guard). (3) 5e: Foreground catch-up swapped refetchQueries({type:'active'})invalidateQueries({type:'active'}) so queries past staleTime refetch while fresh ones stay — per Protocol D (staleTime respect). Before: every foreground resume refetched all active queries unconditionally.
  • Phase 5b + 5c (4250049, OTA group cefc9285-4c5f-462f-894a-32f2c4ab86e3) — Two fan-out fixes bundled: (1) 5b (SessionCard fan-out): New bulk hooks useSessionsRsvps(sessionIds[]) + useMySessionPayments(userId) (port methods findBySessionIds/findByUser, adapters with bounded LIMITs, stable sorted-joined query keys, realtime invalidation extended to touch bulk keys). SessionCard + SessionMetaSection accept optional myRsvp/myPayment/rsvps props with internal hooks gated as fallbacks. 7 list parents migrated: home-today/week/month-view, club-detail/home-tab, club-sessions-screen, activity-screen, activity-pickups-screen, activity-upcoming-sessions-screen. A 50-session feed now costs 3 HTTP requests instead of ~150 — per Protocol A (bulk) + Protocol B (prop-drill). (2) 5c (LiveMatchRow fan-out): New bulk hook useSessionMatchScoreCalls(sessionId) (port findActiveBySession, two-step SELECT, Map<matchId, MatchScoreCall> via select). LiveTab fires once, passes activeCall prop to LiveMatchRow. Per-match useMatchScoreCall becomes a fallback. A 40-match scorecard now costs 1 HTTP request instead of ~40. ScoreCallSheet's per-match hook left alone (single-instance, not fan-out). Per Protocol A + B.
  • Phase 5d (d464354, OTA group 6bd8b5d6-87f7-4fd6-ad56-a371a38f774e)ChallengeCard fan-out fix: New bulk hook useClubsByIds(ids[]) wrapping the existing clubs.findByIds port method. ClubChallengesScreen collects every unique club id (viewer + opponents) once, fires the bulk hook, passes viewerClub + opponentClub as props. Per Protocol A + B.

Older milestone (2026-05-14, commits d68e7a8 · da6def4 · 6838c5d · 3c02074): UI consistency sweep — ConfirmSheet primitive + DataSection primitive + LiveTab virtualization + full Alert.alert migration (4 phases). All OTA-able; no migrations, no native modules.

  • Phase 1 (d68e7a8, OTA group e8e9a8da-ec38-40a2-810f-b87fd7d98903) — New ConfirmSheet primitive in @twomore/ui (packages/ui/src/confirm-sheet.tsx). Tamagui Sheet at snapPoints=[35], imperative useConfirm() hook, singleton state, hardware-back + swipe-down dismiss, button-style map cancel|destructive|primary|default → outline/destructive/primary. ConfirmProvider mounted in apps/mobile/app/_layout.tsx between SessionProvider and AuthGate. Top 5 Alert.alert call sites migrated: delete-account-screen, profile-screen, session-detail-screen, draws-row, live-row. Replaces native Alert.alert which ignores design tokens and renders as an OS-styled centered dialog.
  • Phase 2 (da6def4, OTA group e453ba1a-6867-4d42-988a-6167550b204b) — New DataSection primitive in @twomore/ui (packages/ui/src/data-section.tsx). Encodes the loading→empty→content flow. Two modes: default (renders a SkeletonRow placeholder during isLoading) and hideWhenAbsent (returns null while loading AND when empty — for sections that should only appear once earned). Optional title prop wraps in SectionBlock. Migrated 5 profile preview sections: FriendsPreviewSection (default + empty Card), AchievementsPreviewSection (default + empty Pressable Card), PersonalRecordsSection (hideWhenAbsent), AttendanceSection (hideWhenAbsent), MannerTagsSection (default + flat empty Card). profile-screen.tsx drops the !isLoadingProfile gate on these 5 sections so they mount immediately when userId is available — eliminates the "section pops in after the rest of the screen finishes painting" anti-pattern.
  • Phase 3a (6838c5d, OTA group d680935c-00f4-43bf-ba4b-5fc4c7833d52) — Spectator scorecard's LIVE tab (packages/features/sessions/src/scorecard/live-tab.tsx) virtualized via FeedList (FlashList). Off-screen LiveMatchRow components (each mounting ScoreEditSheet + ScoreCallSheet + MatchProfilesSheet = ~120 sheet components for a 40-match session) no longer mount up-front. Primary fix for chip-tap + scroll lag on the spectator scorecard. Each tab in spectator-scorecard.tsx now owns its own scroll/list container (LiveTab → FeedList; DrawsTab + RoundsTab → ScrollView each), removing the outer ScrollView wrapper from renderTab.
  • Phase 4 (3c02074, OTA group 0e7f1e52-10cf-47a6-a04b-3f591cfbf918) — Remaining 25 Alert.alert sites across 13 files migrated to useConfirm: club-settings-screen, create-club-screen, create-club-step1, change-password-screen, edit-profile-screen, friends-screen, public-profile-screen, settings-screen, add-round-sheet, create-session-screen, edit-session-sheet, match-board-screen, participants-sheet. Native Alert.alert is now gone from all feature code and app routes. Style mapping: cancel→cancel, destructive→destructive, default|omitted→primary. Single-button info alerts use buttons:[{text:t().common.ok}].

Older milestone (2026-05-10, commit ea2b352): Autonomous overnight run — error visibility (L4 → L1), spacing/Pressable/status-badge enforcement, realtime Phase A-C, server status auto-transitions, session-card excitement layers, OTA pipeline foundational fix, seed-scenario time/status fixes, box-dimension constants + 56-test backfill (~50 commits). A long batch of foundational and UX work, grouped by theme:

  • Error visibility (L4 → L1)4b339ce in-app selectable stack + global handler · dc9071a MMKV-backed crash log surviving process death · 7948728 ErrorBoundary global reporter wired so Sentry actually receives crashes · 631eb3b @sentry/react-native Expo config plugin (native linking) · 93b2c66 correct org/project slugs · 25d6806 + 1a17f9c missing notifications + signals i18n namespaces (root cause of two alert-screen crashes) · 693f750 hardened 9 crypto.randomUUID call sites against bare-reference throw · 76b333d + b4c7232 Date-rehydration safety after persisted-cache thaw + arch-test that prevents future drift (every timestamp field is Date everywhere) · 3119af3 ErrorBoundary at the home-route layout level. Dev-panel f78cb7b + 0773b8e MMKV probe buttons surfacing the real exception path; e7c3bec always-show crash-log section + 테스트 button. Result: production crashes now reach Sentry with full stack traces and survive process death.
  • Dev-panel polish + scenarios8646f24 scrollable 3-subscreen layout + Switch + per-step progress · c03c7d7 full i18n migration · a7632eb UX polish (search, dedupe, persistence, layer icon, a11y) · fe1064e unblock chip taps + drop search bar · 42f9a81 four Phase 6 scenarios (attendance / manner / trust / GPS) · 0b2a0e4 settings toggle to enable on any account · 5ec59e5 manner_tags v1→v2 schema-drift fix in generateMannerTags. Then 8197dcb audited every home_* scenario and fixed 6 confirmed time/status mismatches (the headline was seedHomeMatchDayLive reusing seedMatchDayBase's status='open' session under an in_progress match — impossible state, made the home view classifier render "starts in 1.5h" instead of LIVE).
  • Idempotency + GPS hardening919cdaf + 04d6dee thread idempotency_key through session.create + posts.create (defense-in-depth — both were fire-and-replay without dedup) · 835b709 structured HINT fields on 00131 GPS check-in RPC + threading through DomainError so the client prefers error.hint over substring-matching English RPC text. Migrations 00136-00138 ship server-side with this work.
  • Spacing system enforcement5af4ad3 new ESLint rule @twomore/no-padding-top-in-features (directional spacing flows DOWN — first-child top spacing belongs to the container, not the child; the "Lobotomized Owl" pattern made mechanical) · 21ab870 full $N token-system enforcement + inter-card gap sweep · 9080f67 chip→content + temporal-pane card gaps tightened to $2 · 4baf337 codified spacing-ownership rule (when two adjacent containers can each contribute padding to a gap, exactly ONE owns it — convention: the upstream chrome owns its paddingBottom, the downstream content does NOT add a matching paddingTop) · 568688d + 56dcdb3 deleted MotionReveal / MotionStaggerItem wrapper components (the wrapper participated in parent gap calculation as the measured child AND the slide-up y transient created a visible offset on first paint that didn't appear on bare-card siblings — structural asymmetry, not perception bug). Entrance animations now live on the component itself via Tamagui enterStyle + animation. ea2b352 adds named box-dimension constants (AVATAR_SIZE.XS..2XL + DOT_SIZE.XS/SM) at packages/ui/src/box-sizes.ts, sweeping 13 feature files' worth of width={32} height={32} magic numbers via Sonnet agent.
  • Pressable primitive + sweepa15eae7 + da6c507 introduce Pressable from @twomore/ui as the canonical clickable primitive for everything that isn't a Button/PillNav/SegmentedTabs (cards, rows, chips, icons, FABs); 28 consumers swept, MotionPressable deleted, ESLint rule blocks raw pressStyle={{}} on styled stacks in packages/features/**. Encodes: TS-required accessibilityLabel, per-variant scale + opacity press feedback on the UI thread (Reanimated worklet), reduced-motion fallback, web focus ring, standardized disabled (0.5 + non-interactive + busy a11y), loading prop.
  • Status badge canonical system3eb8055 introduces 5 status-badge components (SessionStatusBadge, MatchStatusBadge, RsvpStatusBadge, DuesStatusBadge, ScoreStatusBadge) with a single source-of-truth status→variant table at presentation/components/status/status-badges.tsx. 97ce80f extracts variant maps to a sibling pure-data file status-variant-maps.ts so test files can verify enum coverage + canonical mappings + cross-table invariants without pulling Reanimated through @twomore/ui. CLAUDE.md Components rule names the 8 Badge variants (live/success/accent/warning/info/neutral/muted/premium) and forbids ad-hoc <Badge variant="..." label="..." /> per screen.
  • Realtime Phase A-C297cb61 adds 00138_realtime_publication_for_core_tables.sql (publishes sessions / matches / rsvps / scores to the realtime stream — they were silently absent so prior useXxxRealtime hooks were no-ops at the wire) + wires useSessionRealtime to detail screen · ee95906 user-scoped useUserSessionsRealtime hook closes the home wiring gap · 4025ed8 Phase C extends the same pattern to RSVPs and matches user-scoped. Channels are ref-counted in realtime.supabase.ts so multiple useXxxRealtime(id) calls with the same id share one subscription.
  • Server status auto-transitions5b3c122 adds 00137_session_status_auto_transitions.sql: hourly cron at :15 calls private.auto_advance_session_statuses() to close the drift loop. Rules: in_progress past endTime + 8hcompleted (tennis sessions can legitimately overrun endTime — the clock is not authoritative); open|locked past endTime + 4hcompleted (with RSVPs) or cancelled (none); cascade match→session completion when every match in an in_progress session reaches a terminal state. Per-match cascade fires immediately via the cascade_match_completion_to_session trigger. CLAUDE.md "Session status invariants" rule documents this.
  • Session-card excitement layerse860c74 RSVP fill bar + friends-going meta + size=default on home (visual urgency without clutter) · 88c88f2 weather emoji (☀️ ⛅ ☁️ 🌧️ ❄️ ⛈️) replace Lucide cloud-family icons (instant recognition at 14px) · 13bd8f6 temperature inline + first-pass proximity-aware date column · 0ace49d reverts the proximity-aware date column (user feedback: "more confusing than informative") and keeps proximity as a separate accent-tinted urgency dot · 4c45320 per-second ticking countdown via useNowTicking (gated setInterval, only mounts when the session is in the proximity window) + PulseDot for live sessions matching the live banner.
  • OTA pipeline foundational fixf166109 adds AppState=active foreground re-check (>5 min absence triggers Updates.checkForUpdateAsync), routes failures to Sentry + captureCriticalError, and adds dev-panel diagnostic showing runtime / updateId / channel / isEnabled. Closes the "I don't see the update" loop where the prior implementation only checked at cold start.
  • Test coverage backfill97ce80f covers formatProximateSessionTime + isInTickingWindow (22 tests), useNowTicking (6 tests with jest.useFakeTimers()), and the 5 status-badge variant maps (30 tests). 1149 → 1205 tests across 55 → 57 suites. Status-badge tests required extracting the variant maps out of the React component file into pure-data sibling so the test environment didn't pull Reanimated through the @twomore/ui re-export chain.

All workspace gates green: typecheck clean, lint:strict 0/0, 1205 tests pass across 57 suites. Migrations 00135-00138 all applied to live DB. No native module additions since 0.3.0 — everything in this batch is OTA-able.

Older milestone (2026-05-07, commit 5b4910c): Phase 3 filter persistence + type-safety sweep + architectural audit fixes (6 commits). Two Phase 3 pickup-filter commits land first: 72331b8 wires GPS-default region into pickup-filter parents via a new usePickupFiltersWithGpsDefault shared hook that wraps usePickupFilters + useGpsDefaultRegion — callers get a pre-populated region chip with no extra query work; 26eac81 auto-saves the regions field to user_preferences.filter_prefs (JSONB, no migration needed) whenever the user changes it. Tri-state semantics: undefined = use GPS default, [] = user explicitly cleared, [...] = saved preference wins. Two type-safety commits clean up the last any-escape hatches across adapters: 7196b40 centralises a LucideIconType exported from @twomore/ui/icon.tsx via (typeof LucideIcons)['Star'] type extraction — 6 call sites that previously used ComponentType<any> + eslint-disable now route through it, with the sole remaining cast (ComponentProps<LucideIconType>['color']) being type-checked; b798dd4 makes applyCursorPagination generic over T with an internal CursorChainable structural type, and derives the realtime.supabase.ts ChannelEntry type from ReturnType<typeof supabase.channel> instead of reaching into the transitive @supabase/realtime-js dep — 12 any-disables eliminated total across both commits, 0 added. An Explore-agent architectural deep-dive (04280cf + 5b4910c) then surfaced three classes of issues: (1) four trusted-write RPCs (verify_match_score, increment_challenge, update_member_role, update_player_progression) were declared private-schema only, so every client supabase.rpc() call was returning PGRST202 — the live-broken feature was useUpdateMemberRole on the club-members screen; migration 00135_public_rpc_wrappers.sql adds public-schema wrappers using the same pattern as 00123; (2) two hand-built queryKey arrays (useProfileSearch, usePartnerCandidates) weren't registered in query-keys.ts — added profileKeys.search() and playerMatchupKeys.partnerCandidates() factories plus correct invalidation; (3) three unbounded SELECTs bounded: rsvps.findAllByUser → 90-day window + limit(500); profile.findByClub and post.findReactionsByPost → limit(500). Both pending migrations (00123 + 00135) applied to the live DB on 2026-05-07 via npx supabase db push. No manual DB action remaining. OTA-ready (SQL migration commit doesn't OTA).

Older milestone (2026-05-05, commit 744391f): Phase 5/2B + 5/2C + 5/2D — tier-promotion + stat-card + leaderboard share. Three remaining Phase 5/2 share targets shipped together; Phase 5/2 is now CLOSED on the client. 5/2B (tier promotion): new useTierUpDetection(userId) hook composes useEloHistory + useProfile, ref-tracks the last-observed eloHistory[0].id, fires exactly once per actual tier crossing (promotion only — demotions don't get celebrated; coexists with the existing useTierCelebration confetti system, orthogonal triggers). New TierPromotionSheet (Tamagui Sheet at 40% snap) auto-opens at the match-board screen root after a score submit causes a tier crossing — celebratory headline + 공유하기 / 다음에 CTAs. buildTierPromotionShareCard(profile, newTier) + useShareTierPromotion(). New /p/<userId> deep link kind (mobile redirects to profile detail, web placeholder). 5/2C (성적표): buildStatCardShareCard(profile, newTier, opts) with configuration-driven optional lines (🎾 totalMatches·winRate%, 🤝 매너 태그 ×N self-omit when their data isn't loaded). Profile screen Share2 icon visible only on own profile, pulls stats from existing usePlayerStats + useMannerTagCounts. Reuses the /p/<userId> deep link. 5/2D (leaderboard): buildLeaderboardShareCard(club, entries, opts) produces a top-5 ranked list (configurable via topN, optional periodLabel reserved for future weekly/monthly toggle — ships as "올타임" default). Records-leaderboard-screen Share2 icon on the club-scope tab. New /lb/<clubId> deep link kind. Shared: 14 new t().share.* i18n keys (ko + en); ShareKind extended with 'stat-card' (existing: session, match, club, tier-promotion, leaderboard); 4 new hooks + 3 new composers exported from @twomore/app; CLAUDE.md Components rules updated to name all 5 composers + 5 hooks. Korean term note: leaderboard copy uses 순위 (not 랭킹) — the project's i18n test enforces 랭킹 as a forbidden term. Out of scope (gated on external resources): KakaoTalk SDK / template integration → Phase 5/3 when credentials arrive; Open Graph rich previews on web → Phase 5/1B; universal-link entitlements → Phase 5/1C (gated on twomoreten.is domain ownership). Phase 5 client-side is now ~90% complete; remaining work is all external-dependency gated. No migrations, no native modules — OTA-ready.

Older milestone (2026-05-05, commit 43fc4f2): Phase 5/2A — match result share via OS share sheet. Second slice of Phase 5 social sharing. Builds on the share infrastructure from Phase 5/1A — no new ports or adapters, just a new card composer + hook + integration point + deep-link route. What ships: buildMatchShareCard(match, club, profileMap, opts) extends presentation/utils/share-cards.ts (Korean card body matches the roadmap spec, simplified — headline → score line with optional tiebreak suffix → viewer ELO delta when available → club name); the viewer's ELO delta is the only personalized field, wired from the existing myEloDeltas map exposed by useMatchBoard(). New useShareMatch() hook (same shape as useShareSession). Share2 icon-only button mounted in the MatchCard header row, visible only when isCompleted && !isEditing. New port method matches.findById(matchId) on MatchRepositoryPort (supabase + mock adapters wire it); matchKeys.byId(matchId) cache key + useMatchOnly(matchId) query hook (@freshness stable) for the deep-link receiver. Mobile route apps/mobile/app/m/[matchId].tsx resolves match → session → routes.clubSession() Redirect. Web route apps/web/app/m/[matchId]/page.tsx is a minimal placeholder. 5 new t().share.* i18n keys (matchShareTitle, matchResultHeadline, matchEloDelta, matchTiebreakSuffix, matchNotFound). CLAUDE.md Components rules extended — share-card + share-hook rules now name both composers/hooks. Deferred: tier-change line on the match card (needs before/after Elo lookup); manner-tag counts on the card (needs aggregated query); auto-prompt sheet after score submit (separate UX slice). Out of scope: tier promotion + leaderboard + stat-card share (Phase 5/2B-D); KakaoTalk SDK (Phase 5/3). No migrations, no native modules — OTA-ready.

Older milestone (2026-05-05, commit e80e003): Phase 5/1A — share infrastructure + session-invite share via OS share sheet. First slice of Phase 5 (Social Sharing — the growth engine the roadmap calls "zero revenue but infinite ROI"). Architecture is Kakao-swap-ready: the share port ships now using RN's built-in Share.share() (no new native module), the Kakao adapter swaps in by changing one registry line when credentials arrive. Hex layers: new ShareContent domain entity (kind | title | message | url); new ShareServicePort with single share(content) method that resolves with { outcome, channel? } and never throws; native adapter wraps RN Share.share(); web adapter wraps navigator.share() with clipboard fallback; mock adapter for tests; registry wired (share top-level export). Card composer at presentation/utils/share-cards.tsbuildSessionShareCard(session, club, { rsvpCount }) produces the Korean session card the roadmap specced (📅 date+time, 📍 location+district, 🎾 format+style, 👥 spots remaining, 💰 cost per person, club attribution); configuration-driven so new session fields auto-populate every future share without changing call sites. SHARE_WEB_BASE_URL = 'https://twomoreten.is' constant, overridable per-call. Hook + UX: useShareSession() returns a callback (session, club, opts) => Promise<void> that builds the card + dispatches via @/registry; surfaces a toast on outcome='unsupported', silent on success. Share2 icon mounted in DetailShell.headerRight of session-detail screen — visible to anyone (sharing IS the growth function). Universal-link routing (no Apple/Android entitlement work yet — that's Phase 5/1C): mobile route at apps/mobile/app/s/[sessionId].tsx resolves clubId then Redirects to routes.clubSession(); web route at apps/web/app/s/[sessionId]/page.tsx is a minimal placeholder (title + "앱에서 열기" CTA → twomore://s/<id>). 11 new t().share.* i18n keys (ko + en). 2 new CLAUDE.md Components rules codify the share-card composer pattern + the useShareXxx hook pattern. Out of scope (deferred): KakaoTalk SDK / template integration → Phase 5/3 when credentials arrive (the share port shape will accept it as a swap-in); match-result + tier-promotion + leaderboard + stat-card share targets → separate composer additions; Open Graph rich previews on web landing → Phase 5/1B; apple-app-site-association / assetlinks.json entitlements → Phase 5/1C. No migrations, no native modules — OTA-ready.

Older milestone (2026-05-05): ARCH-8 ESLint rule shipped + useGpsCheckin routed through the AttendanceRecord port. Closes the deferred born-mechanical guard from the 2026-05-04 audit and the one true ARCH-8 violation that slipped in afterward via Phase 6 GPS work (20822cf). Three pieces: (1) port + adapter extension — added checkInGps(sessionId, lat, lng) to AttendanceRecordRepositoryPort + supabase adapter; the existing confirm_attendance/excuse_attendance_record RPCs already followed the same shape so the addition is a one-method extension, not a redesign. (2) useGpsCheckin rewire — drops the getSupabase() direct import, calls attendanceRecords.checkInGps() via @/registry, wraps the call in try/catch that re-throws the friendly-mapped Korean error string with { cause: err } for chain preservation. The existing friendlyServerError substring regex still matches because handleError's wrapped DomainError message is t().adapterErrors.databaseError(<raw>) — the raw RPC text is embedded verbatim inside the wrapped string, so .toLowerCase().includes('too far') etc. still hit. (3) ESLint rule@twomore/no-direct-supabase-in-presentation (plugin v0.4.0 → v0.5.0): scoped to packages/app/src/presentation/hooks/**, flags any import whose source.value ends in /adapters/supabase/client; configurable allowedPaths substring exempts use-realtime-app-state.ts (the substrate AppState gate that owns realtime.connect/disconnect — no port abstraction exists or makes sense for that). 11 new test cases (7 valid / 4 invalid) covering scope, allowlist, relative-path imports, and Windows backslash normalization. AGENTS.md ARCH-8 graduates DOC (planned: ESLint no-direct-supabase-in-presentation)ESLint @twomore/no-direct-supabase-in-presentation. Mechanical-enforcement count moves 17 → 18 of 39. Migration 00123_public_submit_match_score_idempotent.sql confirmed already applied to remote (CLAUDE.md was stale on that point); migrations 00114-00133 in sync. No native modules, no migrations, OTA-ready.

Older milestone (2026-05-04, commit e30ca2d): Monolith decomposition — achievement entity split + player-stats helpers extracted. Surfaced by the post-cleanup monolith audit. Two small refactors, no behavior change. Achievement split (836 → 172 lines, -79%): the 55-entry ACHIEVEMENT_META catalog + display orderings (ACHIEVEMENT_CATEGORY_ORDER, CATEGORY_TO_DISPLAY_GROUP, DISPLAY_GROUP_ORDER) extracted from domain/entities/achievement.entity.ts into a sibling achievement.catalog.ts (690 lines, pure data table). The entity file now holds only types + Zod schemas (the contract); the catalog holds the data. Public API unchanged — domain/index.ts re-exports both files via the same names consumers already use, so no call-site edits needed (verified via grep across packages/ + apps/). Player-stats helpers extraction (432 → 293 lines, -32%): 6 pure compute helpers (computeWeeksActive, computeMonthlyParticipation, computeVenueFrequency, computeFormatBreakdown, computePartnerStats, computeTotalPlayTimeHours) extracted from presentation/hooks/queries/use-player-stats-detail.ts into presentation/utils/player-stats.ts (188 lines, pure functions + their result types — FormatStats, MonthlyCount, PartnerStats, VenueCount). Located under presentation/utils/ (not domain/utils/) because the helpers depend on MatchHistoryEntry from the use-match-history hook — moving them to domain would cross the hex-arch direction line. Result types re-exported from the hook for back-compat. The hook now composes these with the i18n-aware deriveStats function that builds the personalBests array. Helpers are now unit-testable in isolation. Remaining monoliths (deferred — split when next touched, not as standalone refactor): create-session-screen.tsx (1875), match-board-screen.tsx (1800), create-club-screen.tsx (1602), session-detail-screen.tsx (979), records-leaderboard-screen.tsx (883). No migrations, no native modules — OTA-ready.

Older milestone (2026-05-04, commits d800eb8 + 7a5cf43): Audit-cleanup sweep — every deferred issue closed, ratchet → 0 across all rules. End-to-end follow-up to the post-batch-6 architecture audit. Two commits, no behavior change. d800eb8 (audit High fix): the new useMemberStrikeRecords query hook bypassed the port via getSupabase() direct import; routed through the AttendanceRecord port + adapter (added findStrikesByMember(userId, clubId, limit) to the port, moved MemberStrikeRecord to domain/entities/trust.entity.ts). 5 files. 7a5cf43 (full sweep): four independent work units in one commit — (1) AttendanceRecord port expansion — the 4 remaining hooks that bypassed the port now route through attendanceRecords from @/registry: useAttendanceSummary → getSummaryForMember(), useClubAttendanceSummary → findByClub(), useConfirmAttendance → confirmAttendance(), useExcuseAttendance → excuseRecord(). The deriveAttendanceSummary helper relocated from the hook into the supabase adapter where it belongs. (2) ClientError port expansion — pre-existing port had only log(); added findRecent(limit) → ClientErrorRecord[] for the dev-panel error viewer; new ClientErrorRecord domain entity at domain/entities/client-error.entity.ts; useMyClientErrors now calls clientErrors.findRecent(limit) via registry. (3) Tamagui Sheet.ScrollView migrationmanner-tags-sheet and attendance-roster-sheet swapped RN's bare ScrollView (with the inline style={{ flex: 1 }} Tamagui-rule violation) for Tamagui's Sheet.ScrollView, which integrates with the sheet's drag gesture. (4) createMutationHook factory enhancement — accepts an optional mutationKey: QueryKey config and threads it to useMutation; the factory's exemption is dropped from eslint.ratchet.config.mjs. Total ratchet across the entire codebase is now 0 waivers across 0 rules — every ESLint rule enforces on every file. The ratchet file is documented as zero-state; re-introducing a waiver requires explicit decision + PR review. 15 files in 7a5cf43, 1 new domain entity. No migrations, no native modules — OTA-ready.

Older milestone (2026-05-04, commit d220ea3): Phase 14f freshness ratchet CLEARED + DATA-2 mutation-key shrunk 8 → 1. Two-rule sweep that finishes the ratchet shrink Phase 14f has been chipping at all night. Freshness contract (DATA-10): the remaining 28 query-hook ratchet waivers all received @freshness <bucket> JSDoc — use-activity-data, use-challenge-scoreboard, use-club-analytics, use-club-events, use-club-stats, use-club-types, use-guest-applications, use-head-to-head, use-holidays, use-home-signals, use-in-club-ranking, use-inter-club-challenges, use-match-board, use-member-availability, use-participation-milestones, use-partner-candidates, use-player-matchups, use-player-stats-detail, use-practice-log, use-profile-search, use-progression, use-ranking-data, use-reactions, use-season-standings, use-session-feedback, use-session-payments, use-session-templates, use-session-weather, use-subscription, use-tournament, use-weather. Composition hooks (useMatchBoard, useRankingData, useActivityData, usePlayerStatsDetail) annotated with the bucket of their slowest dependency. The freshness rule now enforces every new hook from day one — the ratchet block is empty. Mutation-key contract (DATA-2): the 7 raw useMutation call sites that lacked a key now declare one — useToggleAutoRsvp → ['club','toggle-auto-rsvp'], useToggleReaction → ['reaction','toggle'], useUpdateDuesStatus → ['dues','update-status'], useUpdateMemberRole → ['club','update-member-role'], useUpdatePreferences → ['preferences','update'], useVotePoll → ['poll','vote'], useRsvp / useCancelRsvp → ['rsvp','upsert'] / ['rsvp','cancel']. Only create-mutation-hook.ts (the generic factory) stays in the ratchet — it has no domain-specific key to declare. Bonus hardening: useConfirmAttendance invalidation broadened (byMember + bySession + strikeRecords) so 총무 mark-no-show updates flow into every cached view of that session. Total ratchet: 36 → 1 waiver across 2 rules. No migrations, no native modules — OTA-ready.

Older milestone (2026-05-04, commit 90b0a1e): Phase 6 polish — per-record strike excuse + GPS hardening + Phase 14f batches 4+5. Three independent improvements landed together in one OTA-friendly commit (no migrations, no native modules). Per-record strike list in ExcuseSheet (closes the per-record half of Part 3b/iii): the 총무 dashboard's ExcuseSheet now lists every strike-bearing record for the selected member (up to 20, newest first) instead of just the most recent. Each row shows session title/date + strike-type label (불참 / 지각 취소) + a per-row "사유 인정" button that fires the existing excuse_attendance_record RPC. New useMemberStrikeRecords(userId, clubId, enabled) query hook (inline supabase query, joins sessions(title, date), filters strike_value > 0 AND final_status != 'excused', @freshness frequent); new attendanceKeys.strikeRecords(userId, clubId) cache key; useExcuseAttendance invalidation broadened to also clear byMember + strikeRecords so the sheet shrinks live as 총무 excuses rows. 4 new t().trust.* i18n keys (ko + en). GPS check-in hardening (Phase 6 Part 4b/ii polish): useGpsCheckin gains a 12s Promise.race timeout around Location.getCurrentPositionAsync (expo-location's API has no native timeout — spinner could hang forever on poor signal); new friendlyServerError() mapper translates the RPC's plain-English RAISE strings ("Too far", "window is closed", "no registered court venue", "must be RSVP'd") into Korean i18n strings so users see actionable copy instead of raw server text. 3 new t().trust.* keys (gpsCheckinWindowClosed / gpsCheckinNotRsvpd / gpsCheckinTimeout). Phase 14f freshness backfill batches 4 + 5: 15 more query hooks annotated with @freshness <bucket> JSDoc (use-achievements, use-court-reviews, use-preferences, use-ranking, use-recent-venues, use-regional-leaderboard, use-regional-ranking, use-session-summary, use-attendance, use-club-alerts, use-club-events, use-club-stats, use-club-types, use-holidays, use-my-client-errors). Ratchet shrinks 42 → 28 freshness waivers; total ratchet now 36 waivers across 2 rules (8 mutation-key untouched). Composition hooks (use-activity-data) deferred for separate judgment. No user action needed (no migrations). OTA picks up all three changes in the next yarn ota:preview.

Older milestone (2026-05-04, commits 18f60f7 + 20822cf): Phase 6 closing pass — strike notifications + server-side cooldown + GPS client (runtime 0.2.0 → 0.3.0). Three-piece final closure of Phase 6 follow-ups. 18f60f7 (Part 3b/iii server) — two server-only migrations. 00132_attendance_strike_notifications.sql wires the spec's "Consequences (Private, Graduated)" ladder into the existing private.create_notification pipeline (00071): when strikesInWindow crosses 1 → 'attendance:gentle_nudge' to the member; 2 → 'attendance:warning_cooldown' to the member; 3+ → 'attendance:flagged_admin' to every owner/admin of the club (one notification per admin). Idempotent via a (user, session, threshold) dedup_key embedded in notification_events.data. Wired by EXTENDING the existing private.refresh_attendance_state helper (rather than adding a competing trigger) so the notification fires at exactly the right cadence — every cancel/uncancel/completion/excuse path already calls it. 00133_rsvp_cooldown_enforcement.sql adds a BEFORE INSERT/UPDATE OF status trigger on rsvps that blocks status='confirmed' when caller's strikesInWindow >= 2 in an attendance-tracking club. No-op for non-tracking clubs; never blocks cancellations or recovery transitions. Raises P0001 with HINT='attendance_cooldown' so the client can pattern-match the hint to surface the existing cooldownMessage. Defense-in-depth for the client-side gate from Part 3b/ii. 20822cf (Part 4b/ii client + runtime bump) — closes the GPS auto-checkin loop. Adds expo-location ~19.0.1 + plugin entry in app.json with Korean permission strings + useGpsCheckin mutation hook (createMutationHook factory, requests foreground permission then takes a single Balanced-accuracy ping then calls check_in_attendance_gps) + "지금 출석 체크" primary button on session detail visible when session.status === 'in_progress' && session.courtVenueId && currentUser ∈ confirmedRsvps + 5 new t().trust.* i18n keys. ⚠ Bumps app version 0.2.0 → 0.3.0 (runtime version policy is appVersion) — adding the native module forces a new runtime fingerprint. The current 0.2.0 OTAs continue working but won't pick up future updates against 0.3.0; users must install a fresh preview build before any new OTA reaches them. User actions: npx supabase db push for 00132 + 00133, then cd apps/mobile && npx eas-cli build --profile preview --platform android (and ios when credentials are set up). Phase 6 is now FULLY shipped end-to-end on both server + client across all 4 parts.

Older milestone (2026-05-04, commits c8dbfee + 75b2061): Phase 6 Part 4b — Mutual Confirmation server foundations (match-score 1.1× bonus + GPS auto-checkin RPC). Two server-only commits, both backend-only (no OTA). c8dbfee (Part 4b/i — match-score 1.1× confidence bonus): new migration 00130_match_confidence_bonus.sql adds matches.confidence_bonus_applied BOOLEAN (single-flight gate) + elo_history.reason TEXT (CHECK in {'match', 'confidence_bonus'}, default 'match' so existing rows stay correctly classified) + private.match_both_teams_verified(p_match_id) STABLE helper (uses array_remove(... NULL) so the singles case where t1p2/t2p2 are NULL degenerates correctly to "both players verified") + private.apply_match_confidence_bonus(p_match_id) idempotent applicator (locks the matches row, early-returns when already applied or threshold not met; for each existing 'match' elo_history row computes ROUND(delta × 0.1), appends a 'confidence_bonus' row, bumps cached profiles.elo_rating + club_elo_ratings.elo_rating; skips zero-delta rows so draws stay neutral; sets confidence_bonus_applied=true even when 0 bonus rows applied to prevent re-evaluation churn) + AFTER UPDATE OF verified_by trigger on matches that fires the applicator on every verification mutation. 75b2061 (Part 4b/ii — GPS auto-checkin RPC): new migration 00131_gps_checkin_rpc.sql adds public.check_in_attendance_gps(p_session_id, p_lat, p_lng) — auth + confirmed-RSVP check; ±2h time window vs session start (generous on early arrivals, tight enough that stale "I went last week" pings can't masquerade as today); requires sessions.court_venue_id IS NOT NULL AND venue lat/lng populated (else raises P0001 with "manual check-in required" hint); 200m radius via spherical law of cosines (no PostGIS dependency, ~1m accurate at this scale); UPSERTs attendance_record with confirmed_by resolution (existing 'self' or 'admin' → upgrade to 'mutual'; else → 'gps'; 'excused' immutable); calls private.refresh_attendance_state. Client-side trigger deferred until expo-location is added in a build commit — adding a native module requires a dev/preview rebuild (not OTA-able), so the RPC ships now to avoid runtime version mismatch when the client lands. User action: npx supabase db push for 00130 + 00131. Phase 6 Part 4 server contract is now complete; Part 4 client trigger (GPS button on session detail) and Part 3b/iii follow-ups (gentle-nudge notifications, server-side cooldown enforcement) remain.

Older milestone (2026-05-04, commit 610a70b): Phase 6 Part 4a — 총무 attendance confirmation (Mutual Confirmation). First slice of Part 4. Pre-existing baseline: the 00127 session-completion trigger writes attendance_records rows with confirmed_by='self' for every confirmed RSVP — that's the player-side signal (RSVP commitment). This commit adds the 총무-side signal: when a 총무 confirms a player who already has a 'self' record, the row upgrades to confirmed_by='mutual' — full confidence per the spec. Server side: new 00129_confirm_attendance_rpc.sql adds public.confirm_attendance(p_session_id, p_user_id, p_attended) — auth + owner/admin role check on the session's club; resolves confirmed_by (existing 'self' + 총무 confirms attended → 'mutual'; anything else → 'admin'); existing 'excused' rows are immutable through this path (no-op return); p_attended=false marks no-show with strike_value=1.0 for cases where the auto-trigger flagged a confirmed RSVP'er as attended but they actually didn't show up; calls private.refresh_attendance_state for rate + trust tier propagation; GRANT EXECUTE TO authenticated. Client side: useConfirmAttendance mutation hook (createMutationHook factory; invalidates summaryByUser + byClub + rsvps.bySession + profile.detail). New AttendanceRosterSheet at packages/features/sessions/src/ — Tamagui Sheet at 80% snap with one row per confirmed participant + two pill buttons (참석 / 불참); taps fire the RPC without closing the sheet so 총무 can mark multiple players in succession; empty state when no confirmed RSVPs. Session detail screen gains an "출석 확인" button in the admin-action pill row, gated on canManageClub && session.status in {'in_progress','completed'}; AttendanceRosterSheet mounted at screen root alongside the existing MannerTagsSheet. 8 new t().trust.* i18n keys in ko + en (roster title/subtitle/empty/close + confirm-success toast + confirm-CTA + attended/no-show action labels; EN avoids the "session(s)" trigger word). User action: npx supabase db push for 00129. Out of scope (Part 4b follow-ups): match-score 1.1× ELO confidence bonus when both teams confirm, GPS auto-checkin, per-row "current state" indicator on the roster.

Previous milestone (2026-05-04, commit 99805c5): Phase 6 Part 3b/ii — 총무 attendance dashboard + excuse RPC + RSVP cooldown gate. Closes the user-facing half of Part 3b. Server side: new 00128_attendance_excuse_rpc.sql adds public.excuse_attendance_record(p_record_id) — caller must be owner/admin role for the record's club; UPDATEs final_status='excused', strike_value=0, confirmed_by='admin'; calls private.refresh_attendance_state to recompute the member's rate + trust tier in the same transaction. Client side: new ClubAttendanceScreen at packages/features/clubs/src/club-attendance-screen.tsx — DetailShell + permission gate (ShieldAlert when !canManageClub); hero card shows flagged-member count; FeedList of member rows with rate%, attended count, strike count (warning-tinted when > 0), total; tap a flagged row → ExcuseSheet shows member name + most-recent strike date + "사유 인정" button that fires the RPC against the latestStrikeRecordId. New useClubAttendanceSummary(clubId) query hook composes useClubMembers + useClubProfiles + an inline supabase query for attendance_records; derives ClubAttendanceMemberSummary[] client-side, sorted flagged → strikes desc → name; @freshness frequent. New useExcuseAttendance mutation hook (createMutationHook factory, no queue support — admin action). ClubAttendanceMemberSummary entity gains latestStrikeRecordId so the excuse RPC has a specific row to target. Admin tab on club detail gains "출석 현황" AdminNavRow with ClipboardCheck icon between dues and sessions. New routes.clubAttendance(clubId) → /clubs/[clubId]/attendance + mobile + web route shims. Session detail screen: when own attendance summary's strikesInWindow >= 2, the RSVP CTA is replaced with a $warning cooldown card + disabled button (pure client-side UX gate; server-side enforcement deferred). 11 new t().trust.* + 1 new clubDetail.adminTab.attendance i18n keys (ko + en, EN avoids the "session(s)" trigger word). User action: npx supabase db push for 00128. Out of scope (Part 3b/iii follow-ups): per-record strike list inside the ExcuseSheet (currently excuses only the most recent strike — sufficient for v1), 1-strike "gentle nudge" notification scheduling, server-side cooldown enforcement via RLS, member-side cooldown countdown UI.

Previous milestone (2026-05-04, commit 656f0df): Phase 6 Part 3b/i — Attendance strike-classification triggers. Server-only commit (no OTA needed). New migration 00127_attendance_strike_triggers.sql adds the four pieces of machinery that turn RSVP cancellations into scored attendance records: (1) classify_rsvp_cancel AFTER UPDATE trigger on rsvps when status transitions to 'cancelled' — looks up clubs.attendance_cancel_window_hours, computes hours-until-session, classifies as free_cancel/late_cancel/no_show per the spec table (>= window → 0 strikes; <12h → 1.0; otherwise → 0.5); UPSERTs into attendance_records with the matching strike_value. (2) uncancel_rsvp_cleanup AFTER UPDATE trigger when status flips back from 'cancelled' to 'confirmed' — DELETEs the pending cancel row so re-RSVPs don't carry stale strikes. (3) session_completed_record_attendance AFTER UPDATE trigger on sessions when status flips to 'completed' — bumps profile.total_sessions_confirmed for every confirmed RSVP (this column drives the trust-tier 50/20/5 ladders and was previously never written), and writes 'attended' rows when the club has tracking enabled. (4) Helper refresh_attendance_state(p_user_id) recomputes denormalized profile.global_attendance_rate from attended/(attended+no_show+late_cancel) (free_cancel and excused are NOT in the denominator — the player did the right thing) and calls recalculate_trust_tier so attendance changes propagate to the badge automatically. Excused rows preserved across all triggers — 총무 decisions trump auto-classification. All triggers no-op when attendance_tracking_enabled = false (the default), so this migration is safe to ship even though most clubs haven't opted in. User action: npx supabase db push for 00127. Out of scope (Part 3b/ii follow-ups): 총무 dashboard "flagged members" surface, 총무 excuse-cancel RPC + UI, client-side RSVP cooldown gate when strikesInWindow >= 2, 1-strike "gentle nudge" notification.

Previous milestone (2026-05-03, commit 85650e0): Phase 6 Part 3a — Attendance system settings + private display. Spec: docs/specifications/attendance-and-reviews.md Part 3. New migration 00126_attendance_settings.sql adds the 3rd attendance club setting (attendance_cancellation_penalty_enabled BOOLEAN) on top of the existing two from 00086 (attendance_tracking_enabled + attendance_cancel_window_hours). CHECK constraint enforces "penalty requires tracking" invariant server-side. Also drops the broken public.submit_manner_tags RPC from 00086 — it referenced v1 manner_tags columns (receiver_id / tag) that no longer exist after 00124's rename; dead code (we write tags via direct table queries). Client side: Club entity gains attendanceCancellationPenaltyEnabled (already had the other two); mapper threads the new field; useUpdateClub partial flow accepts it. New useAttendanceSummary(userId, clubId?) query hook (inline Supabase query — promotes to a proper port method when Part 3b lands and the adapter gains write-side logic; has the required @freshness stable JSDoc per DATA-10). New AttendanceSection component (packages/features/profile/src/) self-gates on own-profile + total > 0 (spec: "Member: own rate + streak — private"); two-stat card (rate % + consecutive streak) with a $warning nudge when strikesInWindow >= 1. Mounted between achievements preview and manner tags on the profile screen. Club settings screen gains an "출석 관리" SectionBlock (canManageClub gate) with three rows: tracking toggle → cancel-window 12h/24h/48h chip group → penalty toggle (last two only render when tracking is on). 6 new t().trust.* i18n keys in ko + en. User action: npx supabase db push for 00126. Out of scope (Part 3b): cancel→strike classification trigger on rsvps, RSVP cooldown enforcement, 총무 dashboard "flagged members" surface, GPS auto-checkin, 총무 excuse-cancel action, 1-strike "gentle nudge" notification.

Previous milestone (2026-05-03, commits 547c943 + sprinkle 09e16b0): Migration 00124 hotfix + Trust Tier badge sprinkle. First half: npx supabase db push failed against the production DB — 00124 used CREATE TABLE IF NOT EXISTS which silently skipped because production already had a v1 manner_tags table from migration 00086 (with giver_id / receiver_id / tag columns); the next step then failed on CREATE INDEX ... taggee_id (the v2 column name didn't exist). Hotfix 547c943 rewrites 00124 around a DO block that detects three starting states — fresh DB → CREATE v2 outright; v1 DB → drop policies + indexes + constraints, RENAME COLUMNS to v2 names, remap enum values (accurateskill_accurate, communicativecommunication, enthusiasticenthusiasm), add v2 CHECK + idempotency_key + no-self-tag + new unique; v2 DB → early return. All idempotent. db push then succeeded — Phase 6 is now fully live in production. Second half: trust badge sprinkle. MemberRow in club-members-screen.tsx gets a compact TrustTierBadge in the name row (lookup map extended to thread profile.trustTier through). AuthorRow in post-detail-screen.tsx gets a compact badge next to the author name. Both use variant="compact" size="xs" which renders as icon-only pill; self-hides for sprout/undefined so newcomers don't clutter every list. Phase 6 ledger: Parts 1 + 2 complete (Trust Tiers + Manner Tags end-to-end). Parts 3 + 4 (Attendance, Mutual Confirmation) still pending.

Previous milestone (2026-05-03, commit ee08359): Phase 6 Trust Tier — badge + RPC fix + auto-recompute trigger. New migration 00125_trust_tier_v2_manner_tags.sql replaces the broken private.recalculate_trust_tier() (it had been silently 42703-erroring since 00124 renamed manner_tags columns — references to giver_id / tag / receiver_id no longer exist) with the v2 column names; algorithm unchanged (50-session/6-month/20-tag veteran threshold, 20-session/10-tag/90% attendance trusted, 5-session active, demotion cap at 3+ active reports). Adds the missing AFTER INSERT trigger on public.manner_tags calling recalculate_trust_tier(NEW.taggee_id) — without it, profile.trust_tier stayed 'sprout' for every user regardless of activity. New TrustTierBadge display component (packages/app/src/presentation/components/trust-tier-badge.tsx) renders a $primarySubtle pill with lucide icon (Sprout / CircleDot / Star / Medal) + Korean label; self-hides for sprout and undefined so newcomers don't get a sprout badge cluttering every screen. TRUST_TIER_ICONS in trust.entity.ts updated from Ionicons-style names to lucide names. Mounted on the profile screen hero card below the existing ELO tier badge. User action: npx supabase db push (also picks up 00124 if not already applied). Out of scope (deferred): tap-badge → modal explaining the tier system, member-row badges in club detail, session-card badges (would add N+1 queries — needs prefetch design first).

Previous milestone (2026-05-03, commit a3ac48f): Phase 6 Manner Tags — UI slice (end-to-end). Wires the foundation (31eb9c7) to actual screens. New MannerTagsSheet (Tamagui Sheet at 80% snap, one row per fellow confirmed RSVP, 5 emoji-chip toggles per MANNER_TAG_TYPES, pre-selects existing tags, additive-only changeset) lives in @twomore/sessions; mounts on the session detail screen between RSVP zone and participants card with a "매너 태그 남기기" CTA visible when session.status === 'completed' AND current user is confirmed-RSVP'd. New MannerTagsSection (SectionBlock + useMannerTagCounts, skeleton/empty/sorted-rows states) lives in @twomore/profile; mounts on the profile screen after the achievements preview. The useSubmitMannerTags mutation hook converted from createMutationHook to raw useMutation with full Phase 14d offline-queue support — DATA-11 (networkMode: 'always'), DATA-12 (registerMutationExecutor('manner-tags:submit', ...) at module load), DATA-2 (mutationKey: ['manner-tags', 'submit']), idempotency-key generation per call, isAlreadyAcceptedError catch as success, taggee-deduped invalidation. 6 new v2 alias i18n keys in t().trust (ko + en) for skill_accurate / communication / enthusiasm plus sheet/section copy; v1 keys (accurate / communicative / enthusiastic) left intact. Out of scope this commit (deferred Phase 6 follow-ups): 2h post-session push notification, trust tier badge, attendance system, mutual confirmation, GPS auto-checkin, 24h tag-undo UI.

Previous milestone (2026-05-03, commit 31eb9c7): Phase 6 Trust & Reputation — Manner Tags foundation. First slice of the Phase 6 spec (docs/specifications/attendance-and-reviews.md Part 2). New migration 00124_manner_tags.sql introduces the manner_tags table — composite (session_id, tagger_id, taggee_id, tag_type) with a CHECK forbidding self-tag, unique-per-kind constraint, Phase 14d idempotency_key column + partial unique index, 3 RLS policies (INSERT requires both parties confirmed-RSVP'd in a completed session; SELECT scoped to club members of the taggee's club; DELETE only by the tagger within 24h; no UPDATE — tags immutable beyond the undo window). 5 fixed tag types: manner | punctual | skill_accurate | communication | enthusiasm. Korean labels live in i18n, never in the entity. Client side: new domain/entities/manner-tag.entity.ts with the canonical Zod schemas, port + supabase adapter (full rewrite — old v1 RPC-based shape replaced) + mock + mapper. Pre-existing v1 manner-tag definitions in trust.entity.ts (giver_id/receiver_id/tag) re-aligned to the v2 spec via re-export + label/icon map updates. Existing query hooks renamed useSessionMannerTags → useMySessionMannerTags and useUserMannerTags → useMannerTagCounts to match the v2 privacy model (no public "all tags by session" query — only the tagger sees their own session rows). User action: npx supabase db push to apply 00124. Out of scope this commit (intentional — separate Phase 6 sub-systems): bottom sheet UI, profile counts display, push notification scheduling, trust tier calculation, attendance system, mutual confirmation.

Previous milestone (2026-05-03, commit 0851f03): Second offline-queue safety rule shipped — @twomore/require-executor-for-queue-key (DATA-12). Every enqueue({ key }) call must have a matching registerMutationExecutor(key, ...) in the same file. Without the registration, queued entries sit in MMKV indefinitely (replay manager finds no executor → markRetried + logger.warn → entry persists across launches forever). Silent production-only failure: typecheck passes, user sees optimistic success, only a client_errors.log_warning line hints at the disconnect. Detection walks const X = '<literal>' declarations to build an identifier→literal map (since the canonical pattern uses module-level constants like RSVP_UPSERT_KEY rather than inline strings), then matches register calls and *.enqueue({ key }) calls and pairs them on Program:exit. Cross-file registration not detected — the established convention is co-location (each queueable hook registers its own executor). Repo-wide first run flagged exactly one expected violation: the "leaves entries with no registered executor" test in mutation-queue.test.ts intentionally enqueues an unregistered key to exercise the missing-executor branch — single-line eslint-disable with explanatory comment. 15 new test cases (10 valid, 5 invalid). ESLint plugin v0.3.0 → v0.4.0; mechanical tally 16 → 17 of 38.

Previous milestone (2026-05-03, commit bc308db): Codified the networkMode lesson as ESLint @twomore/require-network-mode-always-for-queueable (DATA-11). Any useMutation in a file that imports onlineManager / useMutationQueueStore / registerMutationExecutor MUST set networkMode: 'always' — fails lint:strict otherwise. Caught 2 real bugs the previous test-bootstrap commit had missed: useCancelRsvp (use-rsvp.ts:229) and useVotePoll (use-post-mutations.ts:285) both lived in queue-aware files but had been shipped without the flag, meaning their optimistic patches in onMutate would never get rolled back if the user was offline (TanStack would pause indefinitely without firing onError). Both fixed in the same commit. New AGENTS.md DATA-11. ESLint plugin bumped to 0.3.0; mechanical-enforcement tally 15 → 16 of 37 (the new constraint is born mechanical, not graduated from a DOC entry). 14 new test cases (8 valid / 6 invalid) covering import detection, all 3 queue-import names, missing/wrong/variable networkMode values, and intentional false-negative cases (member-call useMutation, non-literal options).

Older milestone (2026-05-03, commit 19f85cf): Phase 14d hardening pass — RN test infra bootstrap + production networkMode bug fix. The headline finding of the night: bootstrapping the test infrastructure within the same commit as a test run caught a dead offline path in all 4 freshly-shipped per-mutation opt-ins (d5248f9, 203a196, fb9eee7, ee47246). TanStack Query's default networkMode: 'online' pauses mutations while offline — the mutationFn never executes. Our offline-queue logic lives inside mutationFn, so the onlineManager.isOnline() gate was unreachable when offline. The OTA pickups for those 4 commits had been silently shipping mutations that TanStack would pause rather than route to the queue. Fix: networkMode: 'always' on all four hooks (use-rsvp.ts, use-start-session.ts, use-post-mutations.ts, use-submit-score.ts) — one line per hook that hands routing back to our own onlineManager.isOnline() check. Without the test-infra bootstrap, this would have gone unnoticed until a user reported "I tapped RSVP offline and nothing happened when I came back online."

Test bootstrap details: Uses babel-preset-expo (already present at workspace root via apps/mobile dependency, resolved via yarn workspaces) + react-native/jest-presetjest-expo was deliberately avoided (would have been a new install, pulls the full Expo runtime, adds unnecessary overhead for hook tests). Four previously-ignored test files (use-screen-gate.test.ts, date-format.test.ts, use-home-data.test.ts, use-rsvp.test.ts) removed from testPathIgnorePatterns and now execute live. Three new test files cover the Phase 14d hooks that previously had zero live coverage: use-start-session.test.ts (5 tests), use-post-mutations.test.ts (7 tests for useSetPostReaction), use-submit-score.test.ts (7 tests). Supporting infra: packages/app/jest.setup.js (RN globals + TanStack notifyManager.setScheduler workaround + mocks for expo-notifications/expo-network/expo-device/expo-constants/expo-secure-store/expo-router/expo-updates/@react-native-async-storage/@sentry/react-native), packages/app/src/__mocks__/nativewind.ts (stub for transitive imports), packages/app/babel.config.js switched from @babel/preset-env + @babel/preset-typescript to babel-preset-expo. 1130 → 1201 tests, 49 → 56 suites in @twomore/app. Cosmetic note: "worker process has failed to exit gracefully" warning on mutation-hook suites — known TanStack v5 + react-test-renderer interaction, exit code 0, yarn check passes.

Previous milestone (2026-05-03, commit ee47246): Phase 14d CLOSED — all 4 per-mutation opt-ins shipped. Two commits close the remaining Phase 14d work. fb9eee7 ships useSetPostReaction (renamed from useTogglePostReaction) with replay-safe set(type | null) semantics — naturally idempotent via UPSERT/DELETE-by-PK. ee47246 ships useSubmitScore + supabase/migrations/00123_public_submit_match_score_idempotent.sql — the submit_match_score RPC existed only in the private schema (PostgREST gap); the migration adds a public wrapper with idempotency contract (FOR UPDATE row lock + 23505 short-circuit). Note: all 4 opt-ins shipped with a dead offline path (networkMode bug above) until 19f85cf fixed it.

Older milestone (2026-05-03, commit 203a196): Phase 14d/3 — useStartSession offline-queue opt-in. Naturally idempotent UPDATE shape (sessions.updateStatus → 'in_progress'); no idempotency_key plumbing needed. Offline path enqueues and returns an optimistic Session (reads cache; falls back to minimal sentinel).

Earlier milestone (2026-05-03, commit d5248f9): Phase 14d/2 — useRsvp offline-queue opt-in. First per-mutation wire-up. End-to-end story: tap RSVP offline → MMKV queue → reconnect → replay → server 23505 dedup → dequeue → UI catches up. Also in this push: e2ec867 (post-9b polish) — ClubBoardScreen pinned-post dedupe + CourtsDirectoryScreen entry-point pill.

Earlier milestone (2026-05-03, commit 94247a6): Phase 9b CLOSED (5/5). Four commits finish the remaining Phase 9b screens. 86827fd ships PostCreateScreen; 31da683 ships ClubBoardScreen; b7b108e polishes ClubSettingsScreen (drops all StyleSheet.create, Tamagui Sheet+Input, TogglePill subcomponent); 94247a6 ships CourtsDirectoryScreen at /courts (Naver Maps integration).

Foundation milestone (2026-05-02, commit eeaa6c3): Phase 14d infrastructure landed — MMKV-backed mutation queue, replay manager, isAlreadyAcceptedError helper, SyncStatusBadge "N queued" badge, startReplayManager() wired into _layout.tsx. 94fb76c hotfixed 00122_idempotency_keys.sql table-name typos.

Foundation context (2026-05-02, commit b45e243): Org renamed silverlining-2025ivorybridge-2025. ESLint codification of COMP-1 + COMP-4 (mechanically-enforced constraints 13 → 15 of 36). idempotency_key columns + partial unique indexes on 7 mutation tables.

Foundation (P0–P7) complete (2026-04-27 / 28). Current AGENTS.md mechanical-enforcement counts are generated in docs/current-project-state.md; the remaining DOC-planned rules graduate as their mechanical shape stabilizes.

Credentials: Complete matrix (every env var, visibility level, EAS vs local, rotation procedures) lives in docs/guides/credentials.md. All production credentials are configured on EAS preview + production environments via eas env:create.

OTA + build status:

  • Latest preview build (Android, runtime 0.5.0): https://expo.dev/accounts/ivorybridge-2025/projects/twomore-v2/builds/e9483ebc-e816-4647-be84-78da9d24109b
  • Latest production build (Android, runtime 0.5.0): https://expo.dev/accounts/ivorybridge-2025/projects/twomore-v2/builds/b3542ab9-eb90-41f6-92ad-44825ea10099
  • Latest OTA (group 1ba6ed95-c4ce-43df-8cf4-56fd7612e004) published against commit 0a5a2690 (2026-06-12, runtime 0.6.1, android+ios, from MAIN — first post-merge OTA): the owner-QA fix batch — ModalPanel canonical sizing (14e71d1b primitive flexShrink/KAV + 8-consumer sweep, a75a9b40 chip-picker normal-sibling footers + ScrollView-sizing lint), recap ghost empty state (f489cc4b), club fixed-slot card anatomy + unified media precedence (0a5a2690). Pending owner re-QA on device. Prior OTA (group d5128319-6114-423d-939c-4b3369b2fa5c) published against commit eeb18b29 (2026-06-11, runtime 0.6.1, android+ios, published by Codex from the release/prod-readiness-hardening branch). The Codex batch published preview OTAs CONTINUOUSLY (one per checkpoint — the completion-slices ledger records per-slice groups incl. 28b64198 at the fba46e05 checkpoint, d998a1e1, 822bf6cb, …), so the preview channel tracks the branch tip, NOT main. The Expo account is lbsky (app.json owner — the ivorybridge-2025 URLs below are the GitHub org; historical links may 404). Branch state: draft PR #11 "Production readiness hardening" open against main; hosted EAS Maestro smoke for club-growth (completion-slice 7) still lacks a completed device-level run.
  • Prior OTA (group 0009ae09-0693-4903-811e-6d8b00751588) published against commit c774894 (2026-06-03, runtime 0.6.1, android+ios — match-card chip simplification, USER-VERIFIED on device: float ONLY the 게스트 chip — drop the role/host/member pills (an all-members doubles match got a 멤버 pill on every player; only 게스트 carries real signal) — atop the one-row-per-player MatchScoreboard layout + a new opt-in Badge alignSelf prop (centers pills in alignItems:center rows) + owner i18n 대표→회장 (the Korean tennis-club term, also used by club-members); roleMap/hostId composer plumbing removed end-to-end. Paired backend-only commit e923119 (no OTA) redeployed seed-scenario with a deterministic generateUserSpecs emailPrefix param that makes the cross-batch email collision — the guest-chip bug's root cause — structurally impossible. Prior OTA (group 282d2957) was the intermediate role-chip-polish step. Prior OTA (group bb7bcda0-6cde-4526-af71-9420e735f971) published against commit c3efdfd (2026-06-03, runtime 0.6.1, android+ios — 3 visual-QA bug fixes: e185586 Button disabledStyle (disabled tier/cooldown gates now dim instead of looking enabled); 484742a home live card match summary refactored to MatchScoreboard + guest chips (was members-only text rows → guests showed "—"); c3efdfd non-member→club-preview redirect (RLS was already sound — closed the UX gap)). Prior OTA (group c0eddaef-7ef8-4ebe-a13a-d10003f7c21d) published against commit ac2519d (2026-06-03, runtime 0.6.1, android+ios — dev-panel QA scenarios home_match_day_guests + home_tier_gated for visually checking guest chips + the tier-band CTA; seed-scenario edge fn redeployed). Prior OTA (group 3c5e2cb3-199a-492e-a302-a8746bf4b9cc) published against commit c02bf1c (2026-06-03, runtime 0.6.1, android+ios — foundational-schema follow-ons: cce4e32 client RSVP CTA tier pre-check + c02bf1c ATP Phase A-v2 guest chips on MatchScoreboard). Prior OTA (group 9e4cd3a5-43cd-4aa7-8999-80f7c9cef7e7) published against commit 37fd049 (2026-06-03, runtime 0.6.1, android+ios — foundational-schema batch: nationalityCode mapper, regionLabel venue display coupled to migration 00231's region key flip, useRsvp eligibility_tier message). Migrations 00228–00231 applied to prod via db push; grow-rd edge fn deleted from the prod project. Prior OTA (group a825bae0-e81a-4fee-a666-a2a1516ed481) published against commit 5b8bde3 (2026-05-19 — pre-launch tightening Wave B: soft-delete sweep, offline cancel-RSVP, auth bootstrap optimisation, profile Protocol-B props, storage-migration parallelism, display text roles). Prior Wave A OTA: group b9cc97c7-ef3d-418d-b897-c872febef475 against 33bbb6f (CRITICAL security fixes: get_app_config SR-key revoke, pi_access_log RPC, Sentry PII drop, prod-env guards). Prior: group 598ce80a-c1c9-497f-8309-69d0b0fd18fb against 120295f (2026-05-19 — AbortSignal.any polyfill fix). Server-only commits 5131eb6 + 5754a94 + bddead1 + 402139e and migrations 0017700183 all applied to live DB. Push pipeline verified: dev@twomore.test received real production signals on Android via FCM v1 after 00183 landed. Prior notable OTAs: consent system + notification hardening (2026-05-18, group e216716a-95a3-49e8-8bdb-f943183ea25c against b0503dd). Note: the 13 Android notification channels require a new EAS build to take effect (plugin is build-time); JS-layer changes OTA on 0.5.0. Phase 7 mapper resilience (2026-05-14): 30b2609 (group cad5f231-916f-442d-b0ca-8bf3aece0d4b — safeMap boundary + safeParse fallbacks + DB cleanup migration 00151). Phase 6 Foreground UX Protocols (2026-05-14): e866840 (screen-level readiness gating + PersonalRecordsSection IA move + anticipatory prefetch on live banner); d7e5668 (cosmetic followup). Phase 5 Network Protocols series (2026-05-14): e92e1c0 (group 0884812e-2908-42b9-a7f5-9436d0912e9e — Network Protocols codified + realtime score-call filter + foreground catch-up fix); 4250049 (group cefc9285-4c5f-462f-894a-32f2c4ab86e3 — SessionCard bulk hooks 150→3 req + LiveMatchRow bulk hook 40→1 req); d464354 (group 6bd8b5d6-87f7-4fd6-ad56-a371a38f774e — ChallengeCard bulk hook). Prior 4-phase UI consistency sweep (2026-05-14): d68e7a8 (group e8e9a8da-ec38-40a2-810f-b87fd7d98903 — ConfirmSheet primitive + top-5 Alert sites); da6def4 (group e453ba1a-6867-4d42-988a-6167550b204b — DataSection primitive + profile-screen stagger fix); 6838c5d (group d680935c-00f4-43bf-ba4b-5fc4c7833d52 — LiveTab FlashList virtualization); 3c02074 (group 0e7f1e52-10cf-47a6-a04b-3f591cfbf918 — remaining 25 Alert.alert sites migrated). Prior tip: 82667b3 (2026-05-11, Round 23 — ESLint plugin test depth + React.memo MannerTagsSection), group 816b24a0-e388-43f9-bd89-a3173414a691. Audit-driven sweep closed across Rounds 19-23 following the comprehensive sweep + Rounds 8-18: Round 19 added 8 missing routes.ts factories (profileFriends, profileFriendsAdd, deleteAccount, changePassword, legalNotices/Contact/Terms/Privacy) + swept 14 call sites + added root ErrorBoundary at mobile _layout. Round 20 shipped migration 00146 (lookup_club_by_invite_code SECURITY DEFINER RPC + dropped legacy clubs_invite_lookup USING (true) policy — closes the invite-code enumeration vector). Round 21 named the defensive LIMIT bounds in profile adapter. Round 22 yielded no commits — all audit items re-investigated as already-clean or false-positives (composite hook already had @freshness doc; child queries gate via TanStack enabled; Zod schema is for validation not double-conversion; dead-branch grep returned no hits; ratchet "stale entries" are active waivers grandfathering 131 real violations). Round 23 expanded ESLint plugin test cases (Windows-path normalization + commented-out-call) and wrapped MannerTagsSection in React.memo. Prior tip (runtime 0.3.0): 695c160 (group 1da95ca7-536d-4d20-aeae-ea40e960c9f7 — posts family + manner_tags realtime). Round 5 of foundational chip-system fixes — closes 4 more realtime gaps from the same audit. Migration 00141 publishes club_posts + club_post_comments + club_post_reactions + manner_tags to supabase_realtime (already applied). Four new hooks: useClubPostsRealtime(clubId) on club-board-screen (uses applyRealtimePaginatedChange for the infinite query + invalidates pinned-strip on every event); usePostCommentsRealtime(postId) + usePostReactionsRealtime(postId) on post-detail-screen (in-place patch via applyRealtimeListChange; reactions use composite (postId, userId) as the natural key); useMyMannerTagsRealtime(userId) on profile manner-tags-section (invalidation pattern — counts are derived via reduce). Closes the silent-no-op gap where useClubPosts JSDoc claimed "@freshness realtime" but no realtime hook ever existed since v2 inception. Realtime publication completeness now: signals (00107), sessions/matches/rsvps (00138), session_payments (00139), dues + attendance_records (00140), posts family + manner_tags (00141). Only club_alerts remains as a MEDIUM gap. Prior tip (runtime 0.3.0): d214735 (group 67725acb-d774-49cf-8f22-7500565681a4 — dues + attendance_records realtime). Round 4 of foundational chip-system fixes — closes the next two HIGH-priority realtime gaps caught by an audit modeled on the session_payments fix. Migration 00140 publishes dues + attendance_records to supabase_realtime (already applied to live DB). Three new realtime hooks: useDuesRealtime(clubId) mounted on club-dues-screen so 총무 mark-paid/waive/re-issue actions flip every viewing dashboard within ~100ms instead of 5min; useMyAttendanceRecordsRealtime(userId) on profile AttendanceSection so session-completion trigger writes + 총무 strike-excuse refresh rate%/streak live; useClubAttendanceRecordsRealtime(clubId) on club-attendance-screen so the flagged-members list refreshes on any member's state flip. All three use targeted invalidation (not in-place patch — derived aggregates wouldn't survive raw-list patches). Prior tip (runtime 0.3.0): 6dc984d (group db24bfd7-2171-48ac-9cb9-12daf7958c01 — persistent recap + disputed-match alert; HomeDisputedAlertBanner introduced). Older prior tip: 52be084 (group 50ea6e1a-d22e-4d2f-bdc8-8ff899c66ae9 — real names in home rows + ScoreStatusBadge swept + payment realtime). Older still: 78e5f2c (group 1a6af07e-e801-4e7c-84f7-b175c1253b90 — viewer-aware chips + invariant status + match-row badges everywhere; SessionPaymentStatusBadge introduced). Older prior tip (runtime 0.3.0): e0c51b6 (group 1f6c47a9-610b-4cb9-bc7f-3b7fb657c1d0 — autonomous overnight ~50 commits: error-visibility, spacing/Pressable/status-badge enforcement, realtime Phase A-C, server status auto-transitions, session-card excitement layers, OTA pipeline fix, seed-scenario fixes, box-dimension constants, +56 tests). Older prior tips (runtime 0.3.0): 5b4910c (group 21717b1c-9a0f-42f5-aa26-942c85d27326 — Phase 3 GPS-default + filter-persist region wire-up, LucideIconType type-safety refactor, adapter any-disable sweep, canonical cache key + bounded SELECTs audit fixes). b798dd4 (group 2f7535de-df22-4bd4-84f4-b9dffc32fb1d — adapter any-disable sweep); 7196b40 (group 7d0ad239-564f-4808-a313-965c3f226b43 — LucideIconType type-safety refactor); 26eac81 (group a19504fc-3655-4bf0-96e3-07ba98d23340 — persist pickup-filter regions to filter_prefs); 72331b8 (group 7a89b6f0-ee63-47b7-9a56-b25c2c431f96 — GPS-default region in pickup-filter); 744391f (group e89801f3-c930-4e26-aaa4-ab37cf8b5b19 — Phase 5/2B+C+D share targets); 43fc4f2 (group b79362fe-d15b-4a09-804d-71e73cc7dd21 — Phase 5/2A match-result share); e80e003 (group 0c41d2e6-b1c5-4033-a01d-3f5216e680eb — Phase 5/1A session-invite share); 5e34282 (group aa0157bf-4433-463c-b3f4-e5f87b47e054 — ARCH-8 ESLint rule + useGpsCheckin port-routing); e30ca2d (group e360c957-1335-4c14-808c-c1cf9009c237 — monolith decomposition); 7a5cf43 (group 4340252f-b882-4c19-b1c8-fadad022184b — audit-cleanup sweep, ratchet → 0); d220ea3 (group 6798e7db-e427-4b26-bde7-c934b9c222cb — Phase 14f freshness ratchet CLEARED + mutation-key shrunk 8 → 1); 90b0a1e (group 523198a8-64be-4115-b09b-9ab9bdd68323 — per-record strike excuse + GPS hardening). Older tips (runtime 0.2.0): 610a70b (group e115354e-cfc1-4ea8-8b25-b84fd9c46ad5 — 총무 attendance roster on session detail; requires migration 00129); 99805c5 (group 9dfca4d8-a2d1-402d-95f1-9833393ffe89 — 총무 attendance dashboard + excuse RPC + RSVP cooldown gate); 85650e0 (group 523dbf25-b95c-4c97-8ab3-bf46dd81d476 — Part 3a attendance settings + private profile section); 09e16b0 (group 46dfb450-cec4-4e2a-b6a3-71e989dd3db3 — trust badge sprinkle on member + author rows); ee08359 (group a58ba202-5ed0-4a08-8e85-24ec2a6002b4 — Trust Tier badge on profile hero); a3ac48f (group 8aae7774-7adf-4ef6-b86c-62c7041148cb — Phase 6 Manner Tags UI); 19f85cf (group caee32ba-e889-4df8-987e-0cffecf33fff — first OTA with the networkMode fix; prior OTAs had a dead offline path for all 4 per-mutation hooks); ee47246 (group fc3c823c-87f6-437d-8910-e449cb9b091c); fb9eee7 (group 6a6233a7-3238-45a4-aaac-a5aace90a5f6); 203a196 (group fc76a339-893c-44b8-b1bc-c8be20747f78); d5248f9 (group e137a1a5-846f-42b0-a246-2e9bba84ea95); e2ec867 (group 519cc724-8f01-48d5-901e-c088b0a4e83c); 94247a6 (group d08c593b-c640-4741-af09-5c841b346612); 8ee82ec (group 459229a4-e5dd-4b4a-91a2-721553fd3844); eeaa6c3 (group 966f2881-aeec-4eb3-bed1-58597dfd8856); b45e243 (no OTA — backend-only commits); 9d553e3 (group 5df1bcff-5146-4275-8c84-0ec0d434185a); c3bdf7b (group fb342aa6-3ae7-438a-981c-35229bc6a03f); 8d17f6a (group 80a143a6-ae70-44b3-8fcb-c520b586b5da); 85dfbcf (group 26d605e0-f71b-42fd-893b-68251136f562); b71c055 (group c498fd1d-a978-480c-b374-2897eada186d); c76060f (group 636eefcd-7dd3-41d5-8121-17c3b8b2d185); bccbf8a (group 280c76e1-d11d-4bd8-83bc-d2d0cc0b3ecf); 3cdfd06 (group 8c22f210-8e18-41bf-8e1e-8090cd68df2b); 7df68e0 (group bb633103-b5df-47f3-8bd2-58709cae78ee); 5779cad (group 8f757e09-f0b4-4a5e-8691-3565f7c14abc); f5c7e8f (group bd1a4ef6-8d03-4aa1-92d6-368dde2b5e71); 9110e33 (group 2c773756-daa6-4e7b-acb4-c0b1386ec9d3); 1ad95f1 (group 08bba047-d121-42c9-8717-9509ff29cb95); 33ae559 (group 9084affc-9e6a-4462-9ff6-2e318df719e9).
  • iOS preview build still NOT triggered — needs interactive credential setup (cd apps/mobile && npx eas-cli build --profile preview --platform ios).

What's running:

  • PR #11 MERGED to main 2026-06-12 (merge commit a59a086f) — the Codex batch + the follow-through session are on main; the branch was deleted; work continues from main. No post-merge OTA was needed (commits since the last app-affecting OTA were docs/test-only — bundle identical). The follow-through session record below is kept for the postmortem trail. 2026-06-11/12 follow-through session closed most of the batch's open items: (a) all 5 stale edge fns deployed + verified (send-push v29 fail-closed, check-push-receipts v17, static-map v24, export-user-data v17, search-venues v38 — the batch's hardening is now actually live); (b) 19-HOUR PROD OUTAGE found + fixedapp_config.service_role_key held a REVOKED API key since 2026-06-10 ~19:00 UTC (rotation during the Codex EAS provisioning is the suspected trigger), so EVERY cron net.http_post (send-push every 2 min, all weather jobs, receipts) got 401 "Unregistered API key" at the gateway while the 00202 watchdog wrote hourly CRON HTTP FAILURE FATALs into telemetry that nobody read; repaired WITHOUT the secret ever entering chat/git/agent context via a one-shot token-guarded edge function that copied its own valid TWOMORE_SECRET_KEY env into app_config (digest-verified 7d48dd36… end-to-end, function deleted from prod + disk immediately); recovery probe-confirmed — last 401 13:33:00 UTC, solid 200s from 13:34, weather cache repopulating; (c) club-growth slices 7+8 CLOSED (ledger) — after two diagnostic runs (flow 08's final asserts: missing scroll → a53c637b; then the deeper find — the '승인 대기' assert was UNSATISFIABLE by construction, the status renders inside one composed meta line and Maestro text matchers are full-string regexes → 08a9db61), hosted run 019eb775 SUCCEEDED 2026-06-11 16:48 UTC: 3/3 flows PASSED on the hosted device (01-login 28s · 02-main-tabs 41s · 08-club-growth-media 80s, build 8b32e688) — the device-level proof the ledger required; slice 8's evidence packet is the ledger itself. New maestro-e2e-rerun.yml (validated) reuses an existing build_id for flow-only iterations (~7 min vs ~35); (d) merge PR #11 to main — now unblocked, user decision.
  • Phase 14a/b/c bake gates (per master plan: 7 + 7 + 5 days of preview-channel observation; watch Sentry for context.source: 'mmkv-migration' breadcrumbs).
  • 기록 tab crash fully resolved: native-layer fix landed in b96e59c; JS-layer Date hydration fix landed in ee6f1bd. No open records tracking.
  • CI is now active under ivorybridge-2025 — the workflow at .github/workflows/ci.yml fires on every push and PR. Branch protection on main with required-status-check on typecheck + tests + lints is the next step (GitHub UI action by the user).
  • Phase 14d CLOSED + networkMode fix live (post-19f85cf OTA): infrastructure (9d0b0d6) + useRsvp (d5248f9) + useStartSession (203a196) + useSetPostReaction (fb9eee7) + useSubmitScore (ee47246) + networkMode fix (19f85cf). SyncStatusBadge "N queued" badge fires on offline taps for all four mutations. Prior OTAs (d5248f9 through ee47246) had a dead offline path — TanStack Query's networkMode: 'online' default paused mutations before mutationFn could run; fixed to networkMode: 'always' in 19f85cf. OTA pickup test: go offline, tap RSVP — should now enqueue (prior OTAs silently paused rather than enqueued). 00123_public_submit_match_score_idempotent.sql still requires npx supabase db push — until applied, the useSubmitScore offline replay path will fail to re-execute on the server.

What's deferred (with explicit reasons):

  • iOS preview build: interactive credential setup required (Apple ID, provisioning). The 0.3.0 Android preview build is the only one currently distributed.
  • Branch protection on main: CI is now active under ivorybridge-2025; enable required-status-checks via GitHub UI at https://github.com/ivorybridge-2025/twomore-v2/settings/branches. Required check: typecheck + tests + lints. Do NOT require drift — architectural erosion (non-blocking by design).
  • Supabase type-drift gate activation: waits on SUPABASE_ACCESS_TOKEN repo secret.
  • 5 large feature screens (create-session-screen 1875, match-board-screen 1800, create-club-screen 1602, session-detail-screen 979, records-leaderboard-screen 883): identified as monoliths in the post-cleanup audit but deliberately NOT split as a standalone refactor — the rule that's worked here is "split during the next feature touch" so the decomposition lands with the feature change that motivates it. High-risk / low-reward to split working JSX cold.

Next concrete steps (priority order):

Engineering work is the focus. Launch-blockers that depend on external services or user info (CPO contact, counsel review, lbsc.kr filing, iOS builds, branch protection) are PARKED in docs/pre-launch-checklist.md and DO NOT gate the engineering backlog below. Pre-counsel cleanup brief lives at docs/counsel-prep-brief.md — hand to lawyer when engagement starts.

App engineering backlog (v1.1 — operational hardening, ~4 weeks post-launch budget):

  1. SECDEF search_path = '' round 2 — Wave E3 (commit 595db3d, migration 00194) hardened 7 more functions (1 IMMUTABLE correctly skipped).
  2. Weather edge function rate limits — Wave E2 (commit 64453fb, migration 00193) gated 4 weather functions on CRON_SECRET shared-secret header.
  3. Read-side displayName enrichment — Wave E1 (commit fe60a94) ships useProfilesByIds + resolveSignalBody({profileLookup}) + restored name-bearing i18n strings.
  4. Cron-failure watchdog — Wave E5 (migration 00195, this commit). Pure-DB watchdog: cron_watchdog_state table + private.check_cron_health() SECDEF RPC scheduled every 15 min via pg_cron. Writes client_errors rows (severity='critical', user_id=NULL) when any cron job hits 3+ consecutive failures, deduped to 1 alert/hour per jobname. Surfaces in the existing recent_client_errors dev-panel readback path — no new dashboard/webhook needed. Would have caught the 2026-05-17 push-pipeline outage within 45 min instead of 33h.
  5. Critical-path test coverage — Wave E4 (commit f57c7ee) shipped 17 tests across useUpdateDuesStatus, useMarkPaymentPaid, useMarkPaymentWaived, useDeleteAccount, useGrantConsent, useRevokeConsent.
  6. Production Kakao auth + real PortOne + KakaoTalk sharing — bigger feature/runtime work; gated on Kakao Developers console, PortOne v2 credentials, native adapter/runtime work, and owner-side production setup. Out of scope for v1.1 hardening; tracked separately on the roadmap.

Key numbers (verified 2026-06-27): 303 migrations (latest 00303 = session-payment host authority: additive permissive RLS so a session's HOST (created_by) — not only a club dues-manager — can read the 정산 roster + mark paid/waived (a 번개 host is created_by, not a club admin, so the 00059/00145 admin-only policies had locked pickup hosts out of confirming their own fee); also drops the stray 00059 FOR ALL "writable by admins" policy that 00145's unquoted DROP had missed (closes the unintended admin DELETE); probe-verified 8/8; prior 00302 = portone payment confirm: public.mark_session_payment_paid_via_portone(session_id, user_id, payment_id, paid_amount) SECDEF service_role-only + idempotent (flips only a still-pending hold) + defense-in-depth paid_amount = participation_fee check — the trusted mark-paid path for the verify-portone-payment + portone-webhook Deno edge fns (client NEVER marks paid); prior 00301 = payment rails + hold window: session_payments.method ('manual'|'portone') + submitted_at + portone_payment_id + sessions.payment_hold_minutes (default 10); re-issues expire_unpaid_session_holds with a from-reservation LEAST(hold.created_at + payment_hold_minutes, session_start) window + idle-only (submitted_at IS NULL — 송금 완료 stops the clock) + cron */5; attest_session_payment (member 송금 완료) + dev_mock_pay_session (DEV-account-only); prior 00300 = unpaid-hold expiry cron: private.expire_unpaid_session_holds() + hourly pg_cron (:45) — cancels confirmed-but-unpaid holds past their deadline (TZ-aware), pre-seeds EXCUSED attendance record, best-effort promotes next waitlisted member; prior 00299 = session-payment-gate trigger: private.sync_session_payment_on_rsvp SECDEF trigger auto-creates/releases session_payments holds on RSVP confirm/cancel (ON CONFLICT DO NOTHING preserves paid rows); prior 00298 = participation_fee: sessions.participation_fee INT NOT NULL DEFAULT 0 (fixed per-person ₩ gate, DISTINCT from legacy cost_total) + payment_deadline_hours INT NOT NULL DEFAULT 24; prior 00297 = dm_media: private dm-media storage bucket + path-prefix RLS via is_dm_participant + dm_messages.attachment_path/width/height + CHECK relaxation for image rows + 📷 preview glyph; prior 00296 = dm_rejoin_fix: add_thread_participants ON CONFLICT DO NOTHINGDO UPDATE SET is_active=true, last_read_at=now() WHERE is_active=false — reactivates departed members on re-add + resets their read cursor; prior 00295 = dm_reactions: dm_message_reactions table with thread_id denormalized + toggle_dm_reaction SECDEF (add/remove, is_dm_participant gate + 60/min rate limit); prior 00294 = dm_safety: user_blocks + content_reports tables + block_user/unblock_user/get_blocked_users/report_content SECDEFs + private.is_blocked symmetric helper + initiate_dm_thread block guard + dm_block_guard BEFORE INSERT trigger; prior 00293 = dm_system_messages: dm_messages gains message_type ('text'|'system') + system_event + system_meta JSONB; the five group RPCs emit system rows via private.dm_emit_system; all three INSERT triggers skip system rows (rate-limit / preview-update / push fan-out); applied to preview project + probe-verified; prior 00292 = dm_group_membership: create_group_thread/add_thread_participants gain optional p_context_session_id (confirmed co-participants qualify for group-add even without friendship), old 2-arg overloads DROPPED first, new private.is_confirmed_participant helper, remove_thread_participant + rename_group_thread admin RPCs — all SECDEF search_path=''; applied to preview project; prior 00291 = DM trigger hardening (Batch 2+3); prior 00289 = dm_group_chat: dm_threads.kind direct|group discriminant, nullable lo/hi for group threads, new dm_thread_participants table with per-member last_read_at + is_active, SECDEF helpers is_dm_participant/dm_thread_messageable to avoid RLS recursion, RPCs create_group_thread/add_thread_participants/leave_thread, rewritten mark_thread_read + get_dm_unread_count + new get_dm_unread_by_thread, per-recipient push dedup_key, backfill of existing direct threads — fixes the previously-broken inbox unread badge; applied to the preview Supabase project, OTA group 03aee8a6; prior 00278 = drop clubs.tags / retire the free-form club tags field — identity is now the four canonical dimensions: format · tier · focus · composition; prior 00266 = private.rrule_byday_keys helper + regular_meet_days TEXT[] in get_club_discovery_summaries; prior 0026300265 = RRULE regular-meets + tag cleanup: 00263 club_regular_meets table + sync trigger + backfill; 00264 auto-create meet on club INSERT; 00265 drop 'social'/'training' tag values + strip from existing clubs; prior 0023800258 = the Codex production-readiness batch, all applied to prod (verified 2026-06-11 via supabase migration list --linked): 00238 edge rate-limit fingerprint keys (check_rate_limit_for_key for publishable-key callers) · 0023900242 signal-pipeline restoration (creator-exclusion regression fix; score-call pending-verification signals; referee-transfer RPCs rewired off the dropped private.create_notification to signals_emit + new match_referee_transfer_proposed; check_auto_call emits new match_sudden_death_needed + resolving triggers) · 00243/00244/00246 exact-count SECDEFs (get_dm_unread_count, get_post_reaction_summary, signals_unread_visible_count/signals_acknowledge_visible_unread) · 00245/0024700251 keyset-pagination + partial indexes (signals feed, DM inbox tabs, club_members active, attendance ×4, member_availability, session_payments) · 00252 elo_history RLS participant branch (pickup matches readable by their players) · 00253/00254 mixed_doubles match_format enum + backfill · 0025500258 club growth schema (clubs tagline/focus/tags/photo_policy; club_media + private club-gallery-media bucket with path-prefix RLS; get_club_discovery_summaries; club_growth_events + analytics RPCs; access-contract hardening pass + 18-assertion pgTAP test 00016); prior 00237 = full rating recompute: private.recompute_all_ratings() wipes match-derived rating state + replays every completed match chronologically through the unified engine — invoked once in-migration, 273 prod matches replayed; 00236 = unify the rating writer: auth-free idempotent private.apply_match_ratings called by BOTH submit_match_score (admin) and agree_to_call (live consensus, which previously computed ZERO ELO) + singles-branch elo_rating write; prior 00235 = durable tier-at-match-time: elo_history.tier_before (BEFORE INSERT trigger from the row's own rating_before, no backfill) + single-source private.tier_index_from_elo/tier_from_elo band helpers + rsvps_enforce_tier refactored to drop its inline elo→band CASE (kills the 3rd copy behind the 00151/00229 drift); 00234 = durable match-context snapshot: matches.nature (friendly/competitive/tournament/league, frozen from the session at →completed) + matches.friend_pairs JSONB (accepted-friendship [lo,hi] edges among players at completion, viewer-agnostic — the durable fix for friends-vs-strangers, which had been intersecting against the mutable current friends list) via snapshot_match_completion_context BEFORE INSERT/UPDATE trigger; 00233 = membership-aware history: session_style += league + rsvps.joined_as_member snapshot (member/guest/pickup via BEFORE INSERT trigger); 00232 = assign_waitlist_position BEFORE INSERT/UPDATE trigger (ordered waitlist positions); prior 00231 = public_courts.region → english SIDO keys (region triple-modeling fix); 00230 = rsvps_enforce_tier server-side club tier-band gate (grandfathers existing confirms, P0001 HINT=eligibility_tier); 00229 = pin display_tier + clubs.min/max_tier to the 5-tier enum (fixes the latent 00151 IF NOT EXISTS that left the 00093 7-tier check live in prod — platinum/diamond allowed, grandmaster impossible) + mark Glicko singles_rd/doubles_rd inert + drop dead grow-rd edge fn; 00228 = profiles.nationality_code (ISO-3166-1 alpha-2, always-public, IdP-derived in prod) + get_public_profile_view re-derive; 00221 = add total_sessions_confirmed + global_attendance_rate to get_public_profile_view; 00220 = dm_threads + dm_messages + 3 SECDEF RPCs (initiate_dm_thread, decide_dm_request, mark_thread_read); 00219 = FK cascade cleanup (9 ON DELETE actions on profiles refs); 00218 = SECDEF REVOKE on 5 match_referee functions; 00217 = anon REVOKE consistency; 00216 = guest_application signals; 00215 = pickup host actions; prior 00214 (latest 00214 = club_archive_and_ownership: archived_at column + RLS update hiding archived clubs from discovery + transfer_club_ownership / archive_club / unarchive_club SECDEFs + private.purge_archived_clubs() cron helper (daily 18:30 UTC); prior 00213 = club_join_policy: 3-tier join policy + club_join_request table + apply_to_club / decide_join_request RPCs; prior 00212 = profile_visibility: JSONB column + can_view_profile_field + get_public_profile_view SECDEFs for per-field 4-tier visibility; prior 00211 = club_post_audience: tiered RLS for the discussion board with audience IN (public/member/official); prior: 0020700210 = Supabase Security Advisor hardening: drop the always-true anon/authenticated public_courts UPDATE policies (genuine vandalism hole, dead code), pin search_path on 12 INVOKER functions, revoke anon/PUBLIC EXECUTE from all 82 public SECDEF functions + tighten 9 cron/edge fns to service_role + ALTER DEFAULT PRIVILEGES so new public fns don't auto-grant PUBLIC, scope storage avatars/club-media read to authenticated — all 4 advisor categories closed, locally validated via db reset + 7 posture probes, applied to prod; 00199 = push-cron x-cron-secret; 00200 = signals-immutability system-bypass regression fix; 00201 = drop 2 orphaned ambiguous function overloads; 00202 = cron watchdog scans net._http_response for non-2xx + fixes 2 latent watchdog alert-write constraint bugs; 00203 = expose cron.job.command in dev_inspect_cron_status; 00204 = repair all 10 weather crons to send x-cron-secret (the 403 fix); 00205 = raise weather-cron pg_net timeout 30s → 90s so intermittent slow-KMA runs finish + populate the cache instead of dying at 30s and tripping the watchdog FATAL; 00206 = forfeit_session SECDEF RPC (Phase 1 mid-session withdrawal — excused attendance/no strike + cancel RSVP + session_player_forfeited signal to owner/admin/match_director; runtime-validated that the excused pre-seed suppresses the 00127 no-show classification) — all applied to prod) · app version 0.6.1 (EAS build 5b80d125 — runtime 0.6.1, adds react-native-spotlight-tour@4.0.0 native coachmark module; prior build c1c8fee8 runtime 0.6.0 included @shopify/react-native-skia@2.4.18 + victory-native@41.21.1 + softwareKeyboardLayoutMode:pan) · 2,796 tests across all workspaces (up from 2,666 — group-chat + payment hex stacks added) + 15 tests in scripts/__tests__/lint-pi-schema-changes.test.mjs + the 18-assertion pgTAP RLS suite supabase/tests/00016 · supabase/seed.sql DELETED — scenario builder is the single source of mock data. config.toml seeds only seed_public_courts.sql (real reference data). All user/club/session/meet/DM/payment/review/availability/challenge/stats data is generated randomly + idempotently by seed-scenario. yarn seed / yarn seed:dense scripts wrap the edge function call post-reset. · New entity RegularMeet (domain entity + Zod schema + port/adapter/mapper/registry, 21 unit tests) · New dep rrule (pure-JS iCalendar RRULE parser/generator; Hermes-safe; OTA-deployable) · ModalPanel is the canonical product overlay (Tamagui Sheet legal ONLY in the dev panel — @twomore/no-bottom-sheet-outside-dev-panel); new UI primitives SectionHeader (SectionBlock delegates; icon prop removed) + SelectionChip (canonical selectable data/filter pill; DropdownChip/RangeChip rebuilt on it + ModalPanel) · Maestro lives at apps/mobile/.maestro/ (EAS workflow apps/mobile/.eas/workflows/maestro-e2e.yml; flows 01-login/02-main-tabs/08-club-growth-media CI-active, 03–07 deferred pending deterministic seeds; before_maestro_tests seeds club_growth via scripts/run-scenario.mjs) · scenario system single-sourced: supabase/functions/_shared/scenario-builder.ts (ConfigurableScenarioBuilder shared by seed-scenario + simulate-activity) + dev-panel/scenario-chains.json (the chain catalog consumed by BOTH scripts/run-scenario.mjs and the dev-panel ScenariosPane — never hardcode a chain in one side) · Realtime port/adapter FULLY RETIREDRealtimeServicePort, createSupabaseRealtimeService, realtime.supabase.ts, ports/services/realtime.service.port.ts, presentation/cache/realtime.ts, presentation/cache/realtime-backpressure.ts all deleted; registerRealtimeAuth removed from client.ts; Registry.realtime field removed; createClient no longer configures the realtime socket at all. The app uses ZERO persistent Supabase Realtime WebSocket connections. Data freshness is fully on native refetchInterval (DM thread 5s, DM inbox 15s) + focus-gated 10s manual-invalidate poll (live session screens, stops when terminal) + push (FCM + useNotificationHandler) + refetchOnWindowFocus + staleTime (Protocol D foreground catch-up via useAppForegroundRefresh, renamed from useRealtimeAppState 2026-06-01). use-dm-realtime hook deleted; hooks/realtime/ dir absent. The ESLint guards (no-realtime-subscribe-outside-realtime-hooks, require-is-focused-in-realtime-hook) and the realtime-focus-gate arch-test are KEPT as regression guards. · Canonical offline contract: useNetworkState(queries) (5-state NetworkPhase: 'online'|'offline-stale'|'offline-cold'|'reconnecting'|'error') + useCompositeNetwork (uniform {ready,isError,isPausedOffline,phase} shape for all composers) — replaces the 4 independent offline-detection implementations that preceded this batch · 0 lint errors / 0 lint warnings (lint:strict locked) · 16 ArchUnitTS fitness rules + 1 fs-based arch-test (zod-schema-coverage) · 52 active ESLint @twomore/* rules (added no-bottom-sheet-outside-dev-panel — bans Sheet imports/JSX + gorhom + ModalPanel.Handle + legacy sheet props on ModalPanel outside dev-panel/; prior: no-mutation-hook-factory-in-render — bans calling the createMutationHook factory inside a component/hook body, the React-Compiler "Should have a queue" footgun; also re-enabled react-hooks/set-state-in-render as error; prior: no-localized-option-value for value/label confusion; require-memoized-list-render-callbacks + require-safemap-in-adapter-list) · 2 Node script enforcements (lint:pi-schema — PI schema discipline for new migrations; generate-icon-assets — one-shot icon generator from brand mark SVG, not a lint gate) · 40 of 43 AGENTS.md hard constraints mechanically enforced (ARCH-9, COMP-7 added as DOC-planned; DATA-13 added as script-enforced) · 4 ratchet rules with 87 waivers (no-inline-style-with-vars, no-stylesheet-create, no-padding-top-in-features, no-raw-rn-pressable-in-features; grandfathered while the rules block new drift) · yarn metrics produces the live JSON snapshot. Permanent diagnostic RPCs: dev_inspect_cron_status SECDEF (exposes cron.job + cron.job_run_details + net._http_response, gated to DEV_USER_ID); dev_read_telemetry SECDEF (unified client_errors + client_perf_events stream, gated to DEV_USER_ID) — read via yarn debug:logs. New ports/adapters: ConsentRepositoryPort + supabase adapter + registry wire; 4 new query/mutation hooks (useUserConsents, useConsentHistory, useGrantConsent, useRevokeConsent). New screens: SettingsConsentManagementScreen, ConsentHistoryScreen (routes wired). Onboarding refactored 4 → 6 steps (birth-year + under-14 gate + data-driven consent step) with useGrantConsent as the single source of truth from signup. Mock adapter directory deleted — test data is seeded on Supabase (supabase/seed.sql); use-case tests use inline port-shaped stubs. Wizard primitives (LabeledField, StyledInput, WizardDraftBanner, WizardReviewRow) extracted to @twomore/ui alongside the pre-existing WizardShell. All 5 monoliths decomposed: records-leaderboard-screen 928 → 167, session-detail-screen 1001 → 559, create-club-screen 1458 → 302, create-session-screen 1755 → 348, match-board-screen 1873 → 436 LoC; 7015 → 1812 main-file LoC (-74%) across 34 new sibling files. TYPE-3 closed — all 42+ entities export Zod schemas; arch-test fs scan enforces every new entity. Only ARCH-9, COMP-7, and DATA-3 remain DOC-planned (a draft no-empty-catch-in-adapter was attempted and reverted because it flagged 8 legitimate fire-and-forget paths inside adapters/supabase/ itself; revisit when adapter shape stabilises).

Full phase history + tonight's exact gotchas: docs/rebuild-log.md. "test+fix: bootstrap RN test infra + uncover networkMode production bug" covers 19f85cf (2026-05-03); "Phase 14d/4+5 (useSetPostReaction + useSubmitScore — Phase 14d CLOSED)" covers fb9eee7 + ee47246 (2026-05-03); "Phase 14d/3 (useStartSession opt-in)" covers 203a196 (2026-05-03); "Phase 14d/2 (useRsvp opt-in) + post-9b polish" covers e2ec867 + d5248f9 (2026-05-03); "Phase 9b/2..5: create-post · board feed · settings polish · courts directory" covers 86827fd + 31da683 + b7b108e + 94247a6 (2026-05-03, Phase 9b CLOSED); "Phase 9b/1: per-club session list" covers 8ee82ec (2026-05-03); "Phase 14d infrastructure + migration hotfix" covers 94fb76c + 9d0b0d6 + eeaa6c3 (2026-05-02); "Org rename + ESLint codification + Phase 14d unblock" covers b63bd7a + bbe2568 + dede175 + b45e243 (2026-05-02); "Audit closure: all 2026-05-02 deferred items landed" covers 8d17f6a + c3bdf7b + 995a74e + 9d553e3 (2026-05-02); "More deferred-violations: DATA-5 + DATA-7" covers b71c055 + 85dfbcf (2026-05-02); "Tier C MotionPressable revert + DATA-1 candidate" covers bccbf8a + c76060f (2026-05-02); "Deferred-violations finishing run: COMP-5 + AppHeader subtitle + Tamagui hygiene" covers 5779cad + 70b1e1d + 7df68e0 + 3cdfd06 (2026-05-02); "COMP-4 DetailShell migration" covers f5c7e8f; "AGENTS.md/CLAUDE.md violations sweep" covers 9110e33; "Navigation-chrome icon sweep" covers 1ad95f1; "Header icon round 2: 2xl size + badge repositioning + week/month MotionReveal cleanup" covers 33ae559; "Header-right icon sizes + home-feed MotionReveal cleanup" covers 5e4cd05; "Codified: tab-doublestack ESLint rule + SegmentedTabs memo Components rule" covers f01cbea; "Bottom-tabs chip-tap perf" covers c32da2d + e0ce50b; "Persisted query cache lost Date types" covers ee6f1bd; "Bottom-tabs detach + freeze doublestack crash" covers b96e59c; "Segmented-tab cold-mount lag + 기록 tab silent crash" covers 064f06b (2026-05-01); "Phase 14b", "Phase 14c", "Phase 14e (partial)", "Mobile runtime separation", and "Phase 14d / 14f — DEFERRED" cover the prior session.

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