Skip to content

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 (PY1PY5), 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 0029800303, 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):

  1. 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) fires BEFORE INSERT OR UPDATE OF participant_ids — but the app's own swap flow (updateMatchTeams, packages/app/src/adapters/supabase/match.supabase.ts:191-215, backing SwapSheet/LiveRoundEditorSheet) only ever sets the four team{1,2}_player{1,2}_id columns. Postgres does not fire a column-list trigger unless the named column is literally in the statement's SET list, so the gate never runs on a swap — even though a sibling trigger (private.sync_match_participant_ids, 00313) silently recomputes participant_ids as 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) is B4's and belongs to PS4, not this doc — see §5 boundary note.
  2. MEDIUM — session_payments UPDATE RLS has no WITH CHECK (B3 #3, confirmed live via pg_policies, §2). Because Postgres reuses USING when WITH CHECK is omitted, and both host (00303) and admin (00145) UPDATE policies only gate session_id ownership, an authorized host/admin can rewrite amount, user_id, portone_payment_id, refund_state, currency_code via a raw client call — not just the fields the app's own markPaid/markWaived send. Bundled with two low-severity siblings: no CHECK (amount >= 0) (B3 #4) and no fee-change reconciliation once holds exist (B3 #5, PY4).
  3. 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 — no signals_emit, unlike the sibling cron path which does emit session_payment_hold_expired. (b) private.sync_session_payment_on_rsvp's payer-cancel branch deletes an idle hold but never resolves its session_payment_hold_created dedup key — self-heals within payment_hold_minutes (≤10 min) but leaves a dangling notification meanwhile.
  4. Client staleness — payment-hold UI can outlive its own backing hold (U3-sessions.md #1, HIGH). sessionPaymentKeys is absent from the 10-second focus-gated live poll in use-live-session-data.ts, so a payment card with live 송금완료/checkout buttons can keep rendering for up to staleTime = 5 min after 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:

  1. Widened the trigger in place: DROP TRIGGER trg_matches_settled_participantsCREATE TRIGGER ... BEFORE INSERT OR UPDATE OF team1_player1_id, team1_player2_id, team2_player1_id, team2_player2_id, participant_ids ....
  2. Built a fee session (participation_fee = 10000), two confirmed RSVPs, settled one hold to paid, left the other pending.
  3. Created a match with only the settled pair.
  4. Ran the exact statement shape updateMatchTeams issues: UPDATE matches SET team2_player2_id = <unsettled_user> WHERE id = <match_id>.
  5. Result: RAISE EXCEPTION 'settle-before-play: participant <id> has no settled payment for fee session <id>' — the write is rejected. Before the fix (current shipped 00384/00387 state), the identical statement silently succeeds (this matches B3'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, emits session_payment_confirmed. Complete; no gap.
  • Hold force-released by policy (idle timeout): expire_unpaid_session_holds (00385:294-310, TOCTOU-safe as of c6cdfe7d) emits session_payment_hold_expired with dedup key session_payment_hold_expired:<rsvp_id>.
  • Hold voluntarily released by the actor (payer cancels their own RSVP): sync_session_payment_on_rsvp's DELETE branch — no signal at all, neither a fresh emission nor a resolution of the hold_created signal 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 earlier session_payment_hold_created:<id> signal — the reserve-then-pay literature's usual pattern (Stripe's PaymentIntent.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 for dues_* 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_attendance step (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_expired type — 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 renders session_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:

  1. 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 (grepped supabase/migrations/*.sql for GRANT UPDATE ( — zero hits); introducing the pattern here would be a new idiom for one table, against the single-source-of-truth principle.
  2. RLS WITH CHECK — the docs-recommended default per the wiring plan's first draft, but Postgres RLS has no OLD reference 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.
  3. private.protect_columns() BEFORE UPDATE trigger — already the established codebase idiom (00066/00074, applied to profiles, club_members, matches, subscriptions in 00067, and to sessions in 00319). Read the live function body directly (\sf private.protect_columns): it diffs to_jsonb(OLD) vs. to_jsonb(NEW) per named column and silently merges the OLD value back into NEW for any that changed — a silent revert, not a raised exception. Critically, it first checks CURRENT_USER NOT IN ('authenticated', 'anon', 'authenticator') and returns NEW unmodified for any other role — so SECURITY DEFINER functions (which execute as their owner, postgres) transparently bypass it. This means mark_session_payment_paid_via_portone (service_role-invoked SECDEF) and attest_session_payment (SECDEF, only ever sets submitted_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.VirtualAccountIssued events) with its whsec_... secret captured.
  • [ ] PORTONE_V2_API_SECRET / PORTONE_WEBHOOK_SECRET set via supabase secrets set; both edge functions deployed (--no-verify-jwt on the webhook only — it authenticates via HMAC signature, not a Supabase JWT, since PortOne cannot mint one).
  • [ ] @portone/react-native-sdk native adapter installed + app.json plugin registered — requires a new EAS build (native dependency, not OTA-able per this repo's own feedback_no_native_deps_in_ota rule; the guide explicitly warns against a top-level import of the SDK landing in an OTA bundle before the build ships, since requireNativeModule throws outside userland try/catch and crashes cold start).
  • [ ] registry.ts's payment: binding swapped from createMockPaymentService to 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 from sessionPayments.devMockConfirm to verify-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_mismatch guard; 가상계좌 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, guestApplicationKeyssessionPaymentKeys (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:

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

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

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

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

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

ts
// 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

SliceTypeFilesBlast radiusDepends on
PS2-1 — swap-bypass trigger widenmigrationnew 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-2session_payments column protection + amount CHECKmigrationsame file or a dedicated 00389_session_payments_hardening.sqlsession_payments UPDATE path; zero functional change for markPaid/markWaived/attest_session_paymentnone
PS2-3 — hold-lifecycle signal completenessmigrationsame hardening migration (small CREATE OR REPLACE diffs to two existing functions)signals table onlynone
PS2-4 — fee-change reconciliation lockmigrationsame hardening migrationsessions UPDATE path; inert until a fee-edit UI existsnone
PS2-5 — PortOne readiness checklistops/infra (no code)N/AN/Agates future Phase B slice, not this pass
PS2-6 — client payment-key live invalidationclient-OTApackages/app/src/presentation/hooks/composites/use-live-session-data.tsany screen using useLiveSessionData (session detail, match board) — additive invalidation onlynone

Verification per slice

  • PS2-1: re-run this doc's own §2a repro as a migration test — after applying, the exact updateMatchTeams statement shape against an unsettled participant must raise settle-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-plan PY1 land in the same file — verify trigger name still sorts after sync_match_participant_ids if 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, matching protect_columns' existing behavior elsewhere — not an exception). Attempt amount = -1 via insert/update — assert a CHECK violation. Run the existing markPaid/markWaived mutation tests — assert no regression (they never touch the protected columns). Confirm mark_session_payment_paid_via_portone (SECDEF) still successfully sets status/paid_at/portone_payment_id — since it only writes portone_payment_id when marking a new PortOne-rail payment and never on an already-settled row, and it runs as postgres (bypasses protect_columns's CURRENT_USER gate), 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_expired signal is emitted with the correct dedup_key/recipient_user_id. (b) Cancel a confirmed RSVP with an idle hold — assert the corresponding session_payment_hold_created signal's resolved_at is set immediately (not left to its natural expires_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.

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