Skip to content

Alert-Model Wiring Contract (2026-07-11, owner-approved)

Status: Archived

Execution contract for the alert-system investigation (report: claude.ai/code/artifact/8e40c484). Three parallel workstreams share this file as the single source of truth for type names, severities, and band semantics. When implementation must deviate, update THIS file in the same commit.

Band model (visual hierarchy ← existing severity field, NO schema change)

BandKoreanCriteriaTreatment
T1할 일active AND severity ∈pinned top band, $badgeWarningBg tint, deadline Badge when expiresAt set; leaves the band on RESOLVE (server), not on ack
T2새 소식severity ∈ {medium, low} AND unreadstandard row, $primarySubtle unread tint (R4)
T3이전 알림readplain card; low-severity social rows grouped by (clubId, day)

Admin-attention virtual rows render inside the 할 일 band (ordinary citizens, no special chrome beyond the shared warning tint).

Type ledger

Drift rescue — DB already emits, add to TS union + i18n

typecategoryseverity (audit)route
session_player_forfeitedsessionhighsession detail (i18n EXISTS already)
club_ownership_transferredclubhighclub detail
club_archivedclubhighclub detail
join_request_decidedclubmediumclub detail

DM — rename to spec-canonical, NOT a union addition

DB currently emits dm_message; migrate emitters to chat_message_received (already in the union) + one-time UPDATE of unexpired dm_message rows. Client: i18n (title "새 메시지", body generic — real preview stays edge-fn-only per dm_push_preview_enabled), route chat → DM thread via context.threadId.

New emitters — types already in the union (dead → live)

typecategoryseverityrecipientemitter
session_reminder_day_beforesessionhighconfirmed RSVPsrewritten daily cron (direct signals; retire legacy club_alerts session path)
session_starting_soonsessionhighconfirmed RSVPsNEW 15-min cron, [2h, 2h15m) window, dedup session_starting_soon:{sessionId}:{userId} (see correction below)
rsvp_deadline_soonrsvphighactive club members with NO rsvp rowdaily cron, deadline within 24h
match_score_disputedmatchuphighscore caller + other participantsreject_call RPC
elo_changedratinglowplayerrating write path (flag+skip if entangled)
tier_promoted / tier_demotedratingmediumplayersame

New types — add to union + i18n + emitters

typecategoryseverityrecipientemitter
session_payment_hold_createdpaymenthigh (expiresAt = hold deadline)payersession_payments INSERT (pending) trigger
session_payment_submittedpaymenthighsession host (created_by) + dues managerssubmitted_at NULL→set trigger
session_payment_confirmedpaymentmediumpayer (payload flags waived)status → paid/waived trigger
session_payment_hold_expiredpaymenthighpayerexpire_unpaid_session_holds cron append
club_join_request_receivedclubhighclub owner + adminsclub_join_request (singular table name, 00213) INSERT trigger

Deliberately SKIPPED (documented): session_payment_hold_expiring — the default hold window is 10 minutes and the sweep cron runs */5; a separate pre-expiry warning inside that window is noise. The hold_created signal carries the deadline via expiresAt.

Correction (server workstream, 00385): session_starting_soon's dedup key is session_starting_soon:{sessionId}:{userId}, not session_starting_soon:{sessionId} as first written above. signals.dedup_key is globally UNIQUE and signals_emit's ON CONFLICT never reassigns recipient_user_id — a session-only key would silently deliver to only the first-matched recipient and merge every other confirmed RSVP into a count-bump on that same row. The per-recipient key still satisfies "re-runs can't duplicate" (idempotent per user) while actually reaching every participant, matching every other multi-recipient emitter in the codebase (session_created, matchup_published, club_announcement, etc.).

Note (server workstream, 00385): elo_changed / tier_promoted / tier_demoted were NOT skipped. private.apply_match_ratings (00236, tiebreak-corrected in 00323) is a genuinely single, unified write path — both finalization flows (admin score-entry AND live-consensus agreement) delegate to it, which is the entire point of the 00236 migration. All three types are wired directly into it, gated on private.tier_from_elo/tier_index_from_elo (00235, the single ELO→tier band source).

Severity audit of existing emitters (align to band model)

critical: session_cancelled, dues_severely_overdue high: session_updated, session_weather_warning, match_score_pending_verification, match_sudden_death_needed, match_referee_transfer_proposed, session_waitlist_promoted, dues_overdue, dues_due_soon, club_application_received, attendance_warning_cooldown, attendance_flagged_admin, host_refund_unsettled, session_refund_notice medium: dues_assigned, dues_paid, dues_cleared_by_admin, club_role_changed, club_application_approved, club_application_rejected, club_announcement, post_reply, match_started, match_completed, matchup_published low: session_created, club_member_joined, post_new, attendance_gentle_nudge

i18n key ledger (client workstream — server emitters MUST match exactly)

typetitle_key / body_keypayload fields
session_payment_hold_createdsignal.payment.holdCreated.{title,body}{amount}
session_payment_submittedsignal.payment.submitted.{title,body}{amount} + context.actorUserId = payer
session_payment_confirmedsignal.payment.confirmed.{title,body}{amount, waived}
session_payment_hold_expiredsignal.payment.holdExpired.{title,body}
club_join_request_receivedsignal.club.joinRequestReceived.{title,body}context.actorUserId = applicant
join_request_decidedreuses signal.club.joinApproved/joinRejected (00328 behavior)
dead-going-livesignal.session.startingSoon, signal.session.reminderDayBefore, signal.rsvp.deadlineSoon, signal.matchup.scoreDisputed, signal.rating.eloChanged, signal.rating.tierPromoted, signal.rating.tierDemoted, signal.chat.messageReceivedrating: {change, tierName}

A mismatch falls back to the generic category label (safe, silently generic) — reconcile at integration if the server emitters chose different strings.

Push

Every new/rescued type gets a TITLE_KO entry in supabase/functions/send-push/index.ts (edge fn redeployed as part of the server workstream). Android channels already exist per category.

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