PS2 — Payments & Settlement Integrity
Status: Archived Draws from: B3-payments-backend.md, B4-matches-rating-backend.md (cross-reference only — match-integrity ownership sits in PS4), and U3-sessions.md. Cites, does not repeat: wiring-plans-2026-07-backend.md §PAYMENTS (PY1–PY5), docs/guides/portone-payments.md. Already shipped (reference only, not re-planned): 00385 TOCTOU restore (commit c6cdfe7d — re-added 00313's two FOR UPDATE re-check blocks to expire_unpaid_session_holds that a first draft of 00385 had silently dropped); 00386 PortOne anon-revoke (commit 891cb33b — closed a public EXECUTE grant on mark_session_payment_paid_via_portone, a PS1-class GRANT hygiene bug found by the B5 audit, listed here only because it touches the same function this doc discusses in §4).
1. Problem statement
The settle-before-play canon (migrations 00298–00303, 00307, 00384, 00385) implements a reserve-then-pay hold model for session participation fees, backed by a single session_payments row per (session, user) with a dual rail (method: 'manual' | 'portone'). Four independent defects erode its integrity, all confirmed by direct code + live-DB verification (this doc, 2026-07-11, local Postgres with migrations through 00387 applied):
- CRITICAL — the match-gate backstop is architecturally dead on its primary write path (
B3-payments-backend.md#1).private.enforce_settled_match_participants(00384_settle_before_play.sql:85-87) firesBEFORE INSERT OR UPDATE OF participant_ids— but the app's own swap flow (updateMatchTeams,packages/app/src/adapters/supabase/match.supabase.ts:191-215, backingSwapSheet/LiveRoundEditorSheet) only ever sets the fourteam{1,2}_player{1,2}_idcolumns. Postgres does not fire a column-list trigger unless the named column is literally in the statement'sSETlist, so the gate never runs on a swap — even though a sibling trigger (private.sync_match_participant_ids,00313) silently recomputesparticipant_idsas a side effect of that same UPDATE. Re-reproduced empirically in this session (§2 below) — an unsettled RSVP can be swapped into a fee-session match with zero rejection. This is the payment half of a two-block finding; the match-integrity half (score/status gates on the same write shape) isB4's and belongs to PS4, not this doc — see §5 boundary note. - MEDIUM —
session_paymentsUPDATE RLS has noWITH CHECK(B3#3, confirmed live viapg_policies, §2). Because Postgres reusesUSINGwhenWITH CHECKis omitted, and both host (00303) and admin (00145)UPDATEpolicies only gatesession_idownership, an authorized host/admin can rewriteamount,user_id,portone_payment_id,refund_state,currency_codevia a raw client call — not just the fields the app's ownmarkPaid/markWaivedsend. Bundled with two low-severity siblings: noCHECK (amount >= 0)(B3#4) and no fee-change reconciliation once holds exist (B3#5,PY4). - MEDIUM/LOW — two hold-lifecycle signal gaps (
B3#6/#7). (a)private.session_completed_record_attendance's settle-before-play sweep (00384:104-138, step (a)) silently RSVP-cancels a member whose hold never got confirmed by session start — nosignals_emit, unlike the sibling cron path which does emitsession_payment_hold_expired. (b)private.sync_session_payment_on_rsvp's payer-cancel branch deletes an idle hold but never resolves itssession_payment_hold_createddedup key — self-heals withinpayment_hold_minutes(≤10 min) but leaves a dangling notification meanwhile. - Client staleness — payment-hold UI can outlive its own backing hold (
U3-sessions.md#1, HIGH).sessionPaymentKeysis absent from the 10-second focus-gated live poll inuse-live-session-data.ts, so a payment card with live 송금완료/checkout buttons can keep rendering for up tostaleTime = 5 minafter the cron has already expired and cancelled the underlying RSVP — a stale-CTA foot-gun, not a server-integrity gap.
Separately, PortOne v2 (Phase B, scaffolded) has no code defect but a concrete pre-launch readiness gap enumerated in §4 — this doc treats it as research output (a checklist), not a bug fix.
2. Research
2a. Trigger-ordering semantics — traced + empirically verified live
Postgres fires same-timing (BEFORE/AFTER) row triggers in alphabetical order of trigger name, independent of creation order — this is documented Postgres behavior (not Supabase-specific), and this codebase already leans on it deliberately: 00313_audit_batch3_participant_ids_and_payment_safety.sql:52 has an explicit code comment ("Trigger name sorts AFTER 'protect_matches_scoring' so on a same-statement... (protect_columns having already reverted any illegal write)"). Listing every row trigger currently on public.matches (queried live, pg_trigger/pg_get_triggerdef) confirms the full BEFORE ordering for a team-slot UPDATE:
matches_rules_snapshot_guard (BEFORE UPDATE, unconditional)
matches_snapshot_completion_context (BEFORE INSERT OR UPDATE, unconditional)
matches_updated_at (BEFORE UPDATE, unconditional)
protect_matches_scoring (BEFORE UPDATE, unconditional — protect_columns()
over 'team1_score','team2_score','tiebreak_score',
'status','started_at','completed_at','verified_by',
'score_status','participant_ids')
sync_match_participant_ids (BEFORE INSERT OR UPDATE OF team1_player1_id,
team1_player2_id, team2_player1_id, team2_player2_id)
trg_matches_settled_participants (BEFORE INSERT OR UPDATE OF participant_ids)protect_matches_scoring sorts before sync_match_participant_ids (p < s), so protect_columns()'s unauthorized-write revert of participant_ids happens first, and sync_match_participant_ids then legitimately recomputes NEW.participant_ids from the (unprotected) team-slot columns that were just accepted — this is not a bug, it's the intended defense layering 00313's own comment describes. sync_match_participant_ids in turn sorts before trg_matches_settled_participants (s < t), which is exactly the property the fix depends on: if the settle-before-play gate is widened to also fire ON UPDATE OF the four team-slot columns, it will fire after sync_match_participant_ids has already written the recomputed NEW.participant_ids, so the gate evaluates the final roster, not the stale one.
This was not just read — it was verified by executing the actual fix against the live local Postgres (supabase_db_twomore-v2, migrations through 00387), inside a rolled-back transaction:
- Widened the trigger in place:
DROP TRIGGER trg_matches_settled_participants→CREATE TRIGGER ... BEFORE INSERT OR UPDATE OF team1_player1_id, team1_player2_id, team2_player1_id, team2_player2_id, participant_ids .... - Built a fee session (
participation_fee = 10000), two confirmed RSVPs, settled one hold topaid, left the otherpending. - Created a match with only the settled pair.
- Ran the exact statement shape
updateMatchTeamsissues:UPDATE matches SET team2_player2_id = <unsettled_user> WHERE id = <match_id>. - Result:
RAISE EXCEPTION 'settle-before-play: participant <id> has no settled payment for fee session <id>'— the write is rejected. Before the fix (current shipped00384/00387state), the identical statement silently succeeds (this matchesB3's own independently-reproduced result, cross-validated here rather than re-trusted).
This confirms the wiring-plan's proposed fix shape (wiring-plans-2026-07-backend.md:642-654, Part A) is both correct and sufficient for the payment half of the bypass — no alternative design (AFTER-statement constraint trigger, or folding the check into sync_match_participant_ids itself) is necessary. An AFTER-statement constraint trigger would work too but adds a second trigger-firing model to reason about for zero benefit — the alphabetical-ordering property already gives row-level correctness with less mechanism. Folding the check directly into sync_match_participant_ids was considered and rejected: it would conflate two concerns (participant-list derivation vs. payment authorization) in one function, and would still need participant_ids on its own trigger's UPDATE OF list to catch enforce_settled_match_participants's other entry point (direct participant_ids writes, e.g. from any future bulk-roster tool) — the two-trigger split with a widened column list is the simpler, more auditable shape.
2b. Reserve-then-pay hold-lifecycle event modeling
The canonical "hold released" event in a reserve-then-pay system has three distinct terminal transitions, and this codebase's signals table already models two of them but not uniformly:
- Hold fulfilled (
status → paid/waived):private.signals_on_session_payment_confirmed(00385:188-227) —AFTER UPDATE OF status, emitssession_payment_confirmed. Complete; no gap. - Hold force-released by policy (idle timeout):
expire_unpaid_session_holds(00385:294-310, TOCTOU-safe as ofc6cdfe7d) emitssession_payment_hold_expiredwith dedup keysession_payment_hold_expired:<rsvp_id>. - Hold voluntarily released by the actor (payer cancels their own RSVP):
sync_session_payment_on_rsvp'sDELETEbranch — no signal at all, neither a fresh emission nor a resolution of thehold_createdsignal that's still sitting unread. The correct model here is not a new emission (nothing needs to notify the payer that they cancelled) but a resolution of the earliersession_payment_hold_created:<id>signal — the reserve-then-pay literature's usual pattern (Stripe'sPaymentIntent.canceled, PayPal's authorization-void webhook) is that a released hold either transitions to a terminal "captured" state or is explicitly voided, and any pending "action needed" notification tied to the hold should be retracted at void time, not left to expire on its own clock.signals_resolve_by_dedup_key(00385, used 4× already fordues_*types at lines 892-895) is the existing primitive for exactly this — no new mechanism needed, just a missing call site. - Hold force-released at session completion, unconfirmed (§1's
session_completed_record_attendancestep (a)): structurally identical to the idle-timeout cron path (same excused/strike-0 outcome, same "member could not have played" cause) but silent. This should emit the samesession_payment_hold_expiredtype — the member-facing framing is identical either way ("your hold expired, you weren't seated") — using the RSVP id as the dedup-key suffix exactly like the cron path, so the two emission sites are indistinguishable to a client that already renderssession_payment_hold_expired.
2c. RLS WITH CHECK vs. column-privilege vs. protect_columns trigger
Three mechanisms exist in Postgres/Supabase to stop an authorized row-owner from writing to specific columns of a row they're otherwise allowed to UPDATE:
- Column-level GRANT (
GRANT UPDATE (status, paid_at) ON session_payments TO authenticated) — the most "correct" SQL-standard mechanism, but this codebase does not use column-level grants anywhere (greppedsupabase/migrations/*.sqlforGRANT UPDATE (— zero hits); introducing the pattern here would be a new idiom for one table, against the single-source-of-truth principle. - RLS
WITH CHECK— the docs-recommended default per the wiring plan's first draft, but Postgres RLS has noOLDreference inside a policy predicate the way a trigger does; pinning "this column may not change" requires a self-join subquery (amount = (SELECT amount FROM session_payments sp WHERE sp.id = session_payments.id)), which is correct but reads awkwardly and re-executes the subquery per row. private.protect_columns()BEFORE UPDATE trigger — already the established codebase idiom (00066/00074, applied toprofiles,club_members,matches,subscriptionsin00067, and tosessionsin00319). Read the live function body directly (\sf private.protect_columns): it diffsto_jsonb(OLD)vs.to_jsonb(NEW)per named column and silently merges theOLDvalue back intoNEWfor any that changed — a silent revert, not a raised exception. Critically, it first checksCURRENT_USER NOT IN ('authenticated', 'anon', 'authenticator')and returnsNEWunmodified for any other role — soSECURITY DEFINERfunctions (which execute as their owner,postgres) transparently bypass it. This meansmark_session_payment_paid_via_portone(service_role-invoked SECDEF) andattest_session_payment(SECDEF, only ever setssubmitted_at, which wouldn't be in the protected list anyway) are unaffected — verified by reading both RPC bodies against the protected-column list chosen below.
session_payments currently has zero BEFORE UPDATE triggers (verified live via pg_trigger), so adding one is purely additive — no ordering interaction with the three existing AFTER signal triggers (signals_session_payment_{insert,confirmed,submitted}_trigger), since BEFORE triggers always run before AFTER triggers regardless of name.
Chosen mechanism: protect_columns trigger, matching the codebase's own established precedent (option 3) rather than introducing RLS WITH CHECK self-joins or column-grants as a first-time idiom — directly answering the wiring-plan's own open question ("prefer the protect_columns route... the established codebase pattern").
2d. PortOne v2 Phase B readiness — grounded in the setup guide + edge-fn code
Read docs/guides/portone-payments.md end to end plus the two scaffolded edge functions (supabase/functions/verify-portone-payment/index.ts, supabase/functions/portone-webhook/index.ts — both already reviewed line-by-line by B3 #9 with no drift found). The PortOne v2 WebView server-verify flow (KR-3 canon: WebView only, never a per-PG native SDK) has a fixed shape this codebase already implements correctly at the code level: client opens the WebView → PortOne SDK resolves a paymentId → client calls a server endpoint with only the paymentId (never a claimed amount/status) → server independently re-fetches payment truth from PortOne's API → server checks status === 'PAID' and cross-checks customData against the caller identity → server calls a service-role-only idempotent RPC to mark the hold paid. This repo's verify-portone-payment does exactly that shape today. What remains before Phase B is not code but infrastructure + validation, exhaustively enumerated by the guide's own status table (§"Where it stands") and echoed here as a checklist (no new items invented — this is a synthesis, not new research):
- [ ] PortOne console: Store + test-PG Channel + V2 API Secret provisioned.
- [ ] Webhook endpoint registered (
portone-webhook,Transaction.Paid+Transaction.VirtualAccountIssuedevents) with itswhsec_...secret captured. - [ ]
PORTONE_V2_API_SECRET/PORTONE_WEBHOOK_SECRETset viasupabase secrets set; both edge functions deployed (--no-verify-jwton the webhook only — it authenticates via HMAC signature, not a Supabase JWT, since PortOne cannot mint one). - [ ]
@portone/react-native-sdknative adapter installed +app.jsonplugin registered — requires a new EAS build (native dependency, not OTA-able per this repo's ownfeedback_no_native_deps_in_otarule; the guide explicitly warns against a top-level import of the SDK landing in an OTA bundle before the build ships, sincerequireNativeModulethrows outside userland try/catch and crashes cold start). - [ ]
registry.ts'spayment:binding swapped fromcreateMockPaymentServiceto the real adapter, gated behind a native capability check so a stale OTA bundle on an old (pre-native-build) binary still resolves to the mock. - [ ]
useSessionPaymentCheckout's post-SDK-resolve call swapped fromsessionPayments.devMockConfirmtoverify-portone-payment. - [ ] End-to-end TEST-channel validation per the guide's own checklist: TEST-card success flips the hold; a tampered client-claimed amount is rejected by the RPC's
amount_mismatchguard; 가상계좌 webhook-driven paid transition; webhook signature-failure → 401; stale webhook timestamp → 400; manual rail still works unchanged.
None of this is a defect to fix in this pass — it is the dependency list gating when Phase B can start, included here because PS2 owns the payments surface and the roadmap needs one authoritative checklist rather than re-deriving it per problem space.
2e. Client staleness — traced the actual poll wiring, not the obvious-named hook
useLiveSessionData (packages/app/src/presentation/hooks/composites/use-live-session-data.ts) is the one hook actually driving the 10-second focus-gated poll (confirmed by reading the full file, not assuming from its name) — it explicitly documents why it uses a manual setInterval + queryClient.invalidateQueries per key rather than per-query refetchInterval (lines 176-181: these queries are shared with non-live screens, so a per-query interval would poll them even when unfocused). The invalidation list at lines 214-219 covers sessionKeys, matchKeys (×2), rsvpKeys, tournamentKeys, guestApplicationKeys — sessionPaymentKeys (defined packages/app/src/query-keys.ts:360-366) is absent from both the list and the file's import block (lines 66-72). This is corroborated by U3-sessions.md's independent trace of useSessionPayments's staleTime: STALE_TIME.standard (5 min, use-session-payments.ts:23-26) — the two traces agree: the payment panel has no live-refresh path at all today, only the ambient 5-minute staleTime and whatever mutation invalidation useMarkPaymentPaid already does correctly (use-session-payments.ts:114-119, mutations, not the poll).
3. Solution design
PS2-1 — Close the settle-before-play swap bypass (payment half of the shared PY1 fix)
Widen trg_matches_settled_participants's UPDATE OF column list to match sync_match_participant_ids's, per §2a:
DROP TRIGGER trg_matches_settled_participants ON public.matches;
CREATE TRIGGER trg_matches_settled_participants
BEFORE INSERT OR UPDATE OF
team1_player1_id, team1_player2_id, team2_player1_id, team2_player2_id,
participant_ids
ON public.matches
FOR EACH ROW EXECUTE FUNCTION private.enforce_settled_match_participants();private.enforce_settled_match_participants itself (00384:45-84) needs no body change — it already reads NEW.participant_ids, and the ordering proof in §2a guarantees that value is the post-recompute one by the time this trigger runs.
PS2-2 — session_payments column protection + amount floor (PY2/PY3)
One protect_columns trigger, mirroring the 00067/00319 precedent exactly:
CREATE TRIGGER protect_session_payments_ledger
BEFORE UPDATE ON public.session_payments
FOR EACH ROW
EXECUTE FUNCTION private.protect_columns(
'session_id', 'user_id', 'amount', 'currency_code', 'portone_payment_id'
);
ALTER TABLE public.session_payments
ADD CONSTRAINT session_payments_amount_nonneg CHECK (amount >= 0);status, paid_at, method, refund_state, refund_pending_at, refund_acked_at, recorded_by, submitted_at, idempotency_key are not in the protected list — every field the app's own markPaid/markWaived/attest_session_payment legitimately write stays open. id and created_at don't need protection (no UPDATE ever targets them; id is the PK).
PS2-3 — Two missing signal emissions (PY5)
(a) Completion-trigger hold-expiry emission — inside private.session_completed_record_attendance's existing loop (00384:104-119), add the RSVP id to the SELECT and emit before/after the existing INSERT ... attendance_records (order doesn't matter, matching the cron path's own comment that signals_emit has no side effects on rsvps/attendance):
-- inside the existing FOR v_hold IN SELECT ... loop, add r.id AS rsvp_id
PERFORM public.signals_emit(
p_type := 'session_payment_hold_expired',
p_category := 'payment'::public.signal_category,
p_severity := 'high'::public.signal_severity,
p_recipient_user_id := v_hold.user_id,
p_dedup_key := 'session_payment_hold_expired:' || v_hold.rsvp_id::text,
p_title_key := 'signal.payment.holdExpired.title',
p_body_key := 'signal.payment.holdExpired.body',
p_context := jsonb_build_object('clubId', NEW.club_id, 'sessionId', NEW.id),
p_payload := '{}'::jsonb,
p_expires_at := pg_catalog.now() + INTERVAL '24 hours'
);Reuses the exact type/title/body keys the cron path already emits — no new i18n strings, no new client union member, since the client already renders session_payment_hold_expired.
(b) Payer-cancel resolves its own hold_created signal — inside private.sync_session_payment_on_rsvp's DELETE branch, capture the id and resolve:
DELETE FROM public.session_payments
WHERE session_id = NEW.session_id AND user_id = NEW.user_id
AND status = 'pending' AND submitted_at IS NULL
RETURNING id INTO v_deleted_id;
IF v_deleted_id IS NOT NULL THEN
PERFORM public.signals_resolve_by_dedup_key('session_payment_hold_created:' || v_deleted_id);
END IF;PS2-4 — Fee-change reconciliation lock (PY4)
Adopt the wiring plan's "lock, don't reconcile" choice — simpler and safer given no shipped UI exposes a fee-edit field today:
CREATE OR REPLACE FUNCTION private.block_fee_edit_with_existing_holds()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
IF NEW.participation_fee IS DISTINCT FROM OLD.participation_fee
AND EXISTS (SELECT 1 FROM public.session_payments WHERE session_id = NEW.id) THEN
RAISE EXCEPTION 'participation_fee cannot change once a payment hold exists for this session — cancel/refund existing holds first'
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION private.block_fee_edit_with_existing_holds() FROM PUBLIC;
CREATE TRIGGER trg_block_fee_edit_with_holds
BEFORE UPDATE OF participation_fee ON public.sessions
FOR EACH ROW EXECUTE FUNCTION private.block_fee_edit_with_existing_holds();Inert today (no UI path reaches it); the moment a future fee-edit screen ships, it correctly blocks rather than silently desynchronizing session_payments.amount from the RPC's live-fee validation.
PS2-5 — PortOne Phase B readiness
No code change in this pass — §2d's checklist is the deliverable, to be consumed as the entry criteria for whichever future slice starts Phase B (native adapter + EAS build + TEST-channel validation). Flagging explicitly: this is infra/ops work gated on an EAS build, not something this migration pass unblocks.
PS2-6 — Client: keep sessionPaymentKeys live during an active session view
Add the missing key to use-live-session-data.ts's poll invalidation set — one import, one line:
// imports (alongside the existing rsvpKeys/tournamentKeys/guestApplicationKeys)
import {
sessionKeys,
matchKeys,
rsvpKeys,
tournamentKeys,
guestApplicationKeys,
sessionPaymentKeys,
} from '@/query-keys';
// inside the 10s interval callback, alongside the existing invalidations:
void queryClient.invalidateQueries({ queryKey: sessionPaymentKeys.bySession(sid) });This closes the up-to-5-minute stale window entirely for any screen using useLiveSessionData — the payment card will pick up a cron-driven expiry within the same 10s cadence as rsvpKeys, so the "reserved" card and the now-cancelled RSVP never visibly disagree for more than one poll tick.
4. Slices
| Slice | Type | Files | Blast radius | Depends on |
|---|---|---|---|---|
| PS2-1 — swap-bypass trigger widen | migration | new 00388_settle_before_play_swap_gate.sql (or shared with PS4's PY1 Parts B/C/D if landed together — see §5) | matches trigger only; no client change (sanctioned UI never swaps into a completed match) | none |
PS2-2 — session_payments column protection + amount CHECK | migration | same file or a dedicated 00389_session_payments_hardening.sql | session_payments UPDATE path; zero functional change for markPaid/markWaived/attest_session_payment | none |
| PS2-3 — hold-lifecycle signal completeness | migration | same hardening migration (small CREATE OR REPLACE diffs to two existing functions) | signals table only | none |
| PS2-4 — fee-change reconciliation lock | migration | same hardening migration | sessions UPDATE path; inert until a fee-edit UI exists | none |
| PS2-5 — PortOne readiness checklist | ops/infra (no code) | N/A | N/A | gates future Phase B slice, not this pass |
| PS2-6 — client payment-key live invalidation | client-OTA | packages/app/src/presentation/hooks/composites/use-live-session-data.ts | any screen using useLiveSessionData (session detail, match board) — additive invalidation only | none |
Verification per slice
- PS2-1: re-run this doc's own §2a repro as a migration test — after applying, the exact
updateMatchTeamsstatement shape against an unsettled participant must raisesettle-before-play: ..., not silently succeed. Also confirm a settled-only swap still succeeds (no false-positive rejection) and that a free session (participation_fee = 0) swap is unaffected (the function's own early-return). Coordinate timing with PS4 if Parts B/C/D of the same wiring-planPY1land in the same file — verify trigger name still sorts aftersync_match_participant_idsif any renaming happens. - PS2-2: as an authorized host,
.update({ amount: 999999 })on a payment row they administer via a raw client call — assert the row is unchanged after refetch (silent revert, matchingprotect_columns' existing behavior elsewhere — not an exception). Attemptamount = -1via insert/update — assert aCHECKviolation. Run the existingmarkPaid/markWaivedmutation tests — assert no regression (they never touch the protected columns). Confirmmark_session_payment_paid_via_portone(SECDEF) still successfully setsstatus/paid_at/portone_payment_id— since it only writesportone_payment_idwhen marking a new PortOne-rail payment and never on an already-settled row, and it runs aspostgres(bypassesprotect_columns'sCURRENT_USERgate), this must be a no-op functional change — write a probe that calls it end-to-end against a local TEST hold and asserts success. - PS2-3: (a) let a hold survive to session completion unconfirmed — assert a
session_payment_hold_expiredsignal is emitted with the correctdedup_key/recipient_user_id. (b) Cancel a confirmed RSVP with an idle hold — assert the correspondingsession_payment_hold_createdsignal'sresolved_atis set immediately (not left to its naturalexpires_at). - PS2-4: create a session with an existing hold, attempt
.update({ participationFee: X })— expect a raised exception. Confirm the update still succeeds on a session with zero holds (pre-first-RSVP fee correction still works). - PS2-5: no automated verification — this is a checklist gate, ticked off manually as Phase B infra work lands (owner-driven: PortOne console access, EAS build trigger).
- PS2-6:
yarn typecheck(import + invalidateQueries call-site correctness) plus a scenario-seeded manual check: open a session detail screen with an active reserved hold, let the cron expire it server-side (or seed an already-expired hold), confirm the payment card updates within one 10s poll tick instead of persisting for up to 5 minutes.
5. PS2 / PS4 boundary note
B3-payments-backend.md #1 and B4-matches-rating-backend.md's swap-gate findings are two angles on one underlying defect class (a "server backstop keyed to a column-list the actual write path never targets") and the wiring plan's own PY1 consolidates all four sub-findings (payment-settlement gate, submit_match_score status guard, matches_update RLS status predicate, score_status → disputed wiring) into a single proposed migration, explicitly because they share root cause and blast radius (matches table, same trigger-firing-order reasoning). This doc (PS2) owns and fully specifies only the payment-settlement piece (§3's PS2-1, wiring-plans-2026-07-backend.md Part A) — the match-integrity pieces (Parts B/C/D: score-guard, RLS status predicate, disputed-state machine) are PS4's scope and must be designed in ps4-matches-rating.md, not re-derived here. Whichever problem space's implementation lands first should land the shared migration file (or coordinate a same-day pair of migrations) so trg_matches_settled_participants's column list and any matches_update RLS rewrite don't collide on the same table in the same window — flagging here so the roadmap sequencer treats PS2-1 and PS4's swap-gate slice as a single coordinated unit, not two independently-schedulable items.