PS4 — Match & Rating Integrity
Status: Archived Draws from: B4-matches-rating-backend.md, U4-matches.md. Cites, does not repeat: wiring-plans-2026-07-backend.mdPY1 (Parts B/C/D are this doc's primary scope; Part A is PS2's — see §5 boundary note), M1–M5, and ps2-payments.md §2a/2c (trigger-ordering proof + protect_columns vs. RLS WITH CHECK research, reused verbatim rather than re-derived). Verification method: every empirical claim below was re-run live (2026-07-11, local Postgres supabase_db_twomore-v2, migrations through 00387 applied — confirmed via supabase_migrations.schema_migrations) inside a single BEGIN...ROLLBACK transaction, not inferred from reading code alone. Script + full output preserved in this session; every numbered probe below cites the exact statement and its exact returned row(s).
1. Problem statement
Three CRITICAL defects and four MEDIUM/LOW defects share one root cause pattern already named by B4/U4/the wiring plan's PY1: the match domain has several "server backstop" mechanisms, each keyed to a specific column-list or state field that the actual authorized write path doesn't hit — so matches that read as fully protected (SECDEF RPCs, RLS policies, protect_columns triggers, a CHECK-constrained state field) have live gaps an authenticated club admin/match_director can walk through today, no attacker model required.
- CRITICAL —
submit_match_scorehas no status guard, silently bypassing the00308correction-consent flow, and desyncing the displayed score fromelo_history(B4#1). Empirically confirmed, not just read (§2a): a secondsubmit_match_score(...)call against an already-completedmatch succeeds silently —matches.team1_score/team2_scorechange to the new values, butapply_match_ratings' own idempotency guard (00385:1284-1290) no-ops the rating body entirely, soelo_historystays frozen at the first call's values. Post-probe state:team2_scorechanged2→3,elo_historyrow count for the match stayed at4(unchanged) across both calls. No error, no signal (thecompleted→completedwrite is invisible tocascade_match_completion_to_session/trg_alert_match_completed, both of which only fire on anOLD.status IS DISTINCT FROM 'completed'transition). - CRITICAL —
matches.score_statusis never written to'disputed', so the fully-builtHomeDisputedAlertBanner/ScoreStatusBadgeUI is permanently dead (B4#2, cross-refU4#1).reject_call(terminal00385:404-462) deletes the pendingmatch_score_callsrow and emits amatch_score_disputedsignal (a bell notification) but never touchesmatches.score_status.findDisputedByUseralways returns[]. - CRITICAL — two independent unprotected paths let a completed/live match's roster or score be rewritten (
B4#3, cross-refU4#2 /B3#1 — payment half owned by PS2, see §5):- (a) RLS.
matches_update(terminal00013:76-89) authorizes any active clubowner/admin/match_directorwith no status predicate, noWITH CHECK. Empirically confirmed (§2b): as an authenticated club owner, a rawUPDATE matches SET team2_player2_id = <non-participant>against an already-completedmatch succeeds (UPDATE 1), silently rewriting who played on which team after the match is scored and rated. - (b)
protect_matches_scoring's column list is half stale.00067'sprotect_columns('team1_score', 'team2_score', 'tiebreak_score', 'status', 'started_at', 'completed_at', 'verified_by', 'score_status', 'participant_ids')was written against a schema that00147later changed:ALTER TABLE matches DROP COLUMN tiebreak_score(split intoteam1_tiebreak_score/team2_tiebreak_score), but the trigger's arg list was never updated. This is a new finding, not called out inB4, found by tracing the live schema against the live trigger definition, then confirmed empirically (§2c):protect_columns()diffsto_jsonb(OLD)->colvs.to_jsonb(NEW)->col; for a column name ('tiebreak_score') that doesn't exist in the row, both sides are SQLNULL, soIS DISTINCT FROMis alwaysfalse— the protection entry is a silent no-op. Probe: as the same authenticated admin,UPDATE matches SET team1_tiebreak_score = 9, team2_tiebreak_score = 1on the same completed match — both values persisted (9/1, not reverted). A same-transaction control (UPDATE matches SET team1_score = 1, team2_score = 1— a correctly-named protected column) did revert (read back as6/3, the pre-tamper values), proving the asymmetry is real and not an artifact of RLS or role. - Team-slot columns (
team1_player1_id,team1_player2_id,team2_player1_id,team2_player2_id) were never in the protected list at all (00067predates their existence as a concept needing protection) and remain fully open to any client write that clears RLS.
- (a) RLS.
- MEDIUM — no server-side membership/RSVP check on match participant slots (
B4#5). Empirically confirmed (§2d): as the club admin,INSERT INTO matches (..., team2_player2_id) VALUES (..., <user with zero club_members row and zero rsvps row for this session>)succeeds without error. Cross-club/guest rating is intentional by design (B4#14 —profiles.elo_rating/club_elo_ratingsdual-ELO model, 00017/00236); the gap is narrower — nothing confirms the player was ever offered a seat in this session at all. - HIGH (product-decision gap, not a bug) — referee-transfer is a fully wired backend capability (5 RPCs, 5 mutation hooks) with zero UI consumer, deliberately (
B4#9,U4#3). In-code comments (live-tab.tsx:165: "Referee UI removed — canCall falls back to participant OR session host") confirm the product already resolved "who can score a match" without the referee concept —canCall = (isParticipant || isHost) && match.status === 'in_progress'(live-tab.tsx:169, traced directly) never readsreferee_id. This needs an explicit call, not indefinite dead weight — §4.5. - MEDIUM —
rsvps_enforce_tieruses cross-poolelo_rating, not a format-aware rating (B4#4 / wiring planM1). Already fully specified by the wiring plan; adopted here without re-derivation (§4.6). - LOW-MEDIUM — no round-level player-uniqueness invariant (
B4#6 /M3) and orphanedverify_match_scorelegacy RPC (B4#7 /M4). Already fully specified by the wiring plan; adopted here (§4.6). - LOW —
signals_on_match_insert/signals_on_match_updateusesearch_path = publicinstead of''(B4#8 /M5). Folds into the cross-cuttingD8search-path sweep (22 functions site-wide, not match-specific) — not a PS4 migration; cited only (§5).
Confirmed NOT bugs (re-verified, included for calibration): the tiebreak-aware ELO winner fix (00323) is verbatim-preserved through the terminal apply_match_ratings (00385:1193-1629) — traced line-by-line, no regression. The concurrent-finalization race (submit_match_score vs. agree_to_call racing, not sequential) is safe under Postgres row-locking — agree_to_call's FOR UPDATE on matches (00154) serializes it. Neither needs action here.
2. Research
2a. submit_match_score re-callability + rating desync — traced + empirically reproduced
Terminal definition 00324_audit_batch15_two_sided_tiebreak_submit.sql:26-82:
UPDATE public.matches
SET team1_score = p_team1_score, team2_score = p_team2_score,
team1_tiebreak_score = p_team1_tiebreak_score,
team2_tiebreak_score = p_team2_tiebreak_score,
outcome = p_outcome, status = 'completed', completed_at = now()
WHERE id = p_match_id; -- no status predicate
DELETE FROM public.match_score_calls WHERE match_id = p_match_id;
RETURN private.apply_match_ratings(p_match_id, p_outcome);public.submit_match_score's optional p_idempotency_key (00324, Phase 14d) does not close this gap — it only short-circuits a replay of the exact same key; a genuine second call (new key, or no key — the client hook may not always supply one) still reaches private.submit_match_score unguarded. The idempotency key and the missing status guard are orthogonal: one protects against network retries, the other against a deliberate re-submit.
Probe (§ live, BEGIN...ROLLBACK, 00387-head local DB):
SELECT private.submit_match_score(:match_id, 6, 2, NULL, NULL, 'decisive');
-- → status: completed, team1/2_score: 6/2, elo_history rows for match: 4
SELECT private.submit_match_score(:match_id, 6, 3, NULL, NULL, 'decisive');
-- → returns {"message": "ratings already applied", "elo_changes": []}
-- → matches row: status still completed, team2_score now 3 (changed!)
-- → elo_history rows for match: still 4 (unchanged — frozen at the 6-2 result)This is the exact desync B4 #1 predicted, reproduced rather than assumed. apply_match_ratings's idempotency guard (00385:1284-1290, IF EXISTS (SELECT 1 FROM elo_history WHERE match_id = ... AND reason = 'match')) is correct and load-bearing for its own purpose (safe retry from recompute_all_ratings/backfills) — the bug is entirely that submit_match_score should never have reached it a second time.
Idempotent-write vs. reject-on-terminal — which pattern fits: two established patterns exist for "a write against a resource that's already in its terminal state": (1) idempotent no-op — the second write is silently accepted and produces the same observable result as the first (correct for identical retries, e.g. a network-retried POST with the same idempotency key — which submit_match_score already does correctly at the public. wrapper layer); (2) reject-on-terminal — the second write is refused with an actionable error, correct when the second write's intent may differ from the first and silently accepting it would discard information (a payment can't be "re-captured" with a different amount; a completed match's score can't be "re-submitted" with a different score without that being a correction, which has its own consent requirements). submit_match_score needs pattern (2) — the codebase already has exactly this shape at propose_score_correction (00308:67-70): IF v_match.status <> 'completed' THEN RAISE EXCEPTION 'Only a completed match can be corrected' USING ERRCODE = 'P0001', HINT = 'not_completed'; END IF;. The fix mirrors this precedent in the opposite direction.
2b. matches_update RLS status gap — traced + empirically reproduced
Terminal policy 00013_payment_history_and_match_director_policies.sql:76-89 has USING but is reused for WITH CHECK (Postgres default when omitted) and carries no status predicate:
CREATE POLICY matches_update ON public.matches FOR UPDATE TO authenticated
USING (EXISTS (SELECT 1 FROM sessions s JOIN club_members cm ON cm.club_id = s.club_id
WHERE s.id = matches.session_id AND cm.user_id = auth.uid()
AND cm.role IN ('owner','admin','match_director') AND cm.is_active = true));Probe: SET ROLE authenticated + request.jwt.claims set to the club owner's sub, against the match already flipped to completed by §2a:
UPDATE public.matches SET team2_player2_id = '<nonmember-id>'
WHERE id = :match_id;
-- → UPDATE 1 (succeeds)
-- → team2_player2_id now the nonmember's id, status still completedContrast matches_delete (00011_rls_indexes_triggers.sql:329-335, live — no later redefinition), which does gate on status = 'scheduled':
USING (public.is_club_admin(...) AND status = 'scheduled')— proving the omission on matches_update is a genuine asymmetry, not an intentional design choice.
Resolving the wiring plan's own open question — should in_progress stay in the allowlist? PY1's draft Part C flags: "decide: should a match_director be able to edit an in_progress match's team slots (mid-session substitute UI)... if yes, keep in_progress in the allowlist." Traced the actual sanctioned UI to answer this rather than guessing: match-board-screen.tsx:208-210 — canEditLiveRound = isLive && roundMatches.some((m) => m.status !== 'completed' && m.status !== 'cancelled'), where isLive = sessionStatus === 'in_progress' — gates LiveRoundEditorSheet (match-board-screen.tsx:512-522), which calls useUpdateMatchTeams (live-round-editor-sheet.tsx:52,204) — the sameupdateMatchTeams adapter SwapSheet uses. This sanctioned flow does edit team slots on in_progress-status matches (mid-round substitution, not just pre-round). Answer: in_progress must stay in the allowlist — narrowing to scheduled-only (matching matches_delete's precedent) would break LiveRoundEditorSheet for every live session with an odd substitution mid-round. SwapSheet itself (match-card.tsx:350) only mounts for scheduled, but the RLS predicate has to cover both sanctioned UI callers of the same underlying write.
2c. protect_columns stale-argument bug — traced schema history + empirically reproduced
00067's protect_matches_scoring trigger was written when matches had a single tiebreak_score column. 00147_match_rules_and_two_sided_tiebreak.sql:46 later ran ALTER TABLE matches DROP COLUMN tiebreak_score; (replaced by team1_tiebreak_score/team2_tiebreak_score, added in the same migration) — but no migration ever updated protect_matches_scoring's TG_ARGV list. Reading private.protect_columns()'s live body directly:
FOR i IN 0..TG_NARGS-1 LOOP
col := TG_ARGV[i];
IF (new_json -> col) IS DISTINCT FROM (old_json -> col) THEN ...new_json -> 'tiebreak_score' and old_json -> 'tiebreak_score' are both SQL NULL for a row with no such key — NULL IS DISTINCT FROM NULL is false per SQL's null-handling semantics for IS DISTINCT FROM (the one comparison operator that treats two NULLs as not distinct) — so this list entry has been a permanent, silent no-op since 00147 shipped, with no test or runtime signal ever surfacing it (there's nothing to observe — it just never reverts anything, which looks identical to "nothing tried to write it" from the outside).
Probe (same transaction, continuing from §2b):
UPDATE public.matches SET team1_tiebreak_score = 9, team2_tiebreak_score = 1
WHERE id = :match_id;
-- → both values PERSIST (9, 1) — not reverted
-- control, same transaction, a correctly-named protected column:
UPDATE public.matches SET team1_score = 1, team2_score = 1 WHERE id = :match_id;
-- → REVERTS to 6, 3 (the pre-tamper values) — protect_columns works correctly hereThe control proves the mechanism itself is sound (role, RLS, and trigger firing are all correct) — the defect is scoped precisely to the one stale argument name. This is the same class of bug as the codebase's own documented "verbatim from an outdated migration" lesson (ps2-payments.md §1, citing 00385's TOCTOU regression) — a schema evolves, and a downstream artifact that names columns by string keeps referencing the old shape with no compiler or constraint to catch it (TG_ARGV is untyped text[]; Postgres has no way to validate a protect_columns() argument against the target table's actual column list at CREATE TRIGGER time).
2d. Participant validation — traced + empirically reproduced
Neither matches_insert (00013: is_club_admin(get_match_club_id(session_id)) only) nor any trigger checks that the four player-slot values are club members or confirmed RSVPs of the session. Probe: inserted a second match on the same session with team2_player2_id set to a user with zeroclub_members rows for the club and zero rsvps rows for the session — the INSERT succeeded unconditionally. B4 #14's design note (guest/cross- club rating is intentional, club_elo_ratings is the per-club-scoped table) is unaffected by closing this gap — the fix is "was this person ever seated in this session," not "is rating cross-club-safe" (it already is).
2e. Disputed-state machine — the intended graph, traced end-to-end through every write site
Every score_status write site in the codebase (independently re-grepped here, confirming B4's own re-grep): agree_to_call (terminal 00317, sets 'verified' only on all-agree finalization) is the only site that ever writes 'verified'. submit_match_score (terminal 00324, read in full, §2a) never touches score_status at all — an admin-submitted score leaves it at its default 'unverified' permanently (a pre-existing, narrower quirk than the disputed-state gap, noted for completeness, not separately fixed here — score_status was designed around the participant-consensus path, and the admin-submit path's own authority is the club-role check, not peer-verification; flagging this asymmetry is useful context, not a new CRITICAL). agree_to_correction (terminal 00308:118-186, read in full) also never touches score_status — a completed correction leaves whatever score_status value predates it untouched. reject_call (terminal 00385:404-462) never writes it either. Net: 'disputed' is unreachable (B4/U4's finding), and — a related gap found by this trace, not previously called out — once 'disputed' IS made reachable, agree_to_correction's finalization has no path back to 'verified', meaning a disputed match that gets successfully corrected via the sanctioned all-agree flow would stay stuck showing 'disputed' forever, an even worse dead-UI failure than the original bug (a resolved dispute that never stops looking disputed).
The intended state graph (derived from the CHECK constraint's 3 legal values, 00057:12, plus the shape every write site's own guard already implies):
┌──────────────────────────────────────────────┐
│ │
▼ │
unverified ──────► (call/correction pending, match_score_calls row exists)
▲ │ │
│ fresh call_match / │ all agree │ any one
│ propose_score_ │ (agree_to_call / │ participant
│ correction on a │ agree_to_correction)│ rejects
│ disputed match ▼ ▼ (reject_call)
│ verified disputed ────────────┘
└───────────────────────────────────────────┘
(agree_to_correction on a disputed match's
correction call ALSO resolves to verified —
new fix, §3, closes the "stuck disputed" gap)Concretely: unverified is the resting state for a never-called or freshly-reset match. A call_match (mid-in_progress) or propose_score_correction (on completed) opens exactly one match_score_calls row (UNIQUE(match_id) — the two call types can never coexist, already enforced). All-agree (agree_to_call/agree_to_correction) finalizes to verified. Any single reject (reject_call, works uniformly for both call types — it doesn't branch on call_type at all, confirmed reading its full body) deletes the pending call and should transition to disputed. From disputed, the only way forward is a fresh call — call_match requires status = 'in_progress', propose_score_correction requires status = 'completed' — these two states are themselves mutually exclusive on a given match's lifecycle position, so exactly one of the two "fresh call" entry points is ever reachable from a given disputed match, and either must reset score_status back to unverified before opening the new call. This directly answers the task's framing ("verifying→confirmed / verifying→disputed→?"): the "→?" is "→ a fresh call, which resets to unverified and re-enters the same funnel" — there is no separate host/admin-override "force resolve" state, by design (the whole point of the consensus system is that only participant agreement, never a unilateral admin action, finalizes a score — matching 00308's own stated USTA-rule grounding, "correct only to a score both sides agree on").
3. Solution design
PS4-1 — submit_match_score status guard (Part B of PY1)
UPDATE public.matches
SET team1_score = p_team1_score, team2_score = p_team2_score,
team1_tiebreak_score = p_team1_tiebreak_score,
team2_tiebreak_score = p_team2_tiebreak_score,
outcome = p_outcome, status = 'completed', completed_at = now()
WHERE id = p_match_id
AND status IN ('scheduled', 'in_progress');
IF NOT FOUND THEN
RAISE EXCEPTION 'Only a scheduled or in-progress match can have its score submitted directly — use propose_score_correction for a completed match.'
USING ERRCODE = 'check_violation', HINT = 'already_completed';
END IF;Placed inside private.submit_match_score (terminal 00324), same function signature (no ACL reset needed — no argument-list change). Mirrors propose_score_correction's own terminal-state guard (§2a) in the opposite direction. Zero client change: the sanctioned score-entry UI only mounts for status === 'scheduled' (match-card.tsx:193, confirmed by grep) and this migration doesn't touch the in_progress case that update_live_match_score (a separate RPC, out of scope — already status-scoped, B4's "going well" #12) already owns.
PS4-2 — matches_update RLS status predicate (Part C of PY1)
DROP POLICY matches_update ON public.matches;
CREATE POLICY matches_update ON public.matches
FOR UPDATE
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.sessions s JOIN public.club_members cm ON cm.club_id = s.club_id
WHERE s.id = public.matches.session_id AND cm.user_id = (select auth.uid())
AND cm.role IN ('owner','admin','match_director') AND cm.is_active = true)
AND status IN ('scheduled', 'in_progress') -- §2b: in_progress required, LiveRoundEditorSheet depends on it
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.sessions s JOIN public.club_members cm ON cm.club_id = s.club_id
WHERE s.id = public.matches.session_id AND cm.user_id = (select auth.uid())
AND cm.role IN ('owner','admin','match_director') AND cm.is_active = true)
AND status IN ('scheduled', 'in_progress')
);WITH CHECK is added explicitly (not relying on Postgres's USING-reuse default) so the predicate is unambiguous to a future reader. This closes §2b's empirically-reproduced gap: any UPDATE (team-slot swap included) against a completed match is now RLS-rejected before it reaches the table, independent of §3's other fixes.
PS4-3 — protect_matches_scoring stale-argument fix (§2c, new finding)
DROP TRIGGER protect_matches_scoring ON public.matches;
CREATE TRIGGER protect_matches_scoring
BEFORE UPDATE ON public.matches
FOR EACH ROW
EXECUTE FUNCTION private.protect_columns(
'team1_score', 'team2_score',
'team1_tiebreak_score', 'team2_tiebreak_score', -- was the dead 'tiebreak_score'
'status', 'started_at', 'completed_at',
'verified_by', 'score_status', 'participant_ids'
);Team-slot columns are deliberately not added here — they're legitimate client-writable columns while status IN ('scheduled', 'in_progress') (mid-round substitution), so PS4-2's status-gated RLS is the correct mechanism for them, not an unconditional protect_columns lock (which would break LiveRoundEditorSheet even on a live, still-swappable match). protect_columns is reserved for columns that must never move via a plain client write regardless of match status — the score/tiebreak/status family, matching 00067's original intent, just with the column names corrected to match the live schema.
PS4-4 — disputed-state machine, full lifecycle (Part D of PY1, extended)
(a) reject_call (terminal 00385:404-462) — write 'disputed' before the delete:
-- inside public.reject_call, right before "DELETE FROM public.match_score_calls..."
UPDATE public.matches SET score_status = 'disputed' WHERE id = p_match_id;(b) call_match (terminal 00152:155-199) and propose_score_correction (terminal 00308:41-107) — reset to 'unverified' when opening a fresh call on a match currently 'disputed', in both functions, right after the existing "already has a pending call" guard and before the INSERT:
IF v_match.score_status = 'disputed' THEN
UPDATE public.matches SET score_status = 'unverified' WHERE id = p_match_id;
END IF;(v_match is already SELECT ... FOR UPDATE-locked by call_match; propose_score_correction needs the same row already fetched into v_match earlier in its body — no new query, just reads the field already in scope.)
(c) agree_to_correction (terminal 00308:118-186) — new fix, found by this doc's own trace (§2e), not previously flagged — set 'verified' on all-agree finalization, closing the "stuck disputed forever after a successful correction" gap:
-- inside public.agree_to_correction, added to the existing finalization UPDATE:
UPDATE public.matches SET
team1_score = v_call.team1_score,
team2_score = v_call.team2_score,
team1_tiebreak_score = v_call.team1_tiebreak_score,
team2_tiebreak_score = v_call.team2_tiebreak_score,
score_status = 'verified', -- new
updated_at = now()
WHERE id = p_match_id;agree_to_call's own finalization already sets score_status = 'verified' (00317, unchanged) — (c) makes agree_to_correction consistent with its sibling finalizer rather than leaving a silent asymmetry.
No RLS change needed for any of (a)-(c) — all three functions are already SECURITY DEFINER and already the sole legitimate writers of match_score_calls/the fields they touch (matching B4's own conclusion).
Client-side note (not this migration, flag for the DOC/IMPLEMENT handoff): once 'disputed' is reachable, reject_call's two callers (score-call-sheet.tsx/score-correction-sheet.tsx) fire a now-consequential action from a raw <Button variant="destructive"> with no useConfirm gate (U4 #4) — this was always a canon violation, but was lower-stakes while the action had no persistent effect. Recommend bundling U4 #4's fix (route both reject buttons through useConfirm({ style: 'destructive' }), swap the trigger to ActionButton action="danger") into the same client release that ships PS4-4, since the fix makes the missing confirm materially more consequential — not a blocking dependency, but sequenced together is the right call. U4 #5 (agreed-checkmark rendering in the live/red badge tone) is a related but independent COMP-2 bounded-variant gap (success isn't in the features-bounded Badge enum) — out of this backend doc's scope, flagged for whoever owns the canon amendment.
PS4-5 — participant/RSVP validation on match slots (M2, extended to the swap path)
Add an EXISTS clause to matches_insert and fold the same check into matches_update's WITH CHECK from PS4-2 (so a swap can't introduce a non-participant either — same authorization surface, same migration family):
-- helper, reused by both policies
CREATE OR REPLACE FUNCTION private.match_slot_players_have_rsvp(
p_session_id UUID, p_t1p1 UUID, p_t1p2 UUID, p_t2p1 UUID, p_t2p2 UUID
) RETURNS BOOLEAN LANGUAGE sql STABLE SET search_path = '' AS $$
SELECT NOT EXISTS (
SELECT 1 FROM unnest(ARRAY[p_t1p1, p_t1p2, p_t2p1, p_t2p2]) AS pid
WHERE pid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM public.rsvps r
WHERE r.session_id = p_session_id AND r.user_id = pid AND r.status = 'confirmed'
)
);
$$;
REVOKE ALL ON FUNCTION private.match_slot_players_have_rsvp(UUID,UUID,UUID,UUID,UUID) FROM PUBLIC;-- matches_insert WITH CHECK gains:
AND private.match_slot_players_have_rsvp(session_id, team1_player1_id, team1_player2_id, team2_player1_id, team2_player2_id)
-- matches_update WITH CHECK (PS4-2) gains the same clause, referencing NEW's session_id/team columnsVerified no legitimate flow is excluded: guest-application-approved players and forfeit substitutes both already produce a confirmed rsvps row before match assignment (traced decide_guest_application and forfeit_session's substitute path) — both are covered.
PS4-6 — MEDIUM/LOW items adopted from the wiring plan without re-derivation
M1— format-aware tier gate. Passsessions.formatintoprivate.rsvps_enforce_tier(00286) and branchsingles_elo/doubles_eloselection on it, mirroringapply_match_ratings's ownv_is_doublesbranch. Full spec:wiring-plans-2026-07-backend.mdM1.M3— round-level player-uniqueness trigger.BEFORE INSERT OR UPDATE OF team1_player1_id, team1_player2_id, team2_player1_id, team2_player2_id, round_number ON matches, raising if any incoming player ID already appears in anothermatchesrow with the same(session_id, round_number). Full spec:M3.M4— delete orphanedverify_match_score.DROP FUNCTION public.verify_match_score(...)+private.verify_match_score(...)+ theverifyScoreport/adapter method (3-file client cleanup). Full spec:M4.M5—search_pathhygiene onsignals_on_match_insert/signals_on_match_update. Not a PS4 migration — folds into the cross-cuttingD8sweep (22 functions,00413). Cited, not duplicated.
PS4-7 — Referee-transfer: product decision
Recommendation: remove the dead capability, do not build the UI. Grounded in three concrete traces, not a default "delete dead code" instinct:
- The product already has a working, simpler answer to "who can score a match" that doesn't route through
referee_idat all.live-tab.tsx:169(traced directly):canCall = (isParticipant || isHost) && match.status === 'in_progress'. This shipped and works — the referee concept isn't a gap being silently worked around, it's a superseded design the rest of the product moved past without anyone noticing the old RPCs were still there. - Building the UI is real, undesigned scope, not a small wiring fix (unlike, say, PS4-4's disputed banner, which just needed a column write to activate an already-built component tree). A pending-transfer badge + accept/decline row has no wireframe, no design-system precedent in this codebase, and would need its own artifact-design pass per this project's wireframe-first UI rule — real cost for a concept the product has already organically retired.
- The dead code is not inert — it has its own latent bug (
B4#9):propose_referee_transferhas no match-status gate, so if ever accidentally re-wired, a referee could propose a handoff on an already-completedmatch. Keeping it "just in case" means carrying that landmine indefinitely for a feature with a zero-probability activation path.
Scope of removal (traced the full surface, not just the 5 RPCs named in the audit): public.propose_referee_transfer / accept_referee_transfer / decline_referee_transfer / release_referee / assign_referee (all 5, terminal defs in 00241/00154); the 5 client hooks (use-{propose,accept,decline}-referee-transfer.ts, use-release-referee.ts, use-assign-referee.ts) + their @twomore/app barrel exports; the referee_transfer_pending_to/referee_transfer_initiated_at columns (exclusively used by the transfer flow, safe to drop). referee_id itself is NOT proposed for removal — call_match (terminal 00152:169) still reads it in its authorization fallback (auth.uid() = ANY(v_participants) OR auth.uid() = v_match.referee_id); dropping the column would require also editing call_match's body. Since referee_id is never populated by anything once assign_referee is removed, this fallback branch becomes permanently dead but harmless (always false) — cheaper to leave the column + the one dead branch than to touch call_match's SECDEF signature for a zero-risk no-op. match-profiles-sheet.tsx:66's dead nameMap: _nameMap destructure (a referee-name-display prop that's never read) gets cleaned up in the same client pass.
This is a product decision, not a pure technical call — per this project's own standing posture (the wiring plan itself lists this exact item as "Deferred, no migration assigned... needs an owner product decision first"), this doc's recommendation should be read as a proposal for owner sign-off, not a self-authorizing green light. No migration number is assigned until that sign-off happens (§4).
4. Slices
| Slice | Type | Files | Blast radius | Depends on |
|---|---|---|---|---|
PS4-1 — submit_match_score status guard | migration | private.submit_match_score body only (no signature change, no ACL reset) | matches UPDATE via this one RPC; zero client change (sanctioned UI never re-enters this path) | none |
PS4-2 — matches_update RLS status predicate + participant-RSVP WITH CHECK | migration | matches_update policy | matches UPDATE (team-slot swap + any other admin-tier update); zero client change for SwapSheet/LiveRoundEditorSheet (both already scoped to scheduled/in_progress) | PS4-5's helper function must land first or in the same migration |
PS4-3 — protect_matches_scoring stale-arg fix | migration | trigger re-CREATE, same file as PS4-1/2 | matches UPDATE; zero functional change for any legitimate writer (score/tiebreak columns are never client-written directly today) | none |
| PS4-4 — disputed-state machine (reject/call/correction) | migration | reject_call, call_match, propose_score_correction, agree_to_correction — 4 CREATE OR REPLACE, no signature changes | matches.score_status, match_score_calls; zero client change (client already renders every state this makes reachable) | none, but land in the same migration file as PS4-1/2/3 per PY1's own bundling rationale (shared table, shared review window) |
PS4-5 — participant/RSVP validation helper + matches_insert/matches_update WITH CHECK | migration | new private.match_slot_players_have_rsvp, matches_insert policy, folded into PS4-2's matches_update | matches INSERT/UPDATE; verified guest-application and forfeit-substitute flows both already produce a prior confirmed RSVP | none |
PS4-6 — M1/M3/M4 adoption | migration ×2-3 | per wiring-plan spec, unchanged | see wiring plan M1/M3/M4 | none |
| PS4-7 — referee-transfer removal | migration + client (≥8 files) | 5 RPCs, 5 hooks, 2 columns, 1 dead prop | GATED on owner sign-off — no migration number assigned | none, but blocks on a product decision, not a technical dependency |
Migration numbering: the master sequence (wiring-plans-2026-07-backend.md "Migration sequence" table, HEAD 00387 at authoring time) already reserves 00402 for PY1's 4-part fix (Part A = PS2's payment-settle-gate widen, Parts B/C/D = PS4-1/2/4 here) and 00403 for M1+M2 (= PS4-6's M1 + PS4-5 here) and 00404 for M3+M4 (= PS4-6). This doc adopts those numbers as authoritative — PS4-3 (the new stale-arg finding) folds into 00402 alongside PS4-1/2/4 (same file, same table, same review window); PS4-5 folds into 00403 alongside M1. Note for the roadmap sequencer: ps2-payments.md's own slice table independently cites lower provisional numbers (00388/00389) for its Part-A-equivalent work, apparently authored before the master sequence table was finalized — 00402 (master table) should be treated as the authoritative number for the shared migration, not 00388; flag this discrepancy for whichever problem space implements first to resolve at commit time, per ps2-payments.md §5's own request for coordination. PS4-7 gets no number until owner sign-off (matching the master sequence's own "Deferred, no migration assigned" treatment of this exact item).
Verification per slice
- PS4-1: re-run this doc's own §2a probe as a migration test — a second
private.submit_match_scorecall on an already-completedmatch must now raise, not silently succeed with score/rating drift. Confirm the first (legitimate) call on ascheduledmatch still succeeds unchanged. - PS4-2: re-run §2b's probe — the exact
UPDATE matches SET team2_player2_id = ...shape against acompletedmatch must now be RLS-rejected (0 rows affected / policy violation, not silent success). Confirm the identical statement still succeeds against ascheduledmatch (no false-positive) and against anin_progressmatch (confirms §2b'sLiveRoundEditorSheetdependency is preserved — this is the one regression risk worth an explicit manual pass: open a live session, run a mid-round substitution via the editor sheet, confirm it still saves). - PS4-3: re-run §2c's probe —
UPDATE matches SET team1_tiebreak_score = 9 WHERE id = <completed match>must now revert (read back unchanged), matching the existingteam1_score/team2_scorecontrol behavior. - PS4-4:
reject_callon a pending call → assertmatches.score_status = 'disputed'. Freshcall_match(match stillin_progress) orpropose_score_correction(matchcompleted) on that disputed match → assertscore_statusresets to'unverified'before the newmatch_score_callsrow is visible. All-agree viaagree_to_correctionon that fresh correction call → assertscore_status = 'verified'(closes the "stuck disputed" gap). Client spot-check: seed alive_showcase-style dispute viascripts/run-scenario.mjs, confirmHomeDisputedAlertCardnow renders for the counterpart and clears once resolved. - PS4-5: attempt a match
INSERT/swap-UPDATEincluding a player with noconfirmedRSVP on the session — expect rejection. Re-run the guest- application-approval and forfeit-substitution scenario flows unmodified — both must still succeed (their RSVP already exists by the time a match references them). - PS4-6: per wiring-plan
M1/M3/M4verification sections (a divergent-pool tier-gate probe; a same-round double-booking insert attempt;yarn typecheckafter theverifyScoreclient cleanup). - PS4-7: no automated verification until owner sign-off; once approved, verification is
yarn typecheck(client hook/export removal) + a migration-level confirmation thatcall_matchstill compiles and passes its existing test suite with the now-permanently-falsereferee_idfallback branch intact.
5. PS4 / PS2 boundary note
wiring-plans-2026-07-backend.md's PY1 bundles four sub-findings into one proposed migration because they share root cause (a server backstop keyed to a column-list the real write path doesn't hit) and blast radius (the matches table). Part A (widening trg_matches_settled_participants's UPDATE OF column list to catch the team-slot swap for the settle-before-play payment gate) is fully specified and owned by ps2-payments.md §3 (PS2-1) — this doc does not repeat it, only notes that whichever problem space lands first should land the shared 00402 migration (or a same-day coordinated pair) so the trigger list on matches doesn't collide across two independently-scheduled PRs. Parts B/C/D (PS4-1, PS4-2+PS4-5, PS4-4) are this doc's own scope, in full. ps2-payments.md §2a's trigger-firing-order proof (alphabetical BEFORE-trigger ordering, empirically verified there) is reused here by reference for PS4-2's interaction with sync_match_participant_ids — not re-derived.