PS3 — Session & RSVP Lifecycle
Status: Archived Draws from: docs/audits/blocks/B2-sessions-backend.md (SS1–SS6, the core), docs/audits/blocks/U3-sessions.md (finding #1, payment-hold cache staleness — context, not re-solved here), docs/audits/blocks/U6-discovery.md (finding #1, the guest-apply funnel — SS1 is its backend root cause). Canonical server fix shapes are pre-negotiated in ../wiring-plans-2026-07-backend.md (SS1–SS6, provisional migrations 00397–00401); this document cites that plan rather than repeating it and adds the concurrency/state-machine research those fix shapes assume but don't spell out. Session canon: docs/canon/sessions.md, docs/canon/status-and-recruitment.md.
1. Problem statement
Five defects share one root theme: the server treats sessions/rsvps as a CRUD table pair instead of a lifecycle state machine, so every invariant the product actually depends on (waitlist is FIFO-ordered; a session can't be overbooked; status only moves forward through its intended graph; a non-member can preview a public pickup; a terminal session can't accept new RSVPs) is enforced by which button the client renders, not by the database.
| # | Defect | Source | Severity |
|---|---|---|---|
| SS1 | sessions_select RLS (00011) is is_club_member-only — PublicPickupPreviewScreen, built for non-members, can never load a row for its own target audience | B2 #1, U6 #1 | CRITICAL |
| SS2 | Voluntary confirmed→cancelled RSVP never promotes the waitlist — the only general-purpose promotion code (manageRsvpUseCase.handleCancel) has zero consumers and would fail RLS if wired up (writes another user's row as a non-admin) | B2 #2, U3 canon-note #1 | CRITICAL/HIGH |
| SS3 | private.assign_waitlist_position (00312) reads max_players/confirmed-count with no lock — concurrent last-seat RSVPs can both land confirmed | B2 #3 | HIGH |
| SS4 | private.guard_session_status_transition (00319) is a 2-rule blocklist — cancelled→completed and open→completed (skipping the RPC/cron path) are unblocked for a raw client write | B2 #4 | HIGH |
| SS5 | No trigger reads sessions.status before confirming an RSVP — a stale client or direct API call can confirm onto a cancelled/completed session | B2 #5 | MEDIUM |
(SS6 — max_players decrease doesn't reconcile lock status — is in-scope per the wiring plan's migration sequence but is a small, independent, low-risk fix already fully specified there; this document doesn't re-derive it. signals_on_rsvp_update's search_path=public hygiene gap (B2 #7) is swept by the cross-cutting hygiene migration 00413, not re-fixed here.)
Why these are one problem space, not five: SS2/SS3/SS5 all live in the same trigger seam (private.assign_waitlist_position, the single BEFORE gate every RSVP write passes through) and share one concurrency primitive (the atomic "next waitlisted row" dequeue). SS4 is the guard that makes SS2's new promotion path safe to compose with the existing cron/RPC writers without a second CURRENT_USER-exemption bug. SS1 is textually separate (RLS, not RSVP mechanics) but is the same class of defect — a predicate that was correct in one call site (the discovery RPCs) and never propagated to the sibling call site (the detail read) — and it is the literal backend root cause of U6's growth-funnel finding, so fixing it here closes both audits' top-severity item in one migration.
2. Research
2.1 Atomic waitlist dequeue under concurrency
The problem the current code doesn't have — yet, but the audit's own fix shape would introduce it if done naively. There are 3 promotion contexts today, none of which serialize against each other or against a genuinely concurrent second cancellation on the same session:
private.expire_unpaid_session_holds(00313→00385, cron*/5):sqlNo lock on theSELECT id INTO v_next FROM public.rsvps WHERE session_id = v_rec.session_id AND status = 'waitlisted' ORDER BY waitlist_position ASC NULLS LAST LIMIT 1; IF v_next IS NOT NULL THEN UPDATE public.rsvps SET status='confirmed' WHERE id=v_next; END IF;SELECT. Safe today only because the cron is a single scheduled invocation (no concurrent cron runs) and the outer loop already holdsFOR UPDATEon the freed RSVP row (not the promoted one).public.forfeit_session(00206→00310): the identical unlocked-SELECT-then-UPDATEshape, wrapped inBEGIN...EXCEPTION WHEN OTHERS THEN v_sub_id := NULLso a blocking cooldown/tier trigger on the promotion can never abort the forfeit itself.- The dead general path (
manageRsvpUseCase.handleCancel) — zero consumers, and would failrsvps_updateRLS for a non-admin canceller even if wired up (00011:264-274:(auth.uid() = user_id) OR is_club_admin(...)— promoting a different user's row satisfies neither for an ordinary member).
The gap SS2 asks us to close is exactly the case none of these three cover: a plain member cancelling their own confirmed RSVP on a free/open session. The fix must (a) run as SECDEF (the write touches a different user's row, same reason forfeit_session is SECDEF), (b) be triggered automatically so no client change is required, and (c) be safe if TWO cancellations on the SAME session commit in quick succession (two seats free, two different waitlisted users should each get exactly one of them — never the same user twice, never a promotion that silently no-ops because a race picked the same row).
Canonical concurrent-queue-pop pattern: SELECT ... FOR UPDATE SKIP LOCKED. This is the standard PostgreSQL idiom (native since 9.5, documented under the FOR UPDATE locking clause) for "N workers each pull the next unclaimed row off a shared queue without blocking on a row another worker already claimed." Applied here: instead of a plain SELECT ... ORDER BY waitlist_position LIMIT 1 (which two concurrent transactions could both read before either commits, or which would simply block the second transaction until the first commits under plain FOR UPDATE), FOR UPDATE SKIP LOCKED lets transaction B skip straight past a row transaction A has already locked and grab the next one — so two seats freeing at once promote two different waitlisted users in one pass instead of serializing unnecessarily or racing.
Design: one primitive, split into two layers, because the 3 promotion contexts don't all share the same eligibility gate even though they share the same dequeue mechanic:
-- Layer 1 — pure atomic dequeue. No opinion on session status; the caller
-- has already established a seat is free and eligible to be filled.
CREATE OR REPLACE FUNCTION private.dequeue_next_waitlisted(p_session_id UUID)
RETURNS UUID -- promoted user_id, or NULL if the waitlist is empty
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
DECLARE v_next RECORD;
BEGIN
SELECT id, user_id INTO v_next
FROM public.rsvps
WHERE session_id = p_session_id AND status = 'waitlisted'
ORDER BY waitlist_position ASC NULLS LAST
FOR UPDATE SKIP LOCKED
LIMIT 1;
IF v_next.id IS NULL THEN
RETURN NULL;
END IF;
UPDATE public.rsvps
SET status = 'confirmed', waitlist_position = NULL, cancelled_at = NULL
WHERE id = v_next.id;
RETURN v_next.user_id;
END;
$$;
REVOKE ALL ON FUNCTION private.dequeue_next_waitlisted(UUID) FROM PUBLIC;
-- Layer 2 — the pre-session ("still recruiting") eligibility gate. Used by
-- the new cancel-triggered promotion (SS2) and by expire_unpaid_session_holds.
CREATE OR REPLACE FUNCTION private.promote_next_waitlisted(p_session_id UUID)
RETURNS UUID
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
DECLARE
v_status public.session_status;
v_max INT;
v_confirmed INT;
BEGIN
-- Lock the session row FIRST — same lock object/order as SS3's capacity
-- gate (private.assign_waitlist_position), so the two functions can never
-- form an ABBA cycle: both take `sessions` as their one and only lock
-- before touching `rsvps`.
SELECT status, max_players INTO v_status, v_max
FROM public.sessions WHERE id = p_session_id FOR UPDATE;
IF v_status NOT IN ('open', 'locked') THEN
RETURN NULL; -- session isn't recruiting anymore — nothing to promote into
END IF;
SELECT COUNT(*) INTO v_confirmed
FROM public.rsvps WHERE session_id = p_session_id AND status = 'confirmed';
IF v_max > 0 AND v_confirmed >= v_max THEN
RETURN NULL; -- no seat actually free (defensive re-check)
END IF;
RETURN private.dequeue_next_waitlisted(p_session_id);
END;
$$;
REVOKE ALL ON FUNCTION private.promote_next_waitlisted(UUID) FROM PUBLIC;forfeit_session calls Layer 1 directly (private.dequeue_next_waitlisted), bypassing the open/locked gate — it runs mid-session (status = 'in_progress', already validated by the RPC's own precondition check), which Layer 2 would incorrectly reject. expire_unpaid_session_holds and the new cancel-trigger call Layer 2. This is the "one canonical primitive" the audit asks for at the level that actually matters — the concurrency-safety core (SKIP LOCKED dequeue) — while leaving each caller's own "is this seat-freeing event even promotion-eligible" check where it belongs, since that check is genuinely different pre-session vs. mid-session.
Recursion/self-trigger safety, traced explicitly (the class of check the sibling audits flag as commonly skipped): dequeue_next_waitlisted's own UPDATE ... SET status='confirmed' re-fires assign_waitlist_position (BEFORE UPDATE OF status) and autolock_session_on_capacity (AFTER). The BEFORE trigger's capacity count already excludes the row being updated (id IS DISTINCT FROM NEW.id, 00312), so it correctly counts the other confirmed rows post-cancel and does not re-demote the promotion. The AFTER trigger's own promotion hook only fires on OLD.status='confirmed' AND NEW.status='cancelled' — a waitlisted→confirmed transition never matches that predicate, so there is no infinite recursion. sync_session_payment_on_rsvp also re-fires on this UPDATE and correctly creates a pending hold for the promoted user on a fee session, since it keys purely off the resulting NEW.status, not the caller's identity — this is exactly the SS2 blast-radius item the wiring plan flags ("confirm the trigger fires the same for a trigger-driven UPDATE as a client-driven one") and it holds without any change to that trigger.
Lock ordering, extended: 00313 established "codebase-wide order: rsvps → session_payments" inside the hold-expiry cron. promote_next_waitlisted adds a third resource (sessions) taken after whatever rsvps/session_payments locks the caller already holds (the cron locks its rsvps row, then calls this, which locks sessions, then internally re-touches rsvps). assign_waitlist_position (SS3) never independently locks session_payments, so the full order is rsvps (caller's own row) → sessions → rsvps (promoted row), consistent in both callers (the trigger and the cron) — no path ever acquires sessions before an rsvps row it doesn't already hold, so no ABBA cycle exists.
2.2 Capacity-gate TOCTOU: why FOR UPDATE on the session row, not an advisory lock or SERIALIZABLE
Three candidate fixes, evaluated:
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE— correct in theory (Postgres's SSI would abort one of the two conflicting transactions), but impractical here: PostgREST/Supabase's data-API executes each request in its own implicit transaction at the connection pool's default isolation (READ COMMITTED); forcing SERIALIZABLE per-request isn't something a BEFORE trigger can retroactively impose on the outer transaction, and a serialization-failure retry loop isn't something the client mutation hooks are built to handle. Rejected.pg_advisory_xact_lock(hashtext(session_id::text))— workable, and slightly cheaper than a real row lock (no contention with, e.g., a concurrent session-edit that touches unrelated columns), but introduces a (very low, non-zero) hash-collision surface across unrelated session IDs, and — critically — is a different idiom from the one the codebase already uses for this exact class of race.00313'sexpire_unpaid_session_holdsalready establishedSELECT ... FOR UPDATEon the contended row as the house pattern for closing a payment-adjacent TOCTOU. Introducing a second idiom (advisory locks) for the sibling capacity race is an avoidable inconsistency.SELECT id FROM sessions WHERE id = NEW.session_id FOR UPDATEbefore the count, insideassign_waitlist_position— mirrors00313exactly, serializes concurrent RSVP writes on the same session (harmless at amateur-club scale — the audit's own read: "two people tapping RSVP within the same statement-execution window on the literal last seat is rare"), and is the one-line change the wiring plan (SS3) already specifies. Adopted.
The row lock does mean a concurrent, unrelated session-edit (e.g., an admin retitling the session while an RSVP write is in flight) briefly queues behind the RSVP transaction — an accepted, documented tradeoff, not a regression (session edits are rare, RSVP writes are the hot path this lock exists to protect).
2.3 Status machine: allowlist keyed on (OLD.status, NEW.status), scoped to client writers
The existing CURRENT_USER exemption is the key design lever, not a loophole to route around. 00319's guard_session_status_transition already does:
IF CURRENT_USER NOT IN ('authenticated', 'anon', 'authenticator') THEN
RETURN NEW; -- SECDEF RPCs + the pg_cron-invoked auto-advance function pass through untouched
END IF;This is verified correct in B2's adversarial probes (CURRENT_USER reflects the real connection role, not a spoofable session variable; every SECDEF RPC in this blast radius is owned by a role outside the exempted list). Because of this, the transition table only needs to constrain raw client writes (session.supabase.ts:updateStatus, a plain authenticated .update()) — the cron's open/locked→completed (rule 2a/2b) and →cancelled-via-RPC paths never touch this guard's client branch at all.
Grepping every call site of updateStatus confirms the client only ever writes two transitions today: useStartSession → 'in_progress' (use-start-session.ts:65,124) and useEndSession → 'completed' (use-end-session.ts:14). No client code path writes 'open', 'locked', 'draft', or 'cancelled' directly — cancellation is exclusively through cancel_session_as_host (00307, SECDEF) or cancel_pickup_session_without_strike (00215, SECDEF), both exempted by CURRENT_USER.
Allowlist for the client branch (everything else raises):
| OLD → NEW | Legal for a client write? | Why |
|---|---|---|
open → in_progress | Yes | The common case — most pickups start before ever reaching capacity |
locked → in_progress | Yes | A session that filled up must still be startable; blocking this would strand every capacity-locked session forever |
in_progress → completed | Yes | useEndSession's only write |
cancelled → completed | No — newly blocked | The audit-named CRITICAL gap: would re-run the 00384 v3 completion sweep against a session whose RSVPs/payments were already unwound by the cancel path |
open → completed (direct skip) | No — newly blocked | The audit-named state-skip gap; this transition is legitimately made by the cron (rule 2a/2b) and by useEndSession only from in_progress — a client-initiated direct skip has no legitimate origin |
open → cancelled, locked → cancelled, in_progress → cancelled (raw) | No | Must go through the SECDEF cancel RPCs so the 00307 refund-obligation trigger always arms |
draft → open, any X → draft | No (unchanged from 00319) | Draft rewind is the cascade-wipe vector; no evidence of a live client draft→open raw-write path today (draft-resume finalizes via the create flow, not a bare status PATCH) — if one is added later it must be its own allowlist entry, reviewed alongside the delete-safety invariant in 00319 |
anything → completed where OLD = 'completed' (no-op re-save) | Pass through silently | NEW.status = OLD.status is not a transition — must not raise on an ordinary column re-save |
CREATE OR REPLACE FUNCTION private.guard_session_status_transition()
RETURNS TRIGGER LANGUAGE plpgsql SET search_path = ''
AS $$
BEGIN
IF CURRENT_USER NOT IN ('authenticated', 'anon', 'authenticator') THEN
RETURN NEW; -- unchanged: SECDEF RPCs + cron pass through
END IF;
IF NEW.status IS DISTINCT FROM OLD.status THEN
IF (OLD.status, NEW.status) NOT IN (
('open', 'in_progress'),
('locked', 'in_progress'),
('in_progress', 'completed')
) THEN
RAISE EXCEPTION 'Illegal session status transition: % -> %', OLD.status, NEW.status
USING ERRCODE = 'P0001', HINT = 'illegal_status_transition';
END IF;
END IF;
RETURN NEW;
END;
$$;This is strictly more defensive than the blocklist it replaces: every transition not explicitly named is rejected, including ones nobody has thought of yet, rather than only the two the team happened to notice.
2.4 Public pickup preview: mirror the already-proven discovery predicate
s.visibility = 'public' AND s.public_invite_enabled = TRUE is not a new predicate to design — it is already the exact clause used by 8 discovery RPC migrations (00256, 00259, 00261, 00262, 00267, 00268, 00270, 00331), and 00017 already carries a partial index built for this precise shape:
CREATE INDEX ... ON public.sessions (date, public_invite_enabled, visibility)
WHERE public_invite_enabled = true AND visibility = 'public';The fix is to promote this predicate from "discovery RPC internals" to a second permissive RLS policy on sessions itself (Postgres ORs multiple permissive policies together — confirmed no second SELECT policy exists today, grep across all 386 migrations), so the raw table read (session.supabase.ts:findById, used by useSessionQuery and therefore by PublicPickupPreviewScreen) gets the same visibility get_club_discovery_summary already has via SECDEF. get_public_share_preview (00306) is precedent for granting anon directly on a public-facing RPC (GRANT EXECUTE ... TO anon, authenticated) — the preview screen's own CTA state machine has an explicit unauthenticated → "로그인하고 신청하기" branch, so anon should be included in the new policy's TO clause, not just authenticated.
3. Solution design
SS1 — Second permissive sessions_select policy (mirrors discovery RPCs)
CREATE POLICY sessions_select_public ON public.sessions
FOR SELECT TO authenticated, anon
USING (visibility = 'public' AND public_invite_enabled = true);Depends on the table-level anon GRANT existing on sessions — this is covered by PS1's S4 sweep (00391, ahead of this migration in the plan's provisional numbering). If PS1 hasn't landed yet when this ships, this migration must include its own narrow GRANT SELECT ON public.sessions TO anon; rather than block on the full 82-table sweep. No write-path change. findByIds bulk paths mixing own-club + public sessions benefit for free (same predicate now covers both policies via OR).
SS2 — promote_next_waitlisted wired into the cancel path + the two existing promotion sites unified
Extend private.autolock_session_on_capacity (00312, already the AFTER INSERT OR UPDATE OF status ON rsvps seam) — or add a sibling AFTER trigger on the same event, whichever keeps the diff smallest — with:
IF TG_OP = 'UPDATE' AND OLD.status = 'confirmed' AND NEW.status = 'cancelled' THEN
PERFORM private.promote_next_waitlisted(NEW.session_id);
END IF;placed before the existing open/locked re-evaluation in the same function body, so the promoted row is already confirmed when the capacity comparison runs immediately after (avoids a spurious locked→open→locked flap in the same statement).
Then, mechanically:
expire_unpaid_session_holds(00313/00385): replace the inlineSELECT ... ORDER BY waitlist_position LIMIT 1; UPDATE ...block withPERFORM private.promote_next_waitlisted(v_rec.session_id);.forfeit_session(00310): replace its inline promotion block withv_sub_id := private.dequeue_next_waitlisted(p_session_id);(Layer 1, since forfeit runs mid-session and Layer 2's open/locked gate would wrongly reject it) — keep the surroundingBEGIN...EXCEPTION WHEN OTHERSwrapper so a blocking trigger can never abort the forfeit.- Delete
packages/app/src/application/session/manage-rsvp.usecase.ts's deadhandleCancelpromotion logic (or leave it with a@deprecated — superseded by the DB trigger, 00398comment; deleting is preferred — dead code with zero consumers per the audit's own grep).
session_waitlist_promoted needs no change — it already fires correctly on any waitlisted→confirmed transition regardless of cause (verified in B2).
SS3 + SS5 — Bundle into assign_waitlist_position (same function, same migration)
CREATE OR REPLACE FUNCTION private.assign_waitlist_position()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
DECLARE
v_max INT; v_confirmed INT; v_status public.session_status;
BEGIN
IF NEW.status = 'confirmed'
AND (TG_OP = 'INSERT' OR OLD.status IS DISTINCT FROM 'confirmed') THEN
-- SS5: no confirm onto a session that isn't recruiting.
SELECT status, max_players INTO v_status, v_max
FROM public.sessions WHERE id = NEW.session_id FOR UPDATE; -- SS3 lock
IF v_status NOT IN ('open', 'locked') THEN
RAISE EXCEPTION 'Cannot RSVP to a % session' , v_status
USING ERRCODE = 'P0001', HINT = 'session_not_recruiting';
END IF;
IF v_max > 0 THEN
SELECT COUNT(*) INTO v_confirmed
FROM public.rsvps
WHERE session_id = NEW.session_id AND status = 'confirmed'
AND id IS DISTINCT FROM NEW.id;
IF v_confirmed >= v_max THEN
NEW.status := 'waitlisted';
END IF;
END IF;
END IF;
-- unchanged position assignment (00232)
IF NEW.status = 'waitlisted' AND NEW.waitlist_position IS NULL THEN
SELECT COALESCE(MAX(r.waitlist_position), 0) + 1 INTO NEW.waitlist_position
FROM public.rsvps r
WHERE r.session_id = NEW.session_id AND r.status = 'waitlisted'
AND r.id IS DISTINCT FROM NEW.id;
ELSIF NEW.status IS DISTINCT FROM 'waitlisted' THEN
NEW.waitlist_position := NULL;
END IF;
RETURN NEW;
END;
$$;Note: SS5's exception fires before the capacity check, so a confirm attempt on a cancelled/completed session is rejected outright rather than silently waitlisted — matches the tier/cooldown guards' RAISE ... HINT shape so the client can surface a specific toast (mirrors rsvps_enforce_tier/rsvps_enforce_cooldown, 00230/00133).
SS4 — Allowlist transition guard
As specified in §2.3 — same function (private.guard_session_status_transition), same trigger wiring, no new trigger.
4. Slices
| # | Migration | Scope | Type | Blast radius | Verification | Depends on |
|---|---|---|---|---|---|---|
| 1 | 00397 | SS1 — sessions_select_public policy (+ narrow anon GRANT if PS1/S4 hasn't landed yet) | migration | sessions reads only | As non-member/anon: SELECT * FROM sessions WHERE id = <public pickup id> returns the row; a private/non-invite session returns 0 rows for the same caller. Manual: load /sessions/[id]/preview unauthenticated against local Supabase, confirm it renders instead of "존재하지 않는 모임." | PS1 S4 (table GRANT sweep) — soft dependency, see fallback above |
| 2 | 00398 | SS2 — private.dequeue_next_waitlisted + private.promote_next_waitlisted, wired into autolock_session_on_capacity, expire_unpaid_session_holds, forfeit_session; delete dead manageRsvpUseCase.handleCancel promotion code | migration + 1 client cleanup commit | rsvps, sessions (via autolock interaction), session_payments (fee-session hold creation on promote) | Local psql: session at capacity, 1 waitlisted user (known waitlist_position) — cancel a confirmed member's RSVP as that member (not admin) — assert the waitlisted user flips to confirmed in the same statement, session_waitlist_promoted fires, and (fee session) a session_payments pending hold appears for the promoted user. Concurrency probe: two confirmed members cancel in near-simultaneous separate connections on a session with 2 waitlisted users — assert both waitlisted users get promoted (not the same one twice, not zero). | Slice 3 (shares the sessions FOR UPDATE lock idiom — sequence together or verify lock-order compatibility explicitly if split) |
| 3 | 00399 | SS3 + SS5 — capacity FOR UPDATE lock + RSVP-confirm status gate, both in assign_waitlist_position | migration | rsvps INSERT/UPDATE-of-status path | Two concurrent transactions (separate psql sessions) both confirming the literal last seat — assert exactly one lands confirmed, the other waitlisted, never both. Raw confirm attempt on a cancelled/completed session — expect a raised exception with HINT='session_not_recruiting'. | none |
| 4 | 00400 | SS6 — max_players decrease reconciliation (fully specified in the wiring plan, not re-derived here) | migration | sessions UPDATE path | Per wiring plan | none |
| 5 | 00401 | SS4 — status transition allowlist | migration | Every sessions.status client write path (useStartSession, useEndSession) + audit of the auto-advance cron/RPCs against the allowlist (none should be blocked — they're CURRENT_USER-exempt) | Raw .update({status}) attempts for cancelled→completed and open→completed (direct) both raise illegal_status_transition. open→in_progress, locked→in_progress, in_progress→completed all still succeed. Full node scripts/run-scenario.mjs live_showcase passes unmodified (cheapest sufficient regression proof — no hosted Maestro run needed, this is a DB-trigger-only change with no native surface). | Sequence after slices 2/3 land and are verified — touches the same status machine those interact with (promotion, capacity) |
Ordering rationale: SS1 is fully independent — ship first, it's the highest-severity single-migration fix and unblocks the U6 growth funnel immediately. SS2/SS3 share the sessions FOR UPDATE lock idiom and should be verified together even if shipped as separate migrations (their interaction is exactly what §2.1's "Lock ordering, extended" section reasons through). SS4 goes last because it constrains every write path the earlier slices touch — shipping it first would risk blocking a legitimate transition SS2/SS3's own fixes rely on before that interaction is fully traced.
Not in scope for this document: the signals_on_rsvp_updatesearch_path hygiene fix (B2 #7) — swept by 00413 alongside the other 21 functions. rsvps_delete's dead-policy status (B2 #8, INFO-only, no action needed). U3's payment-hold cache-staleness finding (add sessionPaymentKeys to useLiveSessionData's poll list, or gate the reserved-panel render on viewerParticipating) — a client-only fix, tracked separately, not a backend lifecycle defect.