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)
| Band | Korean | Criteria | Treatment |
|---|---|---|---|
| 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 unread | standard row, $primarySubtle unread tint (R4) |
| T3 | 이전 알림 | read | plain 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
| type | category | severity (audit) | route |
|---|---|---|---|
session_player_forfeited | session | high | session detail (i18n EXISTS already) |
club_ownership_transferred | club | high | club detail |
club_archived | club | high | club detail |
join_request_decided | club | medium | club 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)
| type | category | severity | recipient | emitter |
|---|---|---|---|---|
session_reminder_day_before | session | high | confirmed RSVPs | rewritten daily cron (direct signals; retire legacy club_alerts session path) |
session_starting_soon | session | high | confirmed RSVPs | NEW 15-min cron, [2h, 2h15m) window, dedup session_starting_soon:{sessionId}:{userId} (see correction below) |
rsvp_deadline_soon | rsvp | high | active club members with NO rsvp row | daily cron, deadline within 24h |
match_score_disputed | matchup | high | score caller + other participants | reject_call RPC |
elo_changed | rating | low | player | rating write path (flag+skip if entangled) |
tier_promoted / tier_demoted | rating | medium | player | same |
New types — add to union + i18n + emitters
| type | category | severity | recipient | emitter |
|---|---|---|---|---|
session_payment_hold_created | payment | high (expiresAt = hold deadline) | payer | session_payments INSERT (pending) trigger |
session_payment_submitted | payment | high | session host (created_by) + dues managers | submitted_at NULL→set trigger |
session_payment_confirmed | payment | medium | payer (payload flags waived) | status → paid/waived trigger |
session_payment_hold_expired | payment | high | payer | expire_unpaid_session_holds cron append |
club_join_request_received | club | high | club owner + admins | club_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)
| type | title_key / body_key | payload fields |
|---|---|---|
session_payment_hold_created | signal.payment.holdCreated.{title,body} | {amount} |
session_payment_submitted | signal.payment.submitted.{title,body} | {amount} + context.actorUserId = payer |
session_payment_confirmed | signal.payment.confirmed.{title,body} | {amount, waived} |
session_payment_hold_expired | signal.payment.holdExpired.{title,body} | — |
club_join_request_received | signal.club.joinRequestReceived.{title,body} | context.actorUserId = applicant |
join_request_decided | reuses signal.club.joinApproved/joinRejected (00328 behavior) | — |
| dead-going-live | signal.session.startingSoon, signal.session.reminderDayBefore, signal.rsvp.deadlineSoon, signal.matchup.scoreDisputed, signal.rating.eloChanged, signal.rating.tierPromoted, signal.rating.tierDemoted, signal.chat.messageReceived | rating: {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.