Skip to content

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), M1M5, 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.

  1. CRITICAL — submit_match_score has no status guard, silently bypassing the 00308 correction-consent flow, and desyncing the displayed score from elo_history (B4 #1). Empirically confirmed, not just read (§2a): a second submit_match_score(...) call against an already-completed match succeeds silently — matches.team1_score/ team2_score change to the new values, but apply_match_ratings' own idempotency guard (00385:1284-1290) no-ops the rating body entirely, so elo_history stays frozen at the first call's values. Post-probe state: team2_score changed 2→3, elo_history row count for the match stayed at 4 (unchanged) across both calls. No error, no signal (the completed→completed write is invisible to cascade_match_completion_to_session/ trg_alert_match_completed, both of which only fire on an OLD.status IS DISTINCT FROM 'completed' transition).
  2. CRITICAL — matches.score_status is never written to 'disputed', so the fully-built HomeDisputedAlertBanner/ScoreStatusBadge UI is permanently dead (B4 #2, cross-ref U4 #1). reject_call (terminal 00385:404-462) deletes the pending match_score_calls row and emits a match_score_disputed signal (a bell notification) but never touches matches.score_status. findDisputedByUser always returns [].
  3. CRITICAL — two independent unprotected paths let a completed/live match's roster or score be rewritten (B4 #3, cross-ref U4 #2 / B3 #1 — payment half owned by PS2, see §5):
    • (a) RLS. matches_update (terminal 00013:76-89) authorizes any active club owner/admin/match_director with no status predicate, no WITH CHECK. Empirically confirmed (§2b): as an authenticated club owner, a raw UPDATE matches SET team2_player2_id = <non-participant> against an already-completed match 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's protect_columns('team1_score', 'team2_score', 'tiebreak_score', 'status', 'started_at', 'completed_at', 'verified_by', 'score_status', 'participant_ids') was written against a schema that 00147 later changed: ALTER TABLE matches DROP COLUMN tiebreak_score (split into team1_tiebreak_score/ team2_tiebreak_score), but the trigger's arg list was never updated. This is a new finding, not called out in B4, found by tracing the live schema against the live trigger definition, then confirmed empirically (§2c): protect_columns() diffs to_jsonb(OLD)->col vs. to_jsonb(NEW)->col; for a column name ('tiebreak_score') that doesn't exist in the row, both sides are SQL NULL, so IS DISTINCT FROM is always false — the protection entry is a silent no-op. Probe: as the same authenticated admin, UPDATE matches SET team1_tiebreak_score = 9, team2_tiebreak_score = 1 on the same completed matchboth 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 as 6/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 (00067 predates their existence as a concept needing protection) and remain fully open to any client write that clears RLS.
  4. 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_ratings dual-ELO model, 00017/00236); the gap is narrower — nothing confirms the player was ever offered a seat in this session at all.
  5. 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 reads referee_id. This needs an explicit call, not indefinite dead weight — §4.5.
  6. MEDIUM — rsvps_enforce_tier uses cross-pool elo_rating, not a format-aware rating (B4 #4 / wiring plan M1). Already fully specified by the wiring plan; adopted here without re-derivation (§4.6).
  7. LOW-MEDIUM — no round-level player-uniqueness invariant (B4 #6 / M3) and orphaned verify_match_score legacy RPC (B4 #7 / M4). Already fully specified by the wiring plan; adopted here (§4.6).
  8. LOW — signals_on_match_insert/signals_on_match_update use search_path = public instead of '' (B4 #8 / M5). Folds into the cross-cutting D8 search-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:

sql
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):

sql
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:

sql
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:

sql
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 completed

Contrast matches_delete (00011_rls_indexes_triggers.sql:329-335, live — no later redefinition), which does gate on status = 'scheduled':

sql
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-210canEditLiveRound = 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:

sql
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):

sql
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 here

The 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)

sql
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)

sql
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)

sql
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:

sql
-- 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:

sql
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:

sql
-- 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):

sql
-- 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;
sql
-- 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 columns

Verified 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. Pass sessions.format into private.rsvps_enforce_tier (00286) and branch singles_elo/ doubles_elo selection on it, mirroring apply_match_ratings's own v_is_doubles branch. Full spec: wiring-plans-2026-07-backend.md M1.
  • 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 another matches row with the same (session_id, round_number). Full spec: M3.
  • M4 — delete orphaned verify_match_score. DROP FUNCTION public.verify_match_score(...) + private.verify_match_score(...) + the verifyScore port/adapter method (3-file client cleanup). Full spec: M4.
  • M5search_path hygiene on signals_on_match_insert/ signals_on_match_update. Not a PS4 migration — folds into the cross-cutting D8 sweep (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:

  1. The product already has a working, simpler answer to "who can score a match" that doesn't route through referee_id at 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.
  2. 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.
  3. The dead code is not inert — it has its own latent bug (B4 #9): propose_referee_transfer has no match-status gate, so if ever accidentally re-wired, a referee could propose a handoff on an already-completed match. 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 removalcall_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

SliceTypeFilesBlast radiusDepends on
PS4-1submit_match_score status guardmigrationprivate.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-2matches_update RLS status predicate + participant-RSVP WITH CHECKmigrationmatches_update policymatches 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-3protect_matches_scoring stale-arg fixmigrationtrigger re-CREATE, same file as PS4-1/2matches 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)migrationreject_call, call_match, propose_score_correction, agree_to_correction — 4 CREATE OR REPLACE, no signature changesmatches.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 CHECKmigrationnew private.match_slot_players_have_rsvp, matches_insert policy, folded into PS4-2's matches_updatematches INSERT/UPDATE; verified guest-application and forfeit-substitute flows both already produce a prior confirmed RSVPnone
PS4-6M1/M3/M4 adoptionmigration ×2-3per wiring-plan spec, unchangedsee wiring plan M1/M3/M4none
PS4-7 — referee-transfer removalmigration + client (≥8 files)5 RPCs, 5 hooks, 2 columns, 1 dead propGATED on owner sign-off — no migration number assignednone, 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_score call on an already-completed match must now raise, not silently succeed with score/rating drift. Confirm the first (legitimate) call on a scheduled match still succeeds unchanged.
  • PS4-2: re-run §2b's probe — the exact UPDATE matches SET team2_player2_id = ... shape against a completed match must now be RLS-rejected (0 rows affected / policy violation, not silent success). Confirm the identical statement still succeeds against a scheduled match (no false-positive) and against an in_progress match (confirms §2b's LiveRoundEditorSheet dependency 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 existing team1_score/team2_score control behavior.
  • PS4-4: reject_call on a pending call → assert matches.score_status = 'disputed'. Fresh call_match (match still in_progress) or propose_score_correction (match completed) on that disputed match → assert score_status resets to 'unverified' before the new match_score_calls row is visible. All-agree via agree_to_correction on that fresh correction call → assert score_status = 'verified' (closes the "stuck disputed" gap). Client spot-check: seed a live_showcase-style dispute via scripts/run-scenario.mjs, confirm HomeDisputedAlertCard now renders for the counterpart and clears once resolved.
  • PS4-5: attempt a match INSERT/swap-UPDATE including a player with no confirmed RSVP 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/M4 verification sections (a divergent-pool tier-gate probe; a same-round double-booking insert attempt; yarn typecheck after the verifyScore client cleanup).
  • PS4-7: no automated verification until owner sign-off; once approved, verification is yarn typecheck (client hook/export removal) + a migration-level confirmation that call_match still compiles and passes its existing test suite with the now-permanently-false referee_id fallback 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.

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