Implementation Roadmap — 2026-07 Audit Remediation
Status: Archived
The master execution plan for the whole-app audit. It sequences the slices defined across the twelve problem-space solution docs (solutions/) into dependency-ordered waves, each slice carrying its own E2E verification gate.
- Findings (what's wrong):
../history/(16 blocks, now archived alongside everything else) - Solutions (how each is fixed, with research):
solutions/psN-*.md - This doc (in what order, with what gates): the sequencing authority.
Supersedes the earlier triage in implementation-plan-2026-07-audit.md; cites wiring-plans-2026-07-backend.md and design-specs-2026-07-ui.md for object-level detail rather than repeating.
Standing rules (apply to every slice)
- E2E-verify at the end of each slice (owner directive). Beyond
yarn check: prove the flagged problem is actually fixed end-to-end — a seeded scenario exercising the real RPC+RLS+client path (scripts/run-scenario.mjs), an OTA on-device check for UI, or a Maestro flow where device proof is required. Record the evidence in the slice. - Backend slice = migration → local
db reset+ psql/synthetic probe →yarn check→ commit → OWNER prod push. Client slice =yarn check→ commit → OTA → baseline advance. The prod-push step is owner-gated (the permission classifier blocks agent prod deploys). - Migration numbers are assigned at implementation time from the then-next-free number. The
00388+ numbers in the solution docs are provisional and overlap across docs (parallel authorship) — this roadmap fixes the order, not the integers. Assign sequentially as each slice lands. - One migration per slice,
CREATE OR REPLACE+DROP … IF EXISTS, SECDEF hygiene (search_path=''+REVOKE … FROM PUBLIC, anon+ explicit GRANT), explicit per-table GRANT for new tables,signals_emitfor events, no PII in JSONB. - Docs updated per slice (screen-blueprint / canon / rebuild-log as touched).
Wave 0 — Unblock (owner action, gates everything)
| # | Action | Why first |
|---|---|---|
| 0.1 | npx supabase db push — the already-shipped 00385 (alert wiring + TOCTOU restore), 00386 (PortOne anon-revoke), 00387 (venue RPC lockdown + avatar ownership) | Two are live unauthenticated prod holes. Nothing else should ship on top of an unpushed security baseline. |
| 0.2 | DONE — PS11 Slice 1: fixed 3 of the 19 pgTAP files that had drifted from schema/RPC changes (the "already pass locally" claim was never actually run — clubs.tags dropped in 00278 but referenced in 00016_rls_club_media_growth.test.sql's fixture AND live in get_club_growth_analytics itself, fixed via migration 00392; submit_match_score's split tiebreak-score params in 00002_rpcs.test.sql; 00010_rls_clubs.test.sql test 13 rewritten to call join_open_club after 00145/00146 replaced direct self-join INSERTs), then wired supabase test db into CI as the pgtap job (.github/workflows/ci.yml) + yarn db:test | Turns on the verification harness that every Wave-1 security slice's guard plugs into. All 19 files green, 210 assertions. |
Wave 1 — P0 Security (live exposure)
Order within the wave = exploitability. Each ships with the pgTAP guard that prevents its regression (PS11).
| Slice | Space | What | Gate |
|---|---|---|---|
| 1.1 | PS1 | profiles_select PII leak → route reads through the visibility-enforcing RPC pattern (public/private split); verified blast radius = 7 read methods | probe: unrelated user can't read phone/kakao_id/push_token |
| 1.2 | PS1 | profiles_update column-forge → extend the field-protection trigger to trust_tier/singles_elo/doubles_elo/rating_confidence/kakao_id/nationality_code | probe: self-PATCH of trust_tier reverts |
| 1.3 | PS1 + PS9 | venue_corrections GRANT lockdown (RPC-only, 00329 template) + resolve_venue_facts per-row exception isolation (malformed row no longer aborts a 400-venue batch) | probe: raw insert blocked; malformed correction isolates |
| 1.4 | PS1 + PS6 | guest_applications RPC-only lockdown + capacity/status gate in decide_guest_application | probe: raw PATCH approve blocked; over-capacity rejected |
| 1.5 | PS1 | app_config explicit REVOKE + deny-all RLS (holds service-role key) | probe: anon/authenticated select denied |
| 1.6 | PS1 + PS11 | Structural SECDEF sweep — REVOKE … FROM anon on the ~35 remaining anon-executable SECDEF fns; corrected ALTER DEFAULT PRIVILEGES; fix the false canon claim (data-and-hooks.md:89); pgTAP ACL-hygiene guard (G1) + REVOKE-idiom migration linter (G2) | pgTAP: no state-mutating SECDEF fn is anon-exec without a guard |
| 1.7 | PS1 + PS11 | Explicit per-table GRANT sweep (7% → 100%) + table_grant_hygiene pgTAP (G3) | pgTAP passes |
Wave 2 — P1 Correctness (money & data integrity)
No live exploit, but wrong. Grouped by the shared trigger/table families the research identified so co-located changes land together.
2A — Match & settlement integrity (PS2 + PS4 share the matches trigger family — land as a coordinated set)
- 2A.1 PS4:
submit_match_scorestatus guard (reject resubmit on completed → route via 00308 correction) +agree_to_correctionwritesscore_status(else corrected-disputed sticks). - 2A.2 PS4: complete the
matchesprotection surface — RLS status predicate on team-slot columns + fixprotect_matches_scoring's deadtiebreak_scorereference (team1/team2_tiebreak_score silently unprotected since 00147). - 2A.3 PS2: widen
enforce_settled_match_participantstrigger to the four team-slot columns (settle-gate bypassed by swaps) — same trigger family as 2A.2, verify ordering. - 2A.4 PS4: participant-slot validation (members / confirmed RSVPs).
- 2A.5 PS4: disputed state machine —
reject_callwritesscore_status='disputed'+ resolution path → makes the deadHomeDisputedAlertBanner/ScoreStatusBadgelive. - Gate: seeded match scenario — completed-match resubmit rejected; unsettled swap rejected; disputed round surfaces + resolves.
2B — Payments lifecycle (PS2)
- 2B.1 hold-lifecycle signal gaps: 00384 inline completion-cancel emits
hold_expired; payer-cancel resolveshold_created. - 2B.2
session_paymentsUPDATEWITH CHECK(protect amount/user_id) via theprotect_columnsidiom. - 2B.3 client:
use-live-session-datainvalidatessessionPaymentKeys(stale-hold UI, U3). - Gate: seeded fee-session scenario — hold states + signals correct; UI reactive.
2C — Dues & attendance (PS5)
- 2C.1 canonical
dues_day-precise "overdue" — one function/view; route all 5 surfaces through it. - 2C.2 partial payment — wire the dead
payment_historyledger (receipt rows) +dues.paid_amounttrigger rollup + entity/mapper/adapter (entity-change checklist). - 2C.3 waive — audit trail +
dues_waivedresolution (unresolvable-critical-push fix). - 2C.4 strike-alert resurrection — edge-triggered emit + decay-resolve (the decay half is call-site-independent, ship first).
- 2C.5 ex-member dues exclusion — active-membership INNER JOIN in cron + admin-snapshot.
- Gate: seeded dues scenario — overdue agrees across surfaces; waive clears push; partial records amount; ex-member gets no push.
2D — Session & RSVP lifecycle (PS3)
- 2D.1 canonical waitlist-promotion primitive (
dequeue_next_waitlistedFOR UPDATE SKIP LOCKED +promote_next_waitlisted) — cancel/expiry/forfeit all call it; kill the dead path. - 2D.2 capacity TOCTOU —
SELECT … FOR UPDATEon the session row before the count (00313 idiom). - 2D.3 allowlist status-transition guard (replaces the 2-rule blocklist) + RSVP-to-terminal-session block.
- 2D.4
sessions_select_publicRLS (visibility='public' AND public_invite_enabled) — unblocks the pickup preview and PS9's funnel leak. - Gate: concurrency probe (two last-seat RSVPs) + non-member preview loads + illegal transition rejected.
2E — Membership & account lifecycle (PS6 + PS7 — one canonical deletion path, two owners)
- 2E.1 PS6: sole-owner succession (auto-transfer to longest-tenured admin, else auto-archive) + ghost-member deactivation on deletion + backfill.
- 2E.2 PS7:
delete_account_atomiccompleteness — session-domain tables +banned_until='infinity'(deleted user can currently re-auth and self-cleardeleted_at) +release_departed_user_open_rsvpsbackstop. - 2E.3 PS7: marketing-consent withdrawal backfill (after the client label fix bakes).
- Gate: seeded deletion scenario — no ghost membership, no orphan RSVPs/holds, club not locked, account can't re-auth.
Wave 3 — P2 Dead pipelines & surfacing (high user value, low risk, mostly OTA)
| Slice | Space | What |
|---|---|---|
| 3.1 | PS6 | Join-request approval UI (CRITICAL dead pipeline — server ready) — wire useClubJoinRequests/useDecideJoinRequest into the members screen (Wireframe A) + the missing admin-snapshot invalidation key |
| 3.2 | PS8 | Push-tap routing (CRITICAL) — both producers converge on getSignalRoute via one {category,context} shape + tests; retire the duplicate local reminder scheduler; DM emitter writes context.threadId |
| 3.3 | PS8 | Admin-count single source (sumAdminAttentionTotal) + signalMatchesScreen full category map |
| 3.4 | PS7 | OAuth cancel/failure error-surfacing (one throw e) + login screen for when EXT-11 creds land (credential-gated parts flagged) |
| 3.5 | PS7 + PS8 | 알림 설정 screen (Wireframe C) — claim the dead /settings/signal-preferences route, edit signal_preferences (13 cat × in-app/push/severity/quiet) + retire/adopt the 6 dead notify booleans |
| 3.6 | PS9 | Discovery UX — confidence-tier badge honesty, cause-aware empty states, single region-filter component, naver_place deep-data surfacing (hours + review-keyword chips; zero migration), reopen path |
| 3.7 | PS10 | Records UX — unified zero-match empty state (Wireframe D, the most common launch state), sparkline direction, solo-region leaderboard, "내 순위", tap-to-profile on podium/partners, TierInfoSheet Tamagui rebuild, DEFAULT_ELO=1000, challenge bridge → app root |
| 3.8 | PS6/PS9/PS10 | Membership/venue lifecycle signals (member-left/removed/settings/unarchive) + manner_tag_received |
Wave 4 — P3 Guardrails (interleave; ratchets applied as files are touched)
PS11 remaining slices (G4 no-localecompare-sort ESLint rule + 14-site ratchet baseline; G5 signal completeness test; G6 knip dead-export detection; G7 rls_with_check_completeness pgTAP). Most are ratchets that also retire the ICU localeCompare sites the earlier waves touched (PS6/PS9/PS10 share the plainStringCompare module — land it once, in the first wave that needs it).
Wave 5 — PS12 Engineering streamline (LAST, gated on Waves 1-4 E2E-confirmed)
Only after every problem space ships AND E2E-confirms: the four conformance passes (SSoT / prefer-libraries + knip / hexagonal-SoC / test-suite backfill). Reserved as the final wave; slices defined against the measured residue. See solutions/ps12-engineering-streamline.md.
Cross-wave dependency graph (the load-bearing edges)
- Wave 0.1 (prod push) → everything. No slice ships on an unpushed security baseline.
- Wave 0.2 (CI pgTAP) → all Wave-1 guards.
- 2A.2 ↔ 2A.3 (same
matchestrigger family — coordinate). - 2E.1 ↔ 2E.2 ↔ 2C.5 (one deletion truth: membership deactivation, session-domain cleanup, dues exclusion must agree).
- 3.5 depends on 3.4's consent-label fix baking before 2E.3's backfill.
plainStringCompare/PlayerIdentityChip/ region-filter component are shared primitives (PS6/PS9/PS10) — build once, first consumer owns it, others cite.- Wave 5 depends on Waves 1-4 complete + E2E-confirmed.
Scale
~55 slices across 12 spaces. Roughly: 7 P0-security, ~20 P1-correctness, ~12 P2-pipelines, ~7 P3-guardrails, ~4 P12-streamline, plus the shared primitives. Backend slices are owner-gated at the prod-push step; client slices ship on OTA autonomously.