Skip to content

PS6 — Clubs & Membership Lifecycle

Status: Archived Draws from: B1-clubs-backend.md (findings #1–#7), U2-clubs.md (findings #1, #3, #4, #5, #6 — #2 dues partial-payment and #7–#11 are out of this space's scope: #2 belongs to PS5/dues, #7 is a canon-doc fix, #8–#10 are cosmetic/dead-code items folded in as small slices, #11 is already-tracked ratchet debt). Cites, does not repeat:wiring-plans-2026-07-backend.md §CLUBS (C1C4, provisional migrations 0039400396) and design-specs-2026-07-ui.md §U2 + Wireframe A. Club canon: docs/canon/clubs.md, docs/canon/components.md.

Boundary with PS7 (Identity, account & consent): delete_account_atomic (00186) is one RPC with two independent halves. PS7 owns the profiles/user_consent/pi_access_log half (soft-delete stamping, consent withdrawal, PIPA audit row) — untouched here. This document owns the club-membership half: what happens to a deleted user's club_members rows, and specifically the sole-owner succession problem that half creates. The mechanism is a new trigger on profiles.deleted_at (§3.1), not an edit to delete_account_atomic itself, so the two halves ship independently and never touch the same function body — see §3.1 for why this separation is load-bearing, not incidental.

Boundary with PS1 (Access control) on guest_applications: B1 finding #3 has two independently-shippable halves. PS1 already claims the RLS lockdown half as its own slice P5 (docs/architecture/solutions/ps1-access-control.md:366-377, policy renamed guest_applications_admin_update_rpc_only, USING (false), mirrors 00329 exactly) — not re-planned here. This document owns the capacity/status-gate half (§3.5) — a session-lifecycle business rule inside decide_guest_application and its auto-approve trigger, not an access-control predicate. Cross-doc drift flagged for the roadmap: PS1's own text says this second half was "filed to PS3" (Session & RSVP lifecycle) — but PS3 (ps3-sessions-rsvp.md, read in full for this document) contains no guest_applications fix anywhere in its SS1–SS6 scope. Per this task's explicit assignment ("PS6 owns the club-domain specifics"), PS6 claims it here so it doesn't fall through the crack between the two docs; the roadmap doc should reconcile the PS1 cross-reference when it's written.


1. Problem statement

Six defects share one root theme: the server models club membership as a static row, not a lifecycle — nothing in the schema ever transitions club_members.is_active to false for a live member, nothing resolves "the owner is gone" into a new owner, and three real membership events (leave/removal, settings change, unarchive) are invisible to the notification system that every sibling lifecycle event already uses. A seventh defect is a fully-built, fully-hardened server capability (join-request approval) with zero client UI — a pure wiring gap, not a design gap.

#DefectSourceSeverity
M1Sole-owner account deletion permanently locks the club — no path lets anyone become owner againB1 #1CRITICAL
M2Deleted/departed accounts stay is_active=true in club_members forever — ghost members permanently consume max_members capacityB1 #2CRITICAL (root cause of M1, independently harmful)
M3Join-request admin review has a fully-built, adversarially-hardened backend (00213/00320/00321/00328/00329) and zero consuming UI — useClubJoinRequests has no screen, useDecideJoinRequest isn't even exportedU2 #1, B1 #8 (confirms)CRITICAL (client-only)
M4guest_applications capacity/status gate: neither decide_guest_application nor its auto-approve trigger checks the session's max_players/status before upserting a confirmed RSVPB1 #3 (second half)HIGH
M5Club lifecycle silence: no signal on member-left/removed, dues_amount change, or unarchiveB1 #5MEDIUM
M6Member rows, club-ranking rows, and the transfer-ownership picker render backend-backed identity as dead text or raw truncated userIdsU2 #3, #6HIGH / MEDIUM
M7club-card-model.ts/club-card-summary.ts/use-club-attendance-summary.ts reintroduce the ICU localeCompare freeze pattern fixed 2026-07-10 in the sibling venue directoryU2 #4, #5MEDIUM-HIGH (latent)

role_configs (B1 #4) is deliberately not in this table — see §3.6 for why it stays a defer, not a slice.

Why these are one problem space: M1/M2 are one bug traced to one root cause (no code path ever writes club_members.is_active = false for a live member — B1's own grep confirms zero hits) and one fix (§3.1) closes both. M3/M4 are the two halves of "the join/apply pipeline has a business-logic gap the server doesn't close" — M3 is pure absence (no UI), M4 is a real server gap in the sibling table with an identical shape (club_join_request already got its capacity-adjacent hardening across 00304/00320/00329; guest_applications never did). M5 is the lifecycle-signal gap every other club_members/clubs mutation already closed (00121 role change, 00214 transfer/archive, 00328 join-decision) except these three events. M6/M7 are both client-only "backend-backed data rendered wrong" defects on the same screens M3 ships into (club-members-screen.tsx, discovery/ranking), so they're batched as small companion slices rather than a separate problem space.


2. Research

2.1 Sole-owner succession: what the schema currently allows, verified live

Confirmed by reading migration bodies (not just the audit's prose) and cross-checking against the local schema:

  • transfer_club_ownership (00214:59-90) hard-requires the caller to currently hold role='owner' AND is_active=true — an account-deleted owner can never call it again (auth.uid() no longer resolves to a live session).
  • update_member_role (00121:12-70) lets an existing owner promote anyone to owner (p_new_role='owner' AND v_caller_role != 'owner' → rejected at line 49-51) — a surviving admin has no self-promotion path past a ghost owner.
  • prevent_last_owner_removal (00311:169-199) is a BEFORE DELETE trigger — it guards the DELETE path (kick/leave) and the CASCADE-skip case (club purge), but there is no DELETE on the ghost owner's row in the account-deletion path (delete_account_atomic only touches profiles), so this trigger never fires for the scenario at all. It is not a bug in 00311 — it was never asked to guard an UPDATE.
  • club_members.joined_at TIMESTAMPTZ NOT NULL DEFAULT now() (00004:8) exists on every row today — the exact column "longest-tenured admin" succession needs, with no schema change required.
  • countActiveMembers/join_club_member_checked's capacity check (00304:54-63) is SELECT COUNT(*) ... WHERE is_active = true against club_members.max_members — confirms M2's capacity-leak claim precisely: every ghost row inflates this count forever.

Ownership-succession pattern, grounded in prior art: the "owner leaves without designating a successor" problem is a solved pattern in every platform with organization-owned resources — GitHub org owners require ≥1 owner at all times and block the last owner from leaving; Slack workspaces auto-promote based on admin seniority when the sole owner's account is deactivated by an org-wide SCIM deprovisioning event (not a self-service leave, which Slack blocks outright the same way 00311 does). The PIPA §37 constraint this app operates under is the key divergence: account deletion cannot be blocked (unlike a Slack/GitHub self-service "leave org," deletion is a statutory right), so this app cannot adopt the block-until-transferred pattern for the deletion path even though 00311 already correctly adopts it for the leave/kick path. That leaves exactly the two options B1 already named — auto-transfer or auto-archive — and the auto-transfer-with-archive-fallback shape is the standard "deprovisioning" resolution (SCIM off-boarding runbooks universally prefer reassignment over resource death when any eligible successor exists, falling back to archival only when none does).

Why the fallback should be tenure, not role rank: among admins, match_director is excluded from succession (it's a functional role, not a governance one — role_configs's own default permissions never grant match_director manage_club, confirmed at club.entity.ts:70-73). Among admin-role candidates, tenure (joined_at ASC) is the fairest tie-break with zero product-configurable input needed — it's the same "longest continuously present" signal Slack's own admin-succession runbook uses, and it needs no new column or admin-designated "next in line" UI (a real feature, out of scope here — flagged as a possible Phase-11 enhancement, not a blocker).

2.2 Why a trigger on profiles.deleted_at, not an edit to delete_account_atomic

Two candidate designs:

  • (a) Inline the club-membership logic into delete_account_atomic itself. Rejected: delete_account_atomic is PS7-owned (consent + profile PII), and PS7's own remediation (A1–A5 in the wiring plan) is actively editing PII-adjacent parts of the profiles table surface in the same review cycle. Two problem spaces editing one function body is exactly the drift risk docs/workflows/multi-agent-operating-model.md exists to prevent — a merge conflict or an accidental double-edit is a needless risk when the alternative is free.
  • (b) A new AFTER UPDATE OF deleted_at ON public.profiles trigger, firing WHEN (OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL), calling a new SECDEF helper this document owns entirely. Adopted. Because delete_account_atomic already performs UPDATE profiles SET deleted_at = now() ... WHERE deleted_at IS NULL (00186:71-77) inside its own transaction, the trigger fires atomically in the same commit — functionally identical to inlining, with zero shared-file blast radius. PS7 can freely edit delete_account_atomic's consent/audit-log body in parallel without ever touching this trigger, and vice versa. This is also forward-compatible with any other future path that sets deleted_at (an admin-initiated hard-deprovision RPC, say) — it would get club-membership cleanup for free without a second call site to remember.

Does the 30-day anonymize cron need its own copy of this logic? No — private.anonymize_withdrawn_profiles (00157/00184) runs 30 days after deleted_at is already set; it never re-sets deleted_at (confirmed: it scrubs PII columns, never touches the deleted_at column itself), so the AFTER UPDATE OF deleted_at trigger never fires a second time for the same row. The club-membership cleanup this document designs runs exactly once, at the moment of deletion — correct, since ghost-row deactivation and ownership succession are decisions that should happen immediately (a club shouldn't wait 30 days to regain a functioning owner), not at the hard-anonymize horizon.

2.3 Reusing release_member_future_seats_on_leave (00322) without duplicating it

00322's own header comment is explicit about the gap this document closes: "account deletion is handled separately" (i.e., 00322 scoped itself to the club_members DELETE path — leave/kick — and explicitly deferred the deletion-triggered path). Its trigger function, private.release_member_future_seats_on_leave(), is shaped as an AFTER DELETE trigger taking OLD.club_id/OLD.user_id from the deleted row — it cannot be reused verbatim from an UPDATE OF deleted_at ON profiles trigger, which has no club_members row in its trigger context at all (a user can belong to N clubs). The correct reuse shape is extraction, not duplication: refactor the row-releasing loop body into a private.release_member_future_seats(p_club_id UUID, p_user_id UUID) helper both triggers call — the DELETE trigger passes OLD.club_id/ OLD.user_id (unchanged behavior), the new deletion-driven cleanup calls it once per active club membership the deleted user held. Same pre-seed-excused-then-cancel shape, same attendance_tracking_enabled-gated pre-seed, same cascade-skip guard (EXISTS (SELECT 1 FROM clubs WHERE id = p_club_id)) — ports unchanged since the guard already checks the parameterized club_id, not OLD-record magic.

2.4 guest_applications capacity/status gate — mirroring join_club_member_checked, not assign_waitlist_position

Column-name correction (multi-source verification catch): the wiring plan's C2 prose says "checks the session's max_participants" — this is wrong. Reading 00005_sessions.sql:12 directly: the column is max_players INTEGER NOT NULL DEFAULT 16. max_participants does not exist anywhere in the schema (grep -rn max_participants supabase/migrations/*.sql → 0 hits). This document uses the verified column name throughout.

Two existing capacity-check idioms are available to mirror:

  • private.join_club_member_checked (00304:29-63) — locks the parent row FOR SHARE, counts active members, raises a friendly ERRCODE/HINT pair (join_club_full) rather than silently degrading.
  • private.assign_waitlist_position (00312, extended by PS3's SS3/SS5 design in ps3-sessions-rsvp.md:390-434) — locks the session row FOR UPDATE, and on over-capacity degrades to waitlisted rather than raising, because RSVP is a self-service action where waitlisting is the expected fallback UX.

decide_guest_application should mirror the second idiom (degrade to waitlist), not the first (raise). Reasoning: a guest application is already async and host-mediated — the host is actively deciding "approve this person," and if the session filled up between the applicant applying and the host approving, silently waitlisting (rather than raising an opaque ERRCODE back through an RPC call the host UI wasn't built to surface as a retry-able error) preserves the host's mental model of "I approved them" while being honest about the seat. This is the same tradeoff PS3's own SS5 design reasoned through for the sibling RSVP path, and the two should stay consistent since a guest-approved RSVP and a member-confirmed RSVP land in the exact same rsvps table read by the exact same session-detail screen.

Session-status gate (cancelled/completed) should raise, not degrade — mirrors PS3's SS5 exactly (RAISE ... HINT := 'session_not_recruiting') since there is no sensible "waitlist into a session that already happened." Reusing PS3's own hint string means the client's existing (or soon-to-exist, per PS3) toast-mapping for that hint covers this call site for free — one string, one UI branch, two RPCs.

2.5 Join-request UI: the exact component seam that already exists

Read club-members-screen.tsx in full (618 lines). Three facts ground the design in what's actually shipped, not a fresh composition:

  1. GroupedFeedList<MemberItem>'s header prop (club-members-screen.tsx:549-585) already renders arbitrary JSX above the sectioned list (the hero card + the 파트너 추천 entry row) — this is the existing seam for a new "가입 신청" block, not a new list-composition mechanism.
  2. admin-snapshot-cells.ts:43-44,72-76 already routespendingJoinRequestsCount to routes.clubMembers(clubId) with a comment reading "same destination as the 멤버 row's join-request flow" — i.e., this integration point was already planned when that cell was built (R2-2). No route change, no new screen — the fix is additive content on an existing destination.
  3. getRoleLabel (club-members-screen.tsx:85-99) already has the exact Korean role labels (소유자/운영진/경기 디렉터/멤버 — note: read the live source, "owner"→"소유자" here, distinct from 대표 used in DEFAULT_ROLE_CONFIGS's title field, club.entity.ts:56 — these are two different label sources for the same role key; getRoleLabel is the one already used by this screen and is the one M6's transfer-modal fix should reuse, not role_configs.title, to avoid a third label source).

useClubJoinRequests(clubId, status) (use-club-join-requests.ts:15-23) returns ClubJoinRequest[] with message: string | null and createdAt: Date (club.entity.ts:298-311) — exactly the fields Wireframe A's row needs (applicant message + relative applied-at), no adapter/entity change required. useDecideJoinRequest (use-decide-join-request.ts) already invalidates joinRequestKeys.byClub(clubId, 'pending') + clubKeys.members(clubId) on success — but not the admin-snapshot query key, so the AdminTab's "가입 신청 N건" count would go stale after a decide from the members screen until the next natural refetch. Confirmed via grep: no adminSnapshotKeys/clubAdminSnapshot key exists in query-keys.ts today (the admin snapshot RPC, 00381, has no dedicated TanStack key constant this document could find) — flagged as a real gap to close in the same slice (§3.3) rather than left implicit.

2.6 Signal design: two already-reserved TS union members, one net-new

Reading signal.entity.ts directly (not just the audit's grep) surfaces a detail the audit's grep-for-emitters approach couldn't: club_member_left and club_settings_updated already exist in the SignalType union (signal.entity.ts:~94-95 in the type, and again in the exhaustive test array signal.entity.test.ts:84-86-adjacent list) — they were added speculatively (comment context nearby: "drift rescue... DB already emits these... but they were never added to the TS union" describes club_ownership_transferred/club_archived/join_request_decided, a different, already-closed drift; club_member_left/ club_settings_updated are a second, still-open case of the same class — declared, never emitted, zero consumers found in packages/app/src or packages/features outside the entity/test files themselves). This means: no TS union change needed for M5's member-left and settings-changed signals — only the DB emitter + i18n copy. A genuinely new type is needed only for unarchive (no existing placeholder found for it anywhere in the union).

i18n copy confirmed absent for all threegrep signal\.club\. across packages/app/src/config/i18n/{ko,en}/signals.ts finds roleChanged, joinApproved, joinRejected, memberJoined, applicationReceived, announcement, joinRequestReceived — no memberLeft, settingsUpdated, or unarchived keys exist yet.

Routing needs no new branch. signal-routes.ts:39-40's generic fallback (category === 'club' → routes.club(clubId)) already covers any club-category signal without a named branch — confirmed by reading the function in full: only 4 club-adjacent types get a named override (dues/matchboard/session/post/join-request-to-admin-tab); everything else in the club category falls through to the club-home route, which is the correct destination for all three new signals here (a removed member landing on club-home reads "you're no longer able to see this club's content," which is itself informative).

2.7 The ICU localeCompare fix — same idiom as the already-shipped venue fix, verified at the exact call sites

Confirmed all 5 sites via direct read, not just the audit's line numbers:

  • club-card-summary.ts:30sessionSortKey(a).localeCompare(sessionSortKey(b)) sorting YYYY-MM-DDTHH:MM-shaped ISO strings.
  • club-card-summary.ts:55,59 — chained localeCompare on the same ISO shape, then clubRegionSortKey (a region|district|name composite).
  • club-card-model.ts:396,399 — identical ISO + composite-string shape in sortClubCardModelsForDiscovery's tie-break.
  • use-club-attendance-summary.ts:78a.displayName.localeCompare(b.displayName, 'ko'), the literal two-argument Korean-locale form the venue postmortem named as the worst case (explicit 'ko' forces ICU collation, not just default-locale comparison).

The already-shipped fix (venue-directory-content.tsx:110-118, commit 030420b3) establishes the house idiom precisely:

ts
// tie-break with a PLAIN string compare, never `localeCompare('ko')` —
// Hermes routes that through ICU and it is ~1000× the cost.
a.title < b.title ? -1 : a.title > b.title ? 1 : 0;

All 5 sites here compare either ISO-8601 timestamps or plain concatenated-field composites — both sort correctly byte-wise with </>, so the fix is a mechanical drop-in at each site, not a design decision. use-club-attendance-summary.ts's Korean displayName tie-break loses true Korean collation ordering (a real but minor UX regression — names sort by UTF-16 code unit, not Hangul phonetic order) which the venue fix's own precedent already accepted for the identical tradeoff on venue names.


3. Solution design

3.1 M1 + M2 — private.deactivate_departed_member_state(p_user_id UUID), wired via a new profiles.deleted_at trigger

sql
-- Refactor: extract 00322's loop body into a callable, parameterized helper.
-- The existing AFTER DELETE trigger on club_members now calls this with
-- OLD.club_id/OLD.user_id — behavior-identical, just no longer inlined.
CREATE OR REPLACE FUNCTION private.release_member_future_seats(
  p_club_id UUID, p_user_id UUID
) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
DECLARE v_rsvp RECORD;
BEGIN
  IF NOT EXISTS (SELECT 1 FROM public.clubs WHERE id = p_club_id) THEN
    RETURN;
  END IF;

  FOR v_rsvp IN
    SELECT r.id, r.session_id
    FROM public.rsvps r JOIN public.sessions s ON s.id = r.session_id
    WHERE s.club_id = p_club_id AND r.user_id = p_user_id
      AND r.status IN ('confirmed', 'waitlisted')
      AND (s.date + s.start_time) AT TIME ZONE COALESCE(s.iana_timezone, 'Asia/Seoul') > now()
  LOOP
    INSERT INTO public.attendance_records (
      user_id, session_id, club_id, rsvp_status, final_status, strike_value, confirmed_by
    )
    SELECT p_user_id, v_rsvp.session_id, p_club_id, 'going', 'excused', 0, 'admin'
    WHERE EXISTS (SELECT 1 FROM public.clubs WHERE id = p_club_id AND attendance_tracking_enabled = true)
    ON CONFLICT (user_id, session_id) DO UPDATE
      SET final_status = 'excused', strike_value = 0, confirmed_by = 'admin';

    UPDATE public.rsvps SET status = 'cancelled' WHERE id = v_rsvp.id;
  END LOOP;
END;
$$;

CREATE OR REPLACE FUNCTION private.release_member_future_seats_on_leave()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
  PERFORM private.release_member_future_seats(OLD.club_id, OLD.user_id);
  RETURN OLD;
END;
$$;
-- trigger wiring (00322) unchanged — same function name, same signature.

-- Net-new: the account-deletion cleanup, one row per active club membership.
CREATE OR REPLACE FUNCTION private.deactivate_departed_member_state(p_user_id UUID)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
DECLARE
  v_row     RECORD;
  v_admin   RECORD;
  v_any_admin BOOLEAN;
BEGIN
  FOR v_row IN
    SELECT club_id, role FROM public.club_members
    WHERE user_id = p_user_id AND is_active = true
  LOOP
    -- Sole-owner succession, resolved BEFORE deactivating this row.
    IF v_row.role = 'owner' THEN
      SELECT user_id INTO v_admin
      FROM public.club_members
      WHERE club_id = v_row.club_id AND role = 'admin' AND is_active = true
        AND user_id <> p_user_id
      ORDER BY joined_at ASC
      LIMIT 1;

      IF v_admin.user_id IS NOT NULL THEN
        UPDATE public.club_members SET role = 'owner' WHERE club_id = v_row.club_id AND user_id = v_admin.user_id;
        PERFORM public.signals_emit(
          p_type := 'club_ownership_transferred', p_category := 'club'::public.signal_category,
          p_severity := 'high'::public.signal_severity, p_recipient_user_id := v_admin.user_id,
          p_dedup_key := 'transfer:' || v_row.club_id::text || ':to:' || v_admin.user_id::text,
          p_title_key := 'signal.club.ownershipTransferred.title',
          p_body_key := 'signal.club.ownershipTransferred.body',
          p_context := jsonb_build_object('clubId', v_row.club_id),
          p_payload := jsonb_build_object('clubId', v_row.club_id),
          p_expires_at := now() + INTERVAL '7 days'
        );
      ELSE
        -- No surviving admin: archive, reusing archive_club's own notify-loop shape.
        UPDATE public.clubs SET archived_at = now() WHERE id = v_row.club_id AND archived_at IS NULL;
        FOR v_admin IN
          SELECT user_id FROM public.club_members WHERE club_id = v_row.club_id AND is_active = true
        LOOP
          PERFORM public.signals_emit(
            p_type := 'club_archived', p_category := 'club'::public.signal_category,
            p_severity := 'high'::public.signal_severity, p_recipient_user_id := v_admin.user_id,
            p_dedup_key := 'archive:' || v_row.club_id::text || ':' || v_admin.user_id::text,
            p_title_key := 'signal.club.archived.title', p_body_key := 'signal.club.archived.body',
            p_context := jsonb_build_object('clubId', v_row.club_id),
            p_payload := jsonb_build_object('clubId', v_row.club_id),
            p_expires_at := now() + INTERVAL '30 days'
          );
        END LOOP;
      END IF;
    END IF;

    -- First legitimate writer of is_active=false for a non-deleted row.
    UPDATE public.club_members SET is_active = false
    WHERE club_id = v_row.club_id AND user_id = p_user_id;

    PERFORM private.release_member_future_seats(v_row.club_id, p_user_id);

    UPDATE public.dues SET status = 'waived', updated_at = now()
    WHERE club_id = v_row.club_id AND user_id = p_user_id AND status IN ('unpaid', 'partial');
  END LOOP;
END;
$$;
REVOKE ALL ON FUNCTION private.deactivate_departed_member_state(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION private.deactivate_departed_member_state(UUID) TO postgres;

CREATE OR REPLACE FUNCTION private.on_profile_deleted_deactivate_memberships()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
  IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
    PERFORM private.deactivate_departed_member_state(NEW.id);
  END IF;
  RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION private.on_profile_deleted_deactivate_memberships() FROM PUBLIC;

DROP TRIGGER IF EXISTS trg_on_profile_deleted_deactivate_memberships ON public.profiles;
CREATE TRIGGER trg_on_profile_deleted_deactivate_memberships
  AFTER UPDATE OF deleted_at ON public.profiles
  FOR EACH ROW EXECUTE FUNCTION private.on_profile_deleted_deactivate_memberships();

Every capacity check reading club_members WHERE is_active = true (join_club_member_checked, countActiveMembers) benefits immediately — no separate fix needed. is_club_member/is_club_admin/is_club_owner (00011:26-70) already key off is_active = true, so a deactivated member's RLS-derived authority (e.g., a lingering is_club_admin() truth value) disappears in the same statement — closes B1's own noted theoretical gap in the same fix, even though it was never independently exploitable (a deleted account can't authenticate to use it).

3.2 Ghost-member backfill (one-time, run alongside 00394)

sql
DO $$
DECLARE v_row RECORD;
BEGIN
  FOR v_row IN
    SELECT DISTINCT cm.user_id
    FROM public.club_members cm
    JOIN public.profiles p ON p.id = cm.user_id
    WHERE cm.is_active = true AND p.deleted_at IS NOT NULL
  LOOP
    PERFORM private.deactivate_departed_member_state(v_row.user_id);
  END LOOP;
END $$;

Idempotent (re-running finds nothing left to deactivate) and reuses the exact same function the trigger calls — no parallel backfill logic to drift from the live path.

3.3 M3 — Join-request review section on ClubMembersScreen

Per Wireframe A (design-specs-2026-07-ui.md:920-947): a new "가입 신청 · N건" block rendered inside GroupedFeedList's existing header prop (club-members-screen.tsx:549), between the hero card and the 파트너 추천 row — expanded by default (a pending-action section defaults open, unlike the 멤버 group's collapse-by-default). Each row: Card tone="default" size="sm" with AvatarBubble (via useClubProfiles, never a raw userId fragment — reuses the same profileMap already built at club-members-screen.tsx:318-327) + cardBody name + optional cardMeta applicant message (numberOfLines={2}) + relative applied-at + a trailing ActionButton action="danger" (거절) / action="primary" (승인) pair — the same shipped row-level-ActionButton-pair exception U3's design spec already names for PaymentRow/ApplicationRow.

Required companion changes (mechanical, not design decisions):

  1. Export useDecideJoinRequest from packages/app/src/index.ts (currently only useClubJoinRequests is exported, index.ts:579).
  2. useDecideJoinRequest's invalidateKeys needs the admin-snapshot query key added once one exists (see item 3) — today it invalidates joinRequestKeys.byClub/clubKeys.members only, confirmed at use-decide-join-request.ts, missing the AdminTab strip's source query.
  3. New finding, not in the source audits: no adminSnapshotKeys / dedicated query-key constant exists for get_club_admin_snapshot (00381) in query-keys.ts today. Add one (clubKeys.adminSnapshot(clubId) or similar) so both this decide-mutation and any future admin-snapshot mutation have something concrete to invalidate — otherwise the AdminTab's "가입 신청 N건" count only self-corrects on the query's own staleTime window after an admin approves/rejects from the members screen, a stale count on the same tab the admin is likely to glance back at.

No migration — pure client-OTA slice.

3.4 M6 — tap-to-profile + transfer-modal identity fix

  • MemberRow's Card (club-members-screen.tsx:155-163) and the ranking-tab row (ranking-tab.tsx, renderItem) both wrap in <Pressable variant="card" onPress={() => appRouter.push(routes.publicProfile(userId))}> — the kebab Button (already a sibling, not nested, per club-members-screen.tsx:166-174) needs no change; it already isn't inside the card.
  • transfer-ownership-modal.tsx:49-54's getDisplayName sources from useClubProfiles's displayName (the same hook club-members-screen.tsx already calls) instead of the caller-supplied nameMap stub, removing the userId.slice(0, 8) fallback for any member with useClubProfiles data — the modal already accepts clubId as a prop, so wiring in the hook directly (or having the settings screen pass a useClubProfiles-sourced map instead of its own nickname-only one) is a same-file/one-caller change. member.role (transfer-ownership-modal.tsx:~136, rendered raw with textTransform="capitalize") routes through getRoleLabel (club-members-screen.tsx:85-99) — hoist it to a shared location (packages/features/clubs/src/shared/) since two files now need the identical Korean role-label mapping.

3.5 M4 — capacity/status gate on decide_guest_application + auto-approve

sql
CREATE OR REPLACE FUNCTION public.decide_guest_application(
  p_application_id UUID, p_decision TEXT
) RETURNS SETOF public.guest_applications
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
DECLARE
  v_uid UUID := auth.uid();
  v_app RECORD; v_row RECORD;
  v_status public.session_status; v_max INT; v_confirmed INT;
BEGIN
  -- ... unchanged auth/decision-validation/lock/pending/role checks (00225) ...

  IF p_decision = 'approved' THEN
    SELECT status, max_players INTO v_status, v_max
    FROM public.sessions WHERE id = v_app.session_id FOR UPDATE;

    IF v_status IN ('cancelled', 'completed') THEN
      RAISE EXCEPTION 'Cannot approve into 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 = v_app.session_id AND status = 'confirmed';
    END IF;
  END IF;

  UPDATE public.guest_applications
    SET status = p_decision,
        approval_method = CASE WHEN p_decision = 'approved' THEN 'manual' ELSE approval_method END,
        approved_at = CASE WHEN p_decision = 'approved' THEN now() ELSE approved_at END,
        updated_at = now()
  WHERE id = p_application_id
  RETURNING * INTO v_row;

  IF p_decision = 'approved' THEN
    INSERT INTO public.rsvps (session_id, user_id, status, created_at, updated_at)
    VALUES (
      v_app.session_id, v_app.applicant_id,
      CASE WHEN v_max > 0 AND v_confirmed >= v_max THEN 'waitlisted' ELSE 'confirmed' END,
      now(), now()
    )
    ON CONFLICT (session_id, user_id) DO UPDATE
      SET status = EXCLUDED.status, updated_at = now();
  END IF;

  RETURN NEXT v_row;
END;
$$;

Same FOR UPDATE-on-sessions lock idiom PS3 establishes for the sibling RSVP-confirm gate — no new lock-ordering primitive introduced. private.auto_approve_guest_application_if_needed (00225) gets the identical status/capacity block inserted before its own RSVP upsert, mirroring this shape exactly (auto-approve is decision-time equivalent to a manual approve, just triggered by sessions.public_invite_approval = 'auto' instead of a host tap).

3.6 M5 — three lifecycle signals

sql
-- (a) member removed/left
CREATE OR REPLACE FUNCTION private.signal_on_club_member_removed()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM public.clubs WHERE id = OLD.club_id) THEN
    RETURN OLD;  -- cascade-skip, same idiom as 00322/00311
  END IF;
  PERFORM public.signals_emit(
    p_type := 'club_member_left', p_category := 'club'::public.signal_category,
    p_severity := 'medium'::public.signal_severity, p_recipient_user_id := OLD.user_id,
    p_dedup_key := 'member_left:' || OLD.club_id::text || ':' || OLD.user_id::text,
    p_title_key := 'signal.club.memberLeft.title', p_body_key := 'signal.club.memberLeft.body',
    p_context := jsonb_build_object('clubId', OLD.club_id),
    p_payload := jsonb_build_object('clubId', OLD.club_id),
    p_expires_at := now() + INTERVAL '7 days'
  );
  RETURN OLD;
END;
$$;
CREATE TRIGGER trg_signal_on_club_member_removed
  AFTER DELETE ON public.club_members
  FOR EACH ROW EXECUTE FUNCTION private.signal_on_club_member_removed();

-- (b) dues_amount changed — fan-out to active members, archive_club's loop shape
CREATE OR REPLACE FUNCTION private.signal_on_club_dues_amount_changed()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
DECLARE v_member RECORD;
BEGIN
  IF NEW.dues_amount IS DISTINCT FROM OLD.dues_amount THEN
    FOR v_member IN SELECT user_id FROM public.club_members WHERE club_id = NEW.id AND is_active = true LOOP
      PERFORM public.signals_emit(
        p_type := 'club_settings_updated', p_category := 'club'::public.signal_category,
        p_severity := 'medium'::public.signal_severity, p_recipient_user_id := v_member.user_id,
        p_dedup_key := 'dues_amount:' || NEW.id::text || ':' || extract(epoch from now())::text,
        p_title_key := 'signal.club.settingsUpdated.title', p_body_key := 'signal.club.settingsUpdated.body',
        p_context := jsonb_build_object('clubId', NEW.id),
        p_payload := jsonb_build_object('clubId', NEW.id, 'amount', NEW.dues_amount),
        p_expires_at := now() + INTERVAL '14 days'
      );
    END LOOP;
  END IF;
  RETURN NEW;
END;
$$;
CREATE TRIGGER trg_signal_on_club_dues_amount_changed
  AFTER UPDATE OF dues_amount ON public.clubs
  FOR EACH ROW EXECUTE FUNCTION private.signal_on_club_dues_amount_changed();

-- (c) unarchive — extend unarchive_club (00214) with archive_club's own loop
-- (inline addition to that function body; new SignalType member needed —
-- 'club_unarchived' has no existing TS placeholder, unlike (a)/(b)).

club_member_left and club_settings_updated are already-reservedSignalType union members (§2.6) — zero TS/test-file change for (a)/(b). club_unarchived needs one new union member + one exhaustive-test-array entry (signal.entity.ts, signal.entity.test.ts). All three need new signal.club.{memberLeft,settingsUpdated,unarchived}.{title,body} i18n keys in config/i18n/{ko,en}/signals.ts — no signal-routes.ts change (the generic category === 'club' fallback already covers all three, §2.6). The dedup_key for (b) intentionally includes a timestamp component (unlike every other signal in this domain, which dedupes on a stable identity) — a dues-amount change is a repeatable event, not a one-time state transition, so a stable key would silently suppress the second notification if dues change twice.

3.7 M7 — ICU localeCompare removal (mechanical, 3 files)

All 5 call sites (§2.7) replaced with the plain-compare idiom already established at venue-directory-content.tsx:116-118:

ts
a < b ? -1 : a > b ? 1 : 0;

No behavior change for the ISO-timestamp and composite-string sites (byte-order already matches chronological/alphabetical order for those shapes). use-club-attendance-summary.ts:78 trades exact Hangul collation for speed — an accepted tradeoff, same as the venue precedent.

3.8 role_configs — defer, not a slice (B1 #4)

Confirmed dormant: grep -rln role_configs packages/features → 0 write sites; every live club has role_configs = NULL, resolving to DEFAULT_ROLE_CONFIGS (club.entity.ts:54-81), whose owner/admin permission sets already match what the server hard-codes via is_club_admin. Already tracked as AGENTS.md ARCH-9, and get_club_admin_snapshot's own header comment (00381:19-32) documents the gap explicitly. Not re-planned here — the correct trigger to close it is the first custom-roles settings screen (a real feature, not a bug fix), at which point its plan must audit every hard-coded is_club_admin/session-host authority check this document's research touched (dues RLS, session_payments host/admin policies) and thread a role_configs read through each. Building that server-legible read speculatively, with no writer, would be dead code — the same anti-pattern this whole problem space exists to close elsewhere (M3's dead hooks, the invites table).


4. Slices

#Migration/scopeItemTypeBlast radiusVerificationDepends on
100394M1+M2 — deactivate_departed_member_state + profiles.deleted_at trigger + release_member_future_seats extraction + ghost backfillmigrationclub_members, rsvps, dues, attendance_records, signals, clubs.archived_at (fallback case)Local probe (BEGIN...ROLLBACK): sole-owner club + 1 admin + 1 plain member, admin has a future confirmed RSVP + unpaid dues elsewhere; call delete_account_atomic() as owner; assert admin promoted to owner, owner's club_members.is_active=false. Second probe: same setup with no admin — assert club archived_at set + all active members get club_archived signals. Third: pre-existing ghost rows (is_active=true, profiles.deleted_at IS NOT NULL) all flip to is_active=false after the DO $$ backfill block runs once, and a second run is a no-op. Add account_deletion_lifecycle to scripts/run-scenario.mjs per the wiring plan's own recommendation — this is the multi-domain blast-radius fix in this document, worth a permanent regression scenario.none (independent of PS1/PS7's profiles edits — different trigger, same table, no shared function body)
2client-OTAM3 — join-request review section, useDecideJoinRequest export, new adminSnapshotKeys constant + invalidation wire-inclientclub-members-screen.tsx, packages/app/src/index.ts, query-keys.ts, use-decide-join-request.tsManual: apply to an approval-policy club as a second account, confirm as admin — pending section shows the row, 승인/거절 both work, AdminTab count decrements without a manual pull-to-refresh. yarn check.none — server side (apply_to_club/decide_join_request) already shipped (0021300329)
3client-OTAM6 — member/ranking row Pressable wrap + transfer-modal useClubProfiles/getRoleLabel sourcing (hoist getRoleLabel)clientclub-members-screen.tsx, ranking-tab.tsx, transfer-ownership-modal.tsx, new shared role-labels.tsManual: tap a member row → lands on public profile; open transfer-ownership modal → every candidate shows a real name + Korean role label, no raw userId fragment. yarn check.none
400395M4 — decide_guest_application + auto_approve_guest_application_if_needed capacity/status gatemigrationguest_applications, rsvps, sessions (read-lock only)Local probe: approve a guest application onto a session at max_players capacity — assert the resulting RSVP lands waitlisted, not confirmed. Approve onto a cancelled/completed session — assert session_not_recruiting raised. Confirm PS1's P5 RLS-lockdown slice (unnumbered in ps1-access-control.md, sequence independently — no technical dependency, different functions/objects) still passes its own raw-PATCH-denied probe regardless of ship order.Soft: none technically, but verify against PS1's P5 slice once both are live (belt-and-suspenders — the RLS lockdown makes this the only remaining write path)
500396M5 — 3 lifecycle signal triggers + unarchive_club extension + club_unarchived union member + 3× i18n key pairsmigration + client-OTA (i18n + 1 new union entry, additive)signals growth only, club_members/clubs triggersRemove a member, change dues_amount, unarchive a previously-archived club — assert exactly one new signals row per event with correct dedup_key/recipient set; render each in the notification center and confirm real Korean copy (not a missing-key fallback).none
6client-OTAM7 — 5-site localeCompare removalclientclub-card-model.ts, club-card-summary.ts, use-club-attendance-summary.tsyarn check (existing sort-order unit tests, if any, must still pass with the plain compare); manual: discovery tab + 내 클럽 tab sort order unchanged for current club counts.none

Ordering rationale: Slice 1 (M1+M2) is the CRITICAL fix and fully independent of every other slice — ship first. Slices 2/3 both touch club-members-screen.tsx — sequence together (same file, avoid a rebase) even though they're logically independent; either order is safe. Slice 4 has no hard dependency on PS1's P5 but should be verified against it once both land, since together they close B1 finding #3 completely. Slices 5/6 are fully independent, additive-only — lowest risk, can ship in any order or batched with slice 1's migration if convenient at commit time.

Not in scope for this document: role_configs (§3.8, deferred to ARCH-9 + a future custom-roles feature); the invites dead-code table (B1 #6 — a cleanup-or-finish decision U2 already raised generically, no new design needed, just delete the dead adapter registration or leave it, owner call); the explicit-GRANT hygiene for clubs/club_members/ invites/guest_applications (B1 #7 — covered by PS1's 82-table sweep, ps1-access-control.md §3.9/P8, not re-solved here); U2 #7 (canon-doc ZONE 3 staleness — a docs-only fix, fold into the DOC phase after this implements, not a slice); U2 #9/#10 (AdminSnapshotStrip row-2 spacer, chipGroups.play dead field — cosmetic/dead-code, batch into slice 3 if convenient, not worth a dedicated slice).

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