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 devMockConfirm → verify-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 (resolveName → unknownUser 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). 00263 — club_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 87042bc0 → 6bcba44a — 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 fixes — 00252 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 f2fbbb4 → a69fec5, 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 7368fdb → ed91b36, 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_friendly → matches.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 63ffd6a → c4a158c, 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): cce4e32 — client 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). c02bf1c — ATP 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 c9fd7fc→e923119, 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 424dd61→e959d53, 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 c70454f→47ba68f, 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): scorecard — getItemType 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-switch — preload='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 028e8d2→ad7e090, 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). Club — ClubJoinabilityBadge (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 f7ee2d7 → 545a441, 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 5cbc381 → 1ad6bb5, 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 2 — PublicClubProfileScreen 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 fa3c486 → 22a488f, 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 62eace9 → 21d779b, 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 90edd41 → 575d544, 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 2384aa0 → 875c424, 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 EXCEPTION → RAISE 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-derivedsignals_enforce_immutabilityfrom the00107ORIGINAL body (bypass =service_roleonly) instead of the00109FIX (auth.uid() IS NULL OR service_role), silently reverting it.signals_emit'sON CONFLICT (dedup_key) DO UPDATE(count-bump / re-surface) mutatespayload+created_at+resolved_at+expires_at— all immutable cols — and runs withauth.uid()NULL (no JWT in the SECDEF context), so every dedup RE-EMIT raised. Shipped silently because the trigger isBEFORE UPDATEonly: 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 theauth.uid() IS NULLbypass while KEEPINGsearch_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. Apg_procscan (public+private,HAVING count(*) > 1) found exactly two, both fromCREATE OR REPLACEwith a changed arg list spawning a new overload while the old one survived: (1)recent_client_errors(int)orphaned by00191's 2-arg form →recent_client_errors(20)matched both →42725 not unique(broke the documented dev SQL-editor fallback; the live readback usesdev_read_telemetryso app unaffected); (2)private.submit_match_score(uuid,int,int,int)orphaned by00084'sp_outcomeaddition — 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— regeneratedgenerated.types.tsfrom the live schema (53 migrations of drift;matches.tiebreak_scorewas split intoteam1/team2_tiebreak_scorein 00147).yarn typecheckstays 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 b79b106 → 2e6c6ae, 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 33bbb6f → 64453fb, 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 33bbb6f → 80f0a7c, 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):
get_app_configSR-key leak (CRITICAL) — any authenticated user could callrpc('get_app_config', 'service_role_key')and receive the full bypass-RLS JWT. The function was PUBLIC EXECUTE with no principal filter. Fixed in00184: REVOKE EXECUTE FROM PUBLIC + GRANT only toservice_role. Service-role key rotation is a pending decision (see Next concrete steps).anonymize_withdrawn_profilesPIPA §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 throwing42703 column not foundsilently since 00161 shipped — no withdrawn profile had been anonymized for ~6 weeks. Rewrote in00184against the actualprofilesschema.pi_access_logwrite path missing (CRITICAL) — the table was created in 00167 but no SECDEF RPC wrote to it.support_get_user_errorswas reading cross-user PII with no audit trail. KCC 안전성 확보조치 §29 requires ≥1-year audit log for PI access. Addedrecord_pi_accessSECDEF RPC in00185+ audit-log writes insupport_get_user_errors,get_user_consentsadmin branch, andexport-user-dataedge function. Sentry dropsusername/displayNamefromsetSentryUserper PIPA §15.- 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. - Soft-deleted profiles leaked across 7 read paths (HIGH) —
profile.supabase.tsleaderboard, member list, match-history opponent-name, and 4 other SELECT paths had nodeleted_at IS NULLfilter. Fixed with.is('deleted_at', null)sweep across all 7 adapter query sites in00186. signInWithOAuthsilent catch (HIGH) — mirrored thesignInWithEmailbug fixed in the prior session. Silent catch masked OAuth login failures. Fixed: re-throws after logging.- 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.
- 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: REVOKEget_app_configfrom PUBLIC; fixanonymize_withdrawn_profilescolumn references; drop deaddev_inspect_cron_statusvalue_prefix leak.00185:pi_access_logINSERT policy +record_pi_accessSECDEF RPC + audit-log writes insupport_get_user_errors+get_user_consentsadmin branch +export-user-dataedge function.sentry.ts+sentry.web.ts:setSentryUserdropsusername/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-rdedge 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):
ActivityIndicator→SkeletonHero+SkeletonCardon club-settings + club-dues (Protocol G).null→ 5×SkeletonRowduring 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 deadsubmit_manner_tagsplpgsql function (referenced v1manner_tagscolumns, never callable since 00124).
B2 (commit 7f8a216, migration 00186):
reject_under14_birth_yearBEFORE INSERT/UPDATE trigger onprofiles.birth_year— server-side gate mirrors the client-side onboarding block.delete_account_atomicSECDEF RPC —deleted_at+ push_token clear + consent revocations + audit log in a single transaction.useDeleteAccountswitches to this RPC.profile.supabase.tssoft-delete sweep:.is('deleted_at', null)added to 7 read paths (leaderboard, member list, match-history, club profile, partner candidates, public profile, club-members).useCancelRsvpgains offline-queue branch (RSVP_CANCEL_KEY+ registered executor) — was the only queueable mutation without an offline path.useRsvpthrottle leak fixed: rollback patches now execute before throttled early-return.signInWithOAuthcatch re-throws (mirror of signInWithEmail fix).replay-mutation-queueaddsMAX_REPLAY_ATTEMPTS=5+MAX_REPLAY_AGE=7dcaps — prevents indefinite replay of stale entries.
B3 (commit 5b8bde3):
- Parallel
Promise.allinstorage-migration.ts— saves 200–500ms on first-launch-after-upgrade (was sequential awaits). - Optimistic auth bootstrap in
session-provider: reads cached profile viaqueryClient.getQueryDatabeforederiveAuthState— saves 150–400ms cold-paint on warm cache. - Profile screen lifts 4 query hooks + passes via Protocol B prop to
FriendsPreview/AchievementsPreview/Attendance/MannerTagssub-sections (eliminates duplicate observer cost). club-members-screen.tsx:useMemoon Map/sections/grouped +useCallbackon renderItem — FlashList no longer re-renders on every parent state tick.applyPickupFilters: 7 chained.filtercalls collapsed to single-pass predicate AND.query-lazy-persistermanifest type:string[]→Set<string>(O(1).hasvsO(n).includesper query observer mount).ui/text.tsx:displaySm/displayMd/displayLgText role variants (20/28, 28/36, 36/44 700) — fills the gap betweenpageTitle(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 e982f11 → 3f80935): 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.signInWithEmailwas swallowing all errors in a silentcatchblock. 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.json→app.config.jsfunction form soGOOGLE_SERVICES_JSONenv var is interpolated correctly at build time. Staticapp.jsoncannot reference env vars.120295f— polyfill-safe fetch timeout:send-pushedge function usedAbortSignal.any([...]). Hermes on RN 0.83 Android does not shipAbortSignal.any. Replaced withAbortController+setTimeoutpattern that works on all JS runtimes. Pattern lesson: never useAbortSignal.anyin Hermes-targeted code without a feature-detect.5131eb6— correct DEV_USER_ID: hardcoded UUID (9427aabc-...) in diagnostic RPCs never matched the actualdev@twomore.testauth row (a45bfa0c-...). Pattern lesson: server-side constants referencingauth.usersrows 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 uniquededup_keyper 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 TABLEdeclared a return columnrecipient_user_id; CTEs inside the function body also selectedrecipient_user_idfrom thesignalstable. PostgreSQL raised42702 "column reference is ambiguous"at every call — the cron ran every 2 minutes and appeared healthy incron.job_run_detailsbut 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 TABLEcolumn names that collide with underlying table column names produce ambiguous references inside CTE bodies. PostgreSQL does NOT raise this atCREATE OR REPLACEtime — only at call time. AnyRETURNS TABLEfunction SELECTing from tables sharing output column names must qualify ALL internal refs.3f80935— generate icon assets from brand mark:scripts/generate-icon-assets.mjsgenerates 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 00177–00183 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 a8ca8e4 → af57b65): 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 useLogSignalConsent → useGrantConsent 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 f1b49fa → b0503dd): 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_statuscolumns (migration 00165); frequency caps insignals_pick_pushable()(system ≤10/day, session ≤5/day, social ≤3/day) +push_token_updated_aton profiles (migration 00166).25e2f10—expo-notificationsplugin inapp.json; supabase adapter creates 13 Android channels matching thesignal_categoryenum 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_consentledger (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):
ea15522—useNotificationSetupsoft-prompt refactor: OS prompt no longer fires at cold boot; newPushPermissionSoftPromptSheetsurfaces on first RSVP; 7-day token refresh;InteractionManagerAppRouter-ready gate.8f8c4f0— Consent domain entities (Purpose,PolicyVersion,UserConsent,UserConsentDisplay) + Zod schemas +getRequiredConsentsForJurisdiction('KR')matrix selector (4 mandatory, 3 optional) +CURRENT_POLICY_VERSIONSconstant.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):
9589323—ConsentRepositoryPort+ supabase adapter + registry wire;useUserConsents/useConsentHistoryquery hooks;useGrantConsent/useRevokeConsentmutation hooks.c1a128a— Onboarding wizard refactored 4 → 6 steps: new birth-year step with under-14 blocking gate; data-driven consent step fromgetRequiredConsentsForJurisdiction('KR'); migration 00174 addsprofiles.birth_year.b0503dd—SettingsConsentManagementScreen+ConsentHistoryScreen+ routes wiring; dual-write inhandleTogglePushto bothsignal_consent_log+user_consentledger.
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 inbase.adapter.tsdrops individual rows whose mapper throws, logslogger.warn+ SentryaddBreadcrumb('adapter', 'safeMap:<context> dropped row'). Wired into 35 adapter list call sites across 11 adapter files. Single-rowfindById/.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 —safeMapdrops 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 anyclubs.min_tier/max_tiervalue 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
isFetchedflags 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): NewuseScreenReady(queries[])helper combinesisFetchedflags. Critical-path queries lifted to screen root onprofile-screen.tsx+records-screen.tsx(TanStack dedups so sub-sections hit warm cache); unifiedSkeletonHero + 3× SkeletonCardrenders until everything settles. Before: cascading reveals as each section's independent query resolved. After: single skeleton-then-content commit.usePlayerStatsDetailextended to exposeisFetched(wasisLoading-only). - 6b — PersonalRecordsSection IA move: Moved from
packages/features/profile/src/personal-records-section.tsx→packages/features/records/src/personal-records-section.tsx. Now readsmatchesas a prop (notuserId+useMatchHistoryinternally), so records-screen passesscopedMatches— 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): NewprefetchSpectatorScorecard(client, sessionId)fires session + matches + rsvps + clubProfiles in parallel (clubProfiles chains after session resolves for clubId). Wired ontoHomeLiveAlertBannerviaonPressIn. 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 group0884812e-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 invalidatingmatchKeys.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 swappedrefetchQueries({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 groupcefc9285-4c5f-462f-894a-32f2c4ab86e3) — Two fan-out fixes bundled: (1) 5b (SessionCard fan-out): New bulk hooksuseSessionsRsvps(sessionIds[])+useMySessionPayments(userId)(port methodsfindBySessionIds/findByUser, adapters with bounded LIMITs, stable sorted-joined query keys, realtime invalidation extended to touch bulk keys).SessionCard+SessionMetaSectionaccept optionalmyRsvp/myPayment/rsvpsprops 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 hookuseSessionMatchScoreCalls(sessionId)(portfindActiveBySession, two-step SELECT,Map<matchId, MatchScoreCall>viaselect). LiveTab fires once, passesactiveCallprop toLiveMatchRow. Per-matchuseMatchScoreCallbecomes 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 group6bd8b5d6-87f7-4fd6-ad56-a371a38f774e) — ChallengeCard fan-out fix: New bulk hookuseClubsByIds(ids[])wrapping the existingclubs.findByIdsport method.ClubChallengesScreencollects every unique club id (viewer + opponents) once, fires the bulk hook, passesviewerClub+opponentClubas 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 groupe8e9a8da-ec38-40a2-810f-b87fd7d98903) — NewConfirmSheetprimitive in@twomore/ui(packages/ui/src/confirm-sheet.tsx). Tamagui Sheet atsnapPoints=[35], imperativeuseConfirm()hook, singleton state, hardware-back + swipe-down dismiss, button-style mapcancel|destructive|primary|default → outline/destructive/primary.ConfirmProvidermounted inapps/mobile/app/_layout.tsxbetweenSessionProviderandAuthGate. Top 5Alert.alertcall sites migrated: delete-account-screen, profile-screen, session-detail-screen, draws-row, live-row. Replaces nativeAlert.alertwhich ignores design tokens and renders as an OS-styled centered dialog. - Phase 2 (
da6def4, OTA groupe453ba1a-6867-4d42-988a-6167550b204b) — NewDataSectionprimitive in@twomore/ui(packages/ui/src/data-section.tsx). Encodes the loading→empty→content flow. Two modes: default (renders aSkeletonRowplaceholder duringisLoading) andhideWhenAbsent(returns null while loading AND when empty — for sections that should only appear once earned). Optionaltitleprop wraps inSectionBlock. 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.tsxdrops the!isLoadingProfilegate on these 5 sections so they mount immediately whenuserIdis available — eliminates the "section pops in after the rest of the screen finishes painting" anti-pattern. - Phase 3a (
6838c5d, OTA groupd680935c-00f4-43bf-ba4b-5fc4c7833d52) — Spectator scorecard's LIVE tab (packages/features/sessions/src/scorecard/live-tab.tsx) virtualized viaFeedList(FlashList). Off-screenLiveMatchRowcomponents (each mountingScoreEditSheet+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 inspectator-scorecard.tsxnow owns its own scroll/list container (LiveTab → FeedList; DrawsTab + RoundsTab → ScrollView each), removing the outer ScrollView wrapper fromrenderTab. - Phase 4 (
3c02074, OTA group0e7f1e52-10cf-47a6-a04b-3f591cfbf918) — Remaining 25Alert.alertsites across 13 files migrated touseConfirm: 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. NativeAlert.alertis now gone from all feature code and app routes. Style mapping:cancel→cancel,destructive→destructive,default|omitted→primary. Single-button info alerts usebuttons:[{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) —
4b339cein-app selectable stack + global handler ·dc9071aMMKV-backed crash log surviving process death ·7948728ErrorBoundary global reporter wired so Sentry actually receives crashes ·631eb3b@sentry/react-nativeExpo config plugin (native linking) ·93b2c66correct org/project slugs ·25d6806+1a17f9cmissingnotifications+signalsi18n namespaces (root cause of two alert-screen crashes) ·693f750hardened 9crypto.randomUUIDcall sites against bare-reference throw ·76b333d+b4c7232Date-rehydration safety after persisted-cache thaw + arch-test that prevents future drift (every timestamp field isDateeverywhere) ·3119af3ErrorBoundary at the home-route layout level. Dev-panelf78cb7b+0773b8eMMKV probe buttons surfacing the real exception path;e7c3becalways-show crash-log section + 테스트 button. Result: production crashes now reach Sentry with full stack traces and survive process death. - Dev-panel polish + scenarios —
8646f24scrollable 3-subscreen layout + Switch + per-step progress ·c03c7d7full i18n migration ·a7632ebUX polish (search, dedupe, persistence, layer icon, a11y) ·fe1064eunblock chip taps + drop search bar ·42f9a81four Phase 6 scenarios (attendance / manner / trust / GPS) ·0b2a0e4settings toggle to enable on any account ·5ec59e5manner_tagsv1→v2 schema-drift fix ingenerateMannerTags. Then8197dcbaudited everyhome_*scenario and fixed 6 confirmed time/status mismatches (the headline wasseedHomeMatchDayLivereusingseedMatchDayBase'sstatus='open'session under anin_progressmatch — impossible state, made the home view classifier render "starts in 1.5h" instead of LIVE). - Idempotency + GPS hardening —
919cdaf+04d6deethreadidempotency_keythroughsession.create+posts.create(defense-in-depth — both were fire-and-replay without dedup) ·835b709structuredHINTfields on00131GPS check-in RPC + threading throughDomainErrorso the client preferserror.hintover substring-matching English RPC text. Migrations 00136-00138 ship server-side with this work. - Spacing system enforcement —
5af4ad3new 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) ·21ab870full$Ntoken-system enforcement + inter-card gap sweep ·9080f67chip→content + temporal-pane card gaps tightened to$2·4baf337codified spacing-ownership rule (when two adjacent containers can each contribute padding to a gap, exactly ONE owns it — convention: the upstream chrome owns itspaddingBottom, the downstream content does NOT add a matchingpaddingTop) ·568688d+56dcdb3deletedMotionReveal/MotionStaggerItemwrapper components (the wrapper participated in parentgapcalculation as the measured child AND the slide-upytransient 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 TamaguienterStyle+animation.ea2b352adds named box-dimension constants (AVATAR_SIZE.XS..2XL+DOT_SIZE.XS/SM) atpackages/ui/src/box-sizes.ts, sweeping 13 feature files' worth ofwidth={32} height={32}magic numbers via Sonnet agent. - Pressable primitive + sweep —
a15eae7+da6c507introducePressablefrom@twomore/uias the canonical clickable primitive for everything that isn't aButton/PillNav/SegmentedTabs(cards, rows, chips, icons, FABs); 28 consumers swept,MotionPressabledeleted, ESLint rule blocks rawpressStyle={{}}on styled stacks inpackages/features/**. Encodes: TS-requiredaccessibilityLabel, 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),loadingprop. - Status badge canonical system —
3eb8055introduces 5 status-badge components (SessionStatusBadge,MatchStatusBadge,RsvpStatusBadge,DuesStatusBadge,ScoreStatusBadge) with a single source-of-truth status→variant table atpresentation/components/status/status-badges.tsx.97ce80fextracts variant maps to a sibling pure-data filestatus-variant-maps.tsso 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-C —
297cb61adds00138_realtime_publication_for_core_tables.sql(publishessessions / matches / rsvps / scoresto the realtime stream — they were silently absent so prioruseXxxRealtimehooks were no-ops at the wire) + wiresuseSessionRealtimeto detail screen ·ee95906user-scopeduseUserSessionsRealtimehook closes the home wiring gap ·4025ed8Phase C extends the same pattern to RSVPs and matches user-scoped. Channels are ref-counted inrealtime.supabase.tsso multipleuseXxxRealtime(id)calls with the same id share one subscription. - Server status auto-transitions —
5b3c122adds00137_session_status_auto_transitions.sql: hourly cron at :15 callsprivate.auto_advance_session_statuses()to close the drift loop. Rules:in_progresspastendTime + 8h→completed(tennis sessions can legitimately overrunendTime— the clock is not authoritative);open|lockedpastendTime + 4h→completed(with RSVPs) orcancelled(none); cascade match→session completion when every match in anin_progresssession reaches a terminal state. Per-match cascade fires immediately via thecascade_match_completion_to_sessiontrigger. CLAUDE.md "Session status invariants" rule documents this. - Session-card excitement layers —
e860c74RSVP fill bar + friends-going meta +size=defaulton home (visual urgency without clutter) ·88c88f2weather emoji (☀️ ⛅ ☁️ 🌧️ ❄️ ⛈️) replace Lucide cloud-family icons (instant recognition at 14px) ·13bd8f6temperature inline + first-pass proximity-aware date column ·0ace49dreverts the proximity-aware date column (user feedback: "more confusing than informative") and keeps proximity as a separate accent-tinted urgency dot ·4c45320per-second ticking countdown viauseNowTicking(gatedsetInterval, only mounts when the session is in the proximity window) +PulseDotfor live sessions matching the live banner. - OTA pipeline foundational fix —
f166109adds AppState=active foreground re-check (>5 min absence triggersUpdates.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 backfill —
97ce80fcoversformatProximateSessionTime+isInTickingWindow(22 tests),useNowTicking(6 tests withjest.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/uire-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.ts — buildSessionShareCard(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 migration — manner-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 (accurate→skill_accurate, communicative→communication, enthusiastic→enthusiasm), 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-preset — jest-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-2025 → ivorybridge-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 commit0a5a2690(2026-06-12, runtime 0.6.1, android+ios, from MAIN — first post-merge OTA): the owner-QA fix batch — ModalPanel canonical sizing (14e71d1bprimitive flexShrink/KAV + 8-consumer sweep,a75a9b40chip-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 (groupd5128319-6114-423d-939c-4b3369b2fa5c) published against commiteeb18b29(2026-06-11, runtime 0.6.1, android+ios, published by Codex from therelease/prod-readiness-hardeningbranch). The Codex batch published preview OTAs CONTINUOUSLY (one per checkpoint — the completion-slices ledger records per-slice groups incl.28b64198at thefba46e05checkpoint,d998a1e1,822bf6cb, …), so the preview channel tracks the branch tip, NOT main. The Expo account islbsky(app.jsonowner— theivorybridge-2025URLs 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 commitc774894(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-playerMatchScoreboardlayout + a new opt-inBadgealignSelfprop (centers pills inalignItems:centerrows) +owneri18n 대표→회장 (the Korean tennis-club term, also used by club-members);roleMap/hostIdcomposer plumbing removed end-to-end. Paired backend-only commite923119(no OTA) redeployedseed-scenariowith a deterministicgenerateUserSpecsemailPrefixparam that makes the cross-batch email collision — the guest-chip bug's root cause — structurally impossible. Prior OTA (group282d2957) was the intermediate role-chip-polish step. Prior OTA (groupbb7bcda0-6cde-4526-af71-9420e735f971) published against commitc3efdfd(2026-06-03, runtime 0.6.1, android+ios — 3 visual-QA bug fixes:e185586ButtondisabledStyle(disabled tier/cooldown gates now dim instead of looking enabled);484742ahome live card match summary refactored to MatchScoreboard + guest chips (was members-only text rows → guests showed "—");c3efdfdnon-member→club-preview redirect (RLS was already sound — closed the UX gap)). Prior OTA (groupc0eddaef-7ef8-4ebe-a13a-d10003f7c21d) published against commitac2519d(2026-06-03, runtime 0.6.1, android+ios — dev-panel QA scenarioshome_match_day_guests+home_tier_gatedfor visually checking guest chips + the tier-band CTA; seed-scenario edge fn redeployed). Prior OTA (group3c5e2cb3-199a-492e-a302-a8746bf4b9cc) published against commitc02bf1c(2026-06-03, runtime 0.6.1, android+ios — foundational-schema follow-ons:cce4e32client RSVP CTA tier pre-check +c02bf1cATP Phase A-v2 guest chips on MatchScoreboard). Prior OTA (group9e4cd3a5-43cd-4aa7-8999-80f7c9cef7e7) published against commit37fd049(2026-06-03, runtime 0.6.1, android+ios — foundational-schema batch:nationalityCodemapper,regionLabelvenue display coupled to migration 00231's region key flip,useRsvpeligibility_tiermessage). Migrations 00228–00231 applied to prod viadb push;grow-rdedge fn deleted from the prod project. Prior OTA (groupa825bae0-e81a-4fee-a666-a2a1516ed481) published against commit5b8bde3(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: groupb9cc97c7-ef3d-418d-b897-c872febef475against33bbb6f(CRITICAL security fixes:get_app_configSR-key revoke,pi_access_logRPC, Sentry PII drop, prod-env guards). Prior: group598ce80a-c1c9-497f-8309-69d0b0fd18fbagainst120295f(2026-05-19 — AbortSignal.any polyfill fix). Server-only commits5131eb6+5754a94+bddead1+402139eand migrations00177–00183all applied to live DB. Push pipeline verified:dev@twomore.testreceived real production signals on Android via FCM v1 after00183landed. Prior notable OTAs: consent system + notification hardening (2026-05-18, groupe216716a-95a3-49e8-8bdb-f943183ea25cagainstb0503dd). 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(groupcad5f231-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(group0884812e-2908-42b9-a7f5-9436d0912e9e— Network Protocols codified + realtime score-call filter + foreground catch-up fix);4250049(groupcefc9285-4c5f-462f-894a-32f2c4ab86e3— SessionCard bulk hooks 150→3 req + LiveMatchRow bulk hook 40→1 req);d464354(group6bd8b5d6-87f7-4fd6-ad56-a371a38f774e— ChallengeCard bulk hook). Prior 4-phase UI consistency sweep (2026-05-14):d68e7a8(groupe8e9a8da-ec38-40a2-810f-b87fd7d98903— ConfirmSheet primitive + top-5 Alert sites);da6def4(groupe453ba1a-6867-4d42-988a-6167550b204b— DataSection primitive + profile-screen stagger fix);6838c5d(groupd680935c-00f4-43bf-ba4b-5fc4c7833d52— LiveTab FlashList virtualization);3c02074(group0e7f1e52-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), group816b24a0-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_codeSECURITY DEFINER RPC + dropped legacyclubs_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(group1da95ca7-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 publishesclub_posts+club_post_comments+club_post_reactions+manner_tagstosupabase_realtime(already applied). Four new hooks:useClubPostsRealtime(clubId)on club-board-screen (usesapplyRealtimePaginatedChangefor the infinite query + invalidates pinned-strip on every event);usePostCommentsRealtime(postId)+usePostReactionsRealtime(postId)on post-detail-screen (in-place patch viaapplyRealtimeListChange; 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 whereuseClubPostsJSDoc 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). Onlyclub_alertsremains as a MEDIUM gap. Prior tip (runtime 0.3.0):d214735(group67725acb-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 publishesdues+attendance_recordstosupabase_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(groupdb24bfd7-2171-48ac-9cb9-12daf7958c01— persistent recap + disputed-match alert; HomeDisputedAlertBanner introduced). Older prior tip:52be084(group50ea6e1a-d22e-4d2f-bdc8-8ff899c66ae9— real names in home rows + ScoreStatusBadge swept + payment realtime). Older still:78e5f2c(group1a6af07e-e801-4e7c-84f7-b175c1253b90— viewer-aware chips + invariant status + match-row badges everywhere; SessionPaymentStatusBadge introduced). Older prior tip (runtime 0.3.0):e0c51b6(group1f6c47a9-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(group21717b1c-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(group2f7535de-df22-4bd4-84f4-b9dffc32fb1d— adapter any-disable sweep);7196b40(group7d0ad239-564f-4808-a313-965c3f226b43— LucideIconType type-safety refactor);26eac81(groupa19504fc-3655-4bf0-96e3-07ba98d23340— persist pickup-filter regions to filter_prefs);72331b8(group7a89b6f0-ee63-47b7-9a56-b25c2c431f96— GPS-default region in pickup-filter);744391f(groupe89801f3-c930-4e26-aaa4-ab37cf8b5b19— Phase 5/2B+C+D share targets);43fc4f2(groupb79362fe-d15b-4a09-804d-71e73cc7dd21— Phase 5/2A match-result share);e80e003(group0c41d2e6-b1c5-4033-a01d-3f5216e680eb— Phase 5/1A session-invite share);5e34282(groupaa0157bf-4433-463c-b3f4-e5f87b47e054— ARCH-8 ESLint rule + useGpsCheckin port-routing);e30ca2d(groupe360c957-1335-4c14-808c-c1cf9009c237— monolith decomposition);7a5cf43(group4340252f-b882-4c19-b1c8-fadad022184b— audit-cleanup sweep, ratchet → 0);d220ea3(group6798e7db-e427-4b26-bde7-c934b9c222cb— Phase 14f freshness ratchet CLEARED + mutation-key shrunk 8 → 1);90b0a1e(group523198a8-64be-4115-b09b-9ab9bdd68323— per-record strike excuse + GPS hardening). Older tips (runtime 0.2.0):610a70b(groupe115354e-cfc1-4ea8-8b25-b84fd9c46ad5— 총무 attendance roster on session detail; requires migration 00129);99805c5(group9dfca4d8-a2d1-402d-95f1-9833393ffe89— 총무 attendance dashboard + excuse RPC + RSVP cooldown gate);85650e0(group523dbf25-b95c-4c97-8ab3-bf46dd81d476— Part 3a attendance settings + private profile section);09e16b0(group46dfb450-cec4-4e2a-b6a3-71e989dd3db3— trust badge sprinkle on member + author rows);ee08359(groupa58ba202-5ed0-4a08-8e85-24ec2a6002b4— Trust Tier badge on profile hero);a3ac48f(group8aae7774-7adf-4ef6-b86c-62c7041148cb— Phase 6 Manner Tags UI);19f85cf(groupcaee32ba-e889-4df8-987e-0cffecf33fff— first OTA with the networkMode fix; prior OTAs had a dead offline path for all 4 per-mutation hooks);ee47246(groupfc3c823c-87f6-437d-8910-e449cb9b091c);fb9eee7(group6a6233a7-3238-45a4-aaac-a5aace90a5f6);203a196(groupfc76a339-893c-44b8-b1bc-c8be20747f78);d5248f9(groupe137a1a5-846f-42b0-a246-2e9bba84ea95);e2ec867(group519cc724-8f01-48d5-901e-c088b0a4e83c);94247a6(groupd08c593b-c640-4741-af09-5c841b346612);8ee82ec(group459229a4-e5dd-4b4a-91a2-721553fd3844);eeaa6c3(group966f2881-aeec-4eb3-bed1-58597dfd8856);b45e243(no OTA — backend-only commits);9d553e3(group5df1bcff-5146-4275-8c84-0ec0d434185a);c3bdf7b(groupfb342aa6-3ae7-438a-981c-35229bc6a03f);8d17f6a(group80a143a6-ae70-44b3-8fcb-c520b586b5da);85dfbcf(group26d605e0-f71b-42fd-893b-68251136f562);b71c055(groupc498fd1d-a978-480c-b374-2897eada186d);c76060f(group636eefcd-7dd3-41d5-8121-17c3b8b2d185);bccbf8a(group280c76e1-d11d-4bd8-83bc-d2d0cc0b3ecf);3cdfd06(group8c22f210-8e18-41bf-8e1e-8090cd68df2b);7df68e0(groupbb633103-b5df-47f3-8bd2-58709cae78ee);5779cad(group8f757e09-f0b4-4a5e-8691-3565f7c14abc);f5c7e8f(groupbd1a4ef6-8d03-4aa1-92d6-368dde2b5e71);9110e33(group2c773756-daa6-4e7b-acb4-c0b1386ec9d3);1ad95f1(group08bba047-d121-42c9-8717-9509ff29cb95);33ae559(group9084affc-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 + fixed —app_config.service_role_keyheld a REVOKED API key since 2026-06-10 ~19:00 UTC (rotation during the Codex EAS provisioning is the suspected trigger), so EVERY cronnet.http_post(send-push every 2 min, all weather jobs, receipts) got 401 "Unregistered API key" at the gateway while the 00202 watchdog wrote hourlyCRON HTTP FAILUREFATALs 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 validTWOMORE_SECRET_KEYenv intoapp_config(digest-verified7d48dd36…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 run019eb775SUCCEEDED 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, build8b32e688) — the device-level proof the ledger required; slice 8's evidence packet is the ledger itself. Newmaestro-e2e-rerun.yml(validated) reuses an existingbuild_idfor 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 inee6f1bd. No open records tracking. - CI is now active under
ivorybridge-2025— the workflow at.github/workflows/ci.ymlfires on every push and PR. Branch protection onmainwith required-status-check ontypecheck + tests + lintsis the next step (GitHub UI action by the user). - Phase 14d CLOSED + networkMode fix live (post-
19f85cfOTA): 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'snetworkMode: 'online'default paused mutations beforemutationFncould run; fixed tonetworkMode: 'always'in19f85cf. OTA pickup test: go offline, tap RSVP — should now enqueue (prior OTAs silently paused rather than enqueued).00123_public_submit_match_score_idempotent.sqlstill requiresnpx 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 underivorybridge-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 requiredrift — architectural erosion(non-blocking by design). - Supabase type-drift gate activation: waits on
SUPABASE_ACCESS_TOKENrepo secret. - 5 large feature screens (
create-session-screen1875,match-board-screen1800,create-club-screen1602,session-detail-screen979,records-leaderboard-screen883): 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.mdand DO NOT gate the engineering backlog below. Pre-counsel cleanup brief lives atdocs/counsel-prep-brief.md— hand to lawyer when engagement starts.
App engineering backlog (v1.1 — operational hardening, ~4 weeks post-launch budget):
- ✅ SECDEF
search_path = ''round 2 — Wave E3 (commit595db3d, migration00194) hardened 7 more functions (1 IMMUTABLE correctly skipped). - ✅ Weather edge function rate limits — Wave E2 (commit
64453fb, migration00193) gated 4 weather functions onCRON_SECRETshared-secret header. - ✅ Read-side displayName enrichment — Wave E1 (commit
fe60a94) shipsuseProfilesByIds+resolveSignalBody({profileLookup})+ restored name-bearing i18n strings. - ✅ Cron-failure watchdog — Wave E5 (migration
00195, this commit). Pure-DB watchdog:cron_watchdog_statetable +private.check_cron_health()SECDEF RPC scheduled every 15 min via pg_cron. Writesclient_errorsrows (severity='critical', user_id=NULL) when any cron job hits 3+ consecutive failures, deduped to 1 alert/hour per jobname. Surfaces in the existingrecent_client_errorsdev-panel readback path — no new dashboard/webhook needed. Would have caught the 2026-05-17 push-pipeline outage within 45 min instead of 33h. - ✅ Critical-path test coverage — Wave E4 (commit
f57c7ee) shipped 17 tests acrossuseUpdateDuesStatus,useMarkPaymentPaid,useMarkPaymentWaived,useDeleteAccount,useGrantConsent,useRevokeConsent. - 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 NOTHING → DO 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 00263–00265 = 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 00238–00258 = 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) · 00239–00242 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/00247–00251 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 · 00255–00258 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: 00207–00210 = 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 RETIRED — RealtimeServicePort, 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.