Signal System — Unified Alerts, Banners, Badges, Notifications
Status: Reference — Locked spec, ready to implement (all open questions resolved, see §14) Owner: Domain layer (signal entity + resolver lives in src/domain/) Scope: Replaces ad-hoc alert/banner/notification wiring across the app with a single source of truth Design driver: Korean market conventions. Where this spec makes a choice, the tiebreaker is "what would KakaoTalk / Toss / Naver do?" — users should never have to learn a new notification model. Compliance driver: PIPA (개인정보 보호법) for retention + consent on push.
1. Why this exists
TwoMore currently has several disconnected alert-shaped things:
| System | Lives in | Produces | Surfaces |
|---|---|---|---|
domain/rules/home-state.* | HomePair | Replaced by feed cards | |
domain/rules/home-state.rules.ts | BeltChip | Replaced by feed cards | |
| Notification events (25 types) | domain/entities/notification.entity.ts | NotificationEvent | Bell feed + push |
| Weather warning banner | presentation/components/ui/weather-warning-banner.tsx | Ad-hoc | Home header |
| Toast container | presentation/components/ui/toast-container.tsx | Ad-hoc | Screen overlay |
home-pair-heroes.tsx DuesHero | Replaced by feed card | Home feed |
Each of these answers "is something important happening?" with its own vocabulary, its own lifecycle, and its own presentation logic. The result:
- Adding a new alert (e.g. "RSVP closing in 1h") means patching 3-5 files across layers
- The same underlying fact (unpaid dues) produces different strings on the home card vs the notification feed vs push
- Users have no control — the system decides where each thing shows up
- Dismissal is inconsistent (read/unread vs resolved vs hidden)
- New features inevitably bypass the system "just this once"
A signal is the unified primitive. One event produces one signal. The signal is then projected onto whichever surfaces policy says are appropriate. Surfaces stop making decisions; they just render what the resolver hands them.
2. Mental model — three layers
┌─────────────────────────────────────────────────────────────────┐
│ SIGNAL what happened, immutable fact │
│ emitted by edge function / db trigger / mutation│
│ { type, category, severity, payload, lifecycle }│
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ POLICY who cares, how loud, when │
│ user prefs × role × quiet hours × severity │
│ → "show on these surfaces for this user" │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PRESENTATION where/how to show it │
│ resolver(signals, screen, prefs) │
│ → { banner, badges, toasts, pushQueue } │
└─────────────────────────────────────────────────────────────────┘Rule 1. Signals are immutable once emitted. To change state, emit a new signal or update a lifecycle field — never rewrite severity/payload.
Rule 2. Policy is pure. shouldProjectToSurface(signal, user, surface) takes the signal + user prefs + screen context and returns a boolean. No side effects.
Rule 3. Presentation is the last mile. Components receive resolved projections via props; they don't fetch or filter signals themselves.
3. Vocabulary — one word per concept
These terms have precise meanings in this system. Don't mix them.
| Term | Duration | Trigger | Surface | Example |
|---|---|---|---|---|
| Signal | Persistent, lifecycle-managed | Any system/user event | None directly — it's the data | "dues_overdue" emitted |
| Toast | 2-4 sec transient | Own user action (optimistic UI confirmation) | Overlay | "참가 확정됐어요" |
| Inline validation | Until fixed | User input | Beside field | "초대코드가 올바르지 않아요" |
| Banner | Until resolved or dismissed | Signal projection | Top of relevant screen / embedded in hero | "회비 3건 미납" |
| Badge | Until acknowledged | Signal projection (unread count) | Icon corner, tab bar dot | ● on bell |
| Alert dialog | Blocking until response | Critical decision | Modal | "정말 삭제할까요?" |
| Notification entry | Persistent log | Signal projection (any category user opted in to) | Bell feed + push | Everything that fired |
| Home state | Session lifecycle-derived | Classifier output | Home zones | dues_urgent override |
The key insight: banners/badges/toasts/notification entries are projections of the same underlying signal stream. The signal exists once; it appears on zero or more surfaces depending on policy.
What about toasts? Toasts are the one exception — they're tied to optimistic UI confirmation of the user's own action ("참가 확정됐어요" right after you tap confirm). They don't enter the signal stream. If it needs to persist past the current screen, it's a signal, not a toast.
4. Schema
4.1 TypeScript (domain)
// src/domain/entities/signal.entity.ts
export type SignalSeverity = 'critical' | 'high' | 'medium' | 'low';
export type SignalCategory =
| 'session' // session lifecycle (created, cancelled, starting soon)
| 'matchup' // bracket, courts, live match state
| 'rsvp' // own + friends' attendance signals
| 'dues' // payment events
| 'club' // membership, applications, announcements
| 'social' // posts, reactions, mentions
| 'progression' // streaks, milestones, achievements
| 'rating' // ELO / tier changes
| 'trust' // Phase 6 — manner, attendance, penalties
| 'payment' // Phase 7 — subscription, fees
| 'interclub' // Phase 11 — cross-club challenges
| 'chat' // Phase 11 — club chat messages
| 'system'; // app-level (update, maintenance, errors)
export type SignalType =
// session
| 'session_created'
| 'session_updated'
| 'session_cancelled'
| 'session_starting_soon' // T-2h, T-30m
| 'session_rsvp_closing' // T-1h before deadline
| 'session_waitlist_promoted'
| 'session_reminder_day_before'
| 'session_late_cancel_alert' // admin sees when user cancels < 24h
| 'session_underfilled_alert' // admin sees T-24h if < min attendance
| 'session_weather_warning' // forecast unplayable on session day
// matchup
| 'matchup_published'
| 'matchup_updated'
| 'match_assigned_to_court'
| 'match_started'
| 'match_completed'
| 'match_result_posted'
| 'match_score_pending_verification'
| 'match_score_disputed'
| 'match_referee_transfer_proposed'
| 'match_sudden_death_needed'
// rsvp
| 'rsvp_deadline_soon'
| 'rsvp_pending' // user hasn't responded yet
| 'rsvp_confirmed'
| 'rsvp_waitlisted'
| 'rsvp_friend_joined' // social proof
// dues
| 'dues_assigned'
| 'dues_due_soon' // T-3 days
| 'dues_overdue' // T+1 day
| 'dues_severely_overdue' // T+7 days
| 'dues_paid'
| 'dues_cleared_by_admin'
| 'dues_refund_issued'
// club
| 'club_application_received' // admin-facing
| 'club_application_approved'
| 'club_application_rejected'
| 'club_member_joined'
| 'club_member_left'
| 'club_role_changed'
| 'club_settings_updated'
| 'club_announcement'
// social
| 'post_new'
| 'post_reply'
| 'post_mention'
| 'post_reaction'
| 'friend_followed'
| 'friend_rsvped_to_my_session'
// progression
| 'streak_started'
| 'streak_at_risk' // user about to lose streak
| 'streak_broken'
| 'milestone_reached' // 10/50/100/250/500 matches
| 'achievement_unlocked'
| 'challenge_progress'
| 'challenge_completed'
| 'personal_best'
// rating
| 'elo_changed'
| 'tier_promoted'
| 'tier_demoted'
// trust (Phase 6)
| 'trust_tier_changed'
| 'manner_tag_received'
| 'late_cancel_penalty_applied'
| 'no_show_reported'
// payment (Phase 7)
| 'subscription_started'
| 'subscription_renewing'
| 'subscription_payment_failed'
| 'subscription_cancelled'
| 'plan_upgraded'
| 'transaction_fee_charged'
// interclub (Phase 11)
| 'interclub_challenge_received'
| 'interclub_challenge_accepted'
| 'interclub_match_scheduled'
// chat (Phase 11)
| 'chat_message_received'
| 'chat_mention'
| 'chat_room_invited'
// system
| 'app_update_available'
| 'feature_unlocked'
| 'maintenance_scheduled'
| 'data_sync_error';
export interface SignalContext {
clubId?: string;
sessionId?: string;
matchId?: string;
postId?: string;
duesId?: string;
actorUserId?: string; // who caused the signal (if applicable)
targetUserId?: string; // subject of the signal (if ≠ recipient)
}
export interface Signal {
id: string;
type: SignalType;
category: SignalCategory;
severity: SignalSeverity;
/** The user who should see/receive this signal. */
recipientUserId: string;
/** Structured context for deep-linking and payload resolution. */
context: SignalContext;
/** i18n key (not literal string) for the title template. */
titleKey: string;
/** Optional i18n key for the body template. */
bodyKey?: string;
/** Typed payload for template interpolation, resolved at render time. */
payload: Record<string, unknown>;
/** Emitted at. Monotonic. Used for feed sorting. */
createdAt: string;
/** Auto-expire after this timestamp. Resolver filters expired signals. */
expiresAt?: string;
// ── Lifecycle ─────────────────────────────────────────────────────
/** User has seen this (clears from unread badge counter). */
acknowledgedAt?: string;
/** Underlying condition is no longer true (dues paid, session cancelled
* alert no longer relevant). Signal is auto-hidden even if user never
* looked. Set by the system, not the user. */
resolvedAt?: string;
/** User explicitly hit "hide / not interested". Stronger than ack —
* tells the resolver "don't show me similar ones". */
dismissedAt?: string;
}
export interface SignalPreference {
userId: string;
category: SignalCategory;
inAppEnabled: boolean;
pushEnabled: boolean;
minSeverity: SignalSeverity;
/** null means "no quiet hours" (always deliver push). Payment category
* defaults to null — critical payment failures always push, Toss-style. */
quietHours: { start: string; end: string } | null;
/** When true, severity=critical bypasses quiet_hours entirely. Defaults
* true for all categories. Users can flip per category in settings. */
allowCriticalBypass: boolean;
updatedAt: string;
}4.2 SQL (Supabase)
-- Migration: 00XXX_signals_system.sql
CREATE TYPE signal_severity AS ENUM ('critical', 'high', 'medium', 'low');
CREATE TYPE signal_category AS ENUM (
'session', 'matchup', 'rsvp', 'dues', 'club', 'social',
'progression', 'rating', 'trust', 'payment', 'interclub',
'chat', 'system'
);
CREATE TABLE public.signals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type TEXT NOT NULL, -- signal_type enum (TS-side)
category signal_category NOT NULL,
severity signal_severity NOT NULL,
recipient_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
context JSONB NOT NULL DEFAULT '{}'::jsonb,
title_key TEXT NOT NULL,
body_key TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ,
acknowledged_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
dismissed_at TIMESTAMPTZ,
-- Idempotency key — prevents duplicate signals from re-running triggers.
-- e.g. "dues_overdue:duesId:123" ensures one signal per dues row.
dedup_key TEXT UNIQUE
);
-- Active signals — the 99% query
CREATE INDEX signals_active_idx ON public.signals (recipient_user_id, created_at DESC)
WHERE resolved_at IS NULL AND dismissed_at IS NULL
AND (expires_at IS NULL OR expires_at > now());
-- Unread count — fast badge query
CREATE INDEX signals_unread_idx ON public.signals (recipient_user_id)
WHERE acknowledged_at IS NULL AND resolved_at IS NULL AND dismissed_at IS NULL;
-- Category-filtered feed
CREATE INDEX signals_category_idx ON public.signals (recipient_user_id, category, created_at DESC)
WHERE resolved_at IS NULL AND dismissed_at IS NULL;
-- RLS
ALTER TABLE public.signals ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_read_own_signals" ON public.signals
FOR SELECT USING (recipient_user_id = auth.uid());
CREATE POLICY "users_update_own_lifecycle" ON public.signals
FOR UPDATE USING (recipient_user_id = auth.uid())
WITH CHECK (recipient_user_id = auth.uid());
-- Only service role can INSERT — signals come from triggers / edge functions,
-- never from client code. This is the non-bypassable enforcement.
CREATE POLICY "service_role_inserts" ON public.signals
FOR INSERT TO service_role WITH CHECK (true);
-- Archive table — signals past the 30-day user-visible window move here
-- with PII stripped. See §14.3 (PIPA-compliant retention).
CREATE TABLE public.signals_archive (
id UUID PRIMARY KEY, -- same id as original row
category signal_category NOT NULL,
severity signal_severity NOT NULL,
recipient_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
archived_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- intentionally NO payload / context / title_key / body_key columns
);
CREATE INDEX signals_archive_user_idx
ON public.signals_archive (recipient_user_id, created_at DESC);
ALTER TABLE public.signals_archive ENABLE ROW LEVEL SECURITY;
-- Users cannot read their archive directly — only service role reads
-- it for analytics rollups. No SELECT policy on purpose.4.3 Preferences schema
CREATE TABLE public.signal_preferences (
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
category signal_category NOT NULL,
in_app_enabled BOOLEAN NOT NULL DEFAULT true,
push_enabled BOOLEAN NOT NULL DEFAULT true,
/** Clamp severity upward. e.g. "for social, treat medium as low" → min_severity='low' still visible but push disabled for that category. */
min_severity signal_severity NOT NULL DEFAULT 'low',
/** ISO time window, e.g. {"start": "22:00", "end": "08:00"}. Push is suppressed during this window; in-app still renders. Korean default window matches KakaoTalk "방해금지 모드" convention: 22:00-08:00 KST. */
quiet_hours JSONB DEFAULT '{"start":"22:00","end":"08:00"}'::jsonb,
/** Critical-severity signals bypass quiet_hours when true. Korean banking
* apps (Toss, KakaoPay) bypass DND for fraud + payment failures but
* respect it for marketing. Default true to match that convention. */
allow_critical_bypass BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, category)
);
-- PIPA-mandated consent log. Every push_enabled toggle writes a row here
-- so we can prove consent timestamps during compliance audits. Required
-- by 정보통신망법 §50 (광고성 정보 수신 동의).
CREATE TABLE public.signal_consent_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
category signal_category NOT NULL,
channel TEXT NOT NULL CHECK (channel IN ('in_app', 'push')),
granted BOOLEAN NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ip_address INET,
user_agent TEXT
);
CREATE INDEX signal_consent_user_category_idx
ON public.signal_consent_log (user_id, category, granted_at DESC);
ALTER TABLE public.signal_preferences ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_manage_own_prefs" ON public.signal_preferences
FOR ALL USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid());Rule: one row per (user, category), not per (user, type). Per-type toggles produce 60+ toggles nobody uses. One clear knob per category is the UX target.
5. Severity ladder → surface projection matrix
Severity determines the default surface mix. User preferences can clamp downward but rarely invert. This is the lookup that the resolver uses:
| Severity | Home banner | Embedded hero | Tab bar dot | Bell feed | Push | Modal |
|---|---|---|---|---|---|---|
| critical | ✅ always | ✅ overrides hero | ✅ | ✅ | ✅ (bypasses quiet hours for truly urgent — session cancelled, payment failed) | ⚠️ only for blocking decisions |
| high | ✅ | ⚠️ if relevant screen | ✅ | ✅ | ✅ (respects quiet hours) | ❌ |
| medium | ❌ | ❌ | ✅ | ✅ | ⚠️ user opt-in only | ❌ |
| low | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ |
Severity assignment guide (use this for every new signal type):
- critical — user must take action or experience breaks.
session_cancelled(they were going to play!),subscription_payment_failed(access about to be cut),match_score_disputed(their elo is frozen) - high — time-bounded action required.
rsvp_deadline_soon,dues_overdue,session_starting_soon,club_application_received(admin should respond) - medium — worth knowing, no urgency.
match_completed,post_reply,friend_rsvped_to_my_session,tier_promoted - low — ambient feed fodder.
post_reaction,club_announcement,challenge_progress,streak_started
Test: if you can't decide between two severities, pick the lower one. Alert fatigue kills the whole system.
6. Category-severity matrix for initial implementation
This is the full catalog, severity-tagged, so the first implementation has no guesswork:
Click to expand full signal catalog
session
| Type | Severity | When it fires | Resolves when |
|---|---|---|---|
session_player_forfeited | high | Drift rescue (2026-07-11, migration 00385) — DB already emitted this via forfeit_session/forfeit_auto_substitute (00206/00310); added to the TS union so the mapper stops throwing | Admin acks |
session_created | low | Admin creates session in a club user is member of | 24h after fires |
session_updated | medium | Time/venue/capacity changed on session user is attending | 48h after fires |
session_cancelled | critical | Session cancelled within 24h of start time | Session start time passes |
session_starting_soon | high | T-2h before user's session | Match_started or expired |
session_rsvp_closing | high | T-1h before RSVP deadline, user hasn't responded | User RSVPs or deadline passes |
session_waitlist_promoted | high | Spot opened, user promoted from waitlist | User acknowledges |
session_reminder_day_before | medium | 18:00 day before user's session | Session day arrives |
session_late_cancel_alert | high (admin) | Attendee cancels < 24h before | Admin acks |
session_underfilled_alert | high (admin) | T-24h, attendance < min | Attendance threshold met or session day arrives |
session_weather_warning | high | TMI score ≤ 30 for session day (24h before) | Weather improves or session happens |
matchup
| Type | Severity | When | Resolves |
|---|---|---|---|
matchup_published | high | Admin publishes bracket, user is in it | User views bracket |
matchup_updated | medium | User's match slot changes | User views bracket |
match_assigned_to_court | medium | During live session, user assigned to court | Match completes |
match_started | medium | User's match goes in_progress | Match completes |
match_completed | medium | User's match completes | 24h later |
match_result_posted | medium | Score submitted for user's match | User acks |
match_score_pending_verification | high | Score call is pending for a match participant | Score call accepted/rejected/voided |
match_score_disputed | critical | Opposing team disputes the score | Admin resolves |
match_referee_transfer_proposed | high | Current referee proposes a hand-off | Accepted, declined, or expires |
match_sudden_death_needed | high | Time cap expires while tied and rules require sudden death | Score call opens or match exits live |
rsvp
| Type | Severity | When | Resolves |
|---|---|---|---|
rsvp_deadline_soon | high | T-4h before RSVP deadline, user hasn't responded | User RSVPs or deadline passes |
rsvp_pending | medium | 24h after session created, user hasn't RSVPed | User RSVPs or 48h later |
rsvp_confirmed | low | Own confirmation echo (optional — many apps skip) | 6h later |
rsvp_waitlisted | medium | User added to waitlist | User removed from waitlist or promoted |
rsvp_friend_joined | low | A followed user RSVPs to a session user is attending | 48h later |
dues
| Type | Severity | When | Resolves |
|---|---|---|---|
dues_assigned | medium | Dues row created for user | 3 days later or paid |
dues_due_soon | high | T-3 days before due_date | Paid or overdue fires |
dues_overdue | high | Day after due_date | Paid |
dues_severely_overdue | critical | 7 days after due_date | Paid |
dues_paid | low | Payment confirmed | 24h later |
dues_cleared_by_admin | medium | Admin manually marks paid/waived | User acks |
dues_refund_issued | medium | Admin refunds user | User acks |
club
| Type | Severity | When | Resolves |
|---|---|---|---|
club_application_received | high (admin) | New member applies to join | Admin approves/rejects |
club_application_approved | high | User's application approved | User acks |
club_application_rejected | medium | User's application rejected | User acks |
club_member_joined | low | New member joins club (existing members) | 48h later |
club_member_left | low (admin) | Member leaves | 48h later |
club_role_changed | high | User promoted to admin / demoted | User acks |
club_settings_updated | low | Admin changes club settings | 48h later |
club_announcement | medium | Admin posts announcement | 7 days later |
club_join_request_received | high (admin) | INSERT on club_join_request (00213, singular table name) — owner+admins | Admin approves/rejects |
join_request_decided | medium | Drift rescue (2026-07-11, 00385) — DB already emitted this (00328 join-decision notify); reuses signal.club.joinApproved/joinRejected i18n via server-set titleKey/bodyKey | User acks |
club_ownership_transferred | high | Drift rescue (2026-07-11, 00385) — DB already emitted this (00214 ownership transfer) | User acks |
club_archived | high | Drift rescue (2026-07-11, 00385) — DB already emitted this (00214 archive) | User acks |
social
| Type | Severity | When | Resolves |
|---|---|---|---|
post_new | low | New post in user's club board | 48h later |
post_reply | medium | Reply to user's post | User views post |
post_mention | high | User @mentioned in a post | User views post |
post_reaction | low | Someone reacted to user's post | 24h later |
friend_followed | low | User followed by another user | 48h later |
friend_rsvped_to_my_session | low | Followed user RSVPs to session user is attending | 48h later |
progression
| Type | Severity | When | Resolves |
|---|---|---|---|
streak_started | low | 3rd consecutive win | 48h later |
streak_at_risk | medium | 3+ streak, 7 days idle | User plays a match |
streak_broken | low | Loss after streak ≥ 3 | 24h later |
milestone_reached | medium | Matches 10/50/100/250/500 | 7 days later |
achievement_unlocked | medium | Any achievement unlock | 7 days later |
challenge_progress | low | Significant progress (≥50%) on monthly challenge | 24h later |
challenge_completed | medium | Challenge finished | 7 days later |
personal_best | medium | New PB stat (win rate, rally count, etc) | 7 days later |
rating
| Type | Severity | When | Resolves |
|---|---|---|---|
elo_changed | low | After each match | 24h later |
tier_promoted | medium | ELO crosses tier boundary up | 7 days later |
tier_demoted | medium | ELO crosses tier boundary down | 7 days later |
trust (Phase 6)
| Type | Severity | When | Resolves |
|---|---|---|---|
trust_tier_changed | medium | Trust tier recalculation crosses boundary | 7 days later |
manner_tag_received | low | Another user tagged manner on user | 7 days later |
late_cancel_penalty_applied | high | Late cancel deducts trust | User acks |
no_show_reported | critical | Admin reports no-show | User acks |
payment (Phase 7)
| Type | Severity | When | Resolves |
|---|---|---|---|
subscription_started | low | Subscription activated | 24h later |
subscription_renewing | medium | T-3 days before auto-renew | Renewal happens or cancelled |
subscription_payment_failed | critical | Payment attempt fails | Payment retried successfully |
subscription_cancelled | medium | User cancels (echo) | 24h later |
plan_upgraded | low | Plan upgraded (echo) | 24h later |
transaction_fee_charged | low | Any transaction fee | 24h later |
Session participation-fee reserve-then-pay lifecycle (added 2026-07-11, migration 00385 — see CLAUDE.md "Session participation fees + dual-rail payments"):
| Type | Severity | When | Recipient | Resolves |
|---|---|---|---|---|
session_payment_hold_created | high (expiresAt = hold deadline) | session_payments INSERT (pending) trigger | payer | Submitted, confirmed, or expired |
session_payment_submitted | high | submitted_at NULL→set trigger (송금 완료 tap) | session host (created_by) + dues managers | Host confirms or rejects |
session_payment_confirmed | medium | status → paid/waived trigger | payer (payload flags waived) | User acks |
session_payment_hold_expired | high | expire_unpaid_session_holds cron append | payer | User acks |
Deliberately SKIPPED (documented in alert-model-contract.md): session_payment_hold_expiring — the default hold window is 10 minutes and the sweep cron runs */5; a pre-expiry warning inside that window is noise. hold_created already carries the deadline via expiresAt.
interclub (Phase 11)
| Type | Severity | When | Resolves |
|---|---|---|---|
interclub_challenge_received | high (admin) | Another club challenges user's club | Admin accepts/declines |
interclub_challenge_accepted | high | User's challenge accepted | User acks |
interclub_match_scheduled | high | Interclub match date set | User acks |
chat (Phase 11)
| Type | Severity | When | Resolves |
|---|---|---|---|
chat_message_received | low (down-regulated — chat has its own UI) | New message in room user is in | User opens room |
chat_mention | medium | User @mentioned in chat | User opens room |
chat_room_invited | medium | Added to new chat room | User opens room |
system
| Type | Severity | When | Resolves |
|---|---|---|---|
app_update_available | low | New build in stores | User updates |
feature_unlocked | low | User unlocks a feature (tier/plan-based) | 7 days later |
maintenance_scheduled | medium | Planned downtime within 48h | Window passes |
data_sync_error | high | Client-side sync failure persists | Sync succeeds |
Total: 92 signal types across 13 categories (live count of the SignalType union in packages/app/src/domain/entities/signal.entity.ts, 2026-07-11 — the TS file is the source of truth; this table + the illustrative §4.1 block lag it and are updated surgically, not regenerated). Covers current scope (Phases 1-5 complete, Phase 6 trust partially wired) + roadmap (Phase 7 session-fee slice live per 00298-00303/00385, Phase 11 scaffolded).
7. Lifecycle rules
A signal progresses through up to 4 states. The order is not strict — resolved and dismissed can fire without acknowledged first.
┌─────────┐
│ ACTIVE │ created, visible, counts toward unread
└────┬────┘
│
┌───────┼───────┐
▼ ▼ ▼
acknow- resolved dismissed
ledged (auto) (user)
│ │ │
└────────┴─────────┘
│
▼
┌─────────┐
│ ARCHIVED│ no longer projected; kept 90 days then deleted
└─────────┘- acknowledgedAt — user opened the notification / saw the banner. Clears the unread badge, but signal may still render in the feed. "I saw it."
- resolvedAt — underlying condition is gone. Set by the system, not the user. Dues got paid →
dues_overduesignal resolves automatically. This is the critical field that prevents stale banners. - dismissedAt — user hit "hide / not interested". Stronger than ack — tells the resolver "don't show me similar ones for a while." Use sparingly; most signals shouldn't need this (they should auto-resolve).
Retention: PIPA-aligned. Active + archived signals visible in the feed for 30 days (matches KakaoTalk notification history), then moved to a signals_archive table where PII (payload contents, context IDs) is stripped but severity/category/timestamps remain for analytics for 1 year, then hard-deleted. Two pg_cron jobs: signals_archive_30d (daily) and signals_purge_365d (daily). Compliant with PIPA §21 (retention-purpose limitation) and §50 (처리 목적 달성 시 파기).
7.5 Grouping
Grouping is mandatory, not optional. Three KakaoTalk replies to the same post should appear as one entry ("3 new replies"), never three. Ungrouped feeds are the #1 reason users disable notifications.
Mechanism: every signal emission computes a deterministic dedup_key of the form {type}:{primaryContextId}. Examples:
| Signal | dedup_key |
|---|---|
post_reply on postId=abc | post_reply:abc |
dues_overdue on duesId=xyz | dues_overdue:xyz |
session_cancelled on sessionId=s1 | session_cancelled:s1 |
chat_message_received in roomId=r9 | chat_message_received:r9 |
The signals table has UNIQUE(dedup_key). On collision, the trigger upserts rather than inserting a duplicate:
INSERT INTO public.signals (...)
VALUES (...)
ON CONFLICT (dedup_key) DO UPDATE SET
payload = jsonb_set(
public.signals.payload,
'{count}',
to_jsonb(COALESCE((public.signals.payload->>'count')::int, 1) + 1)
),
created_at = EXCLUDED.created_at, -- bump to latest occurrence
acknowledged_at = NULL, -- re-surface as unread
resolved_at = NULL; -- re-open if had been auto-resolvedRules:
- One dedup_key per logical context.
post_reply:postIdis one signal regardless of reply count.chat_message_received:roomIdis one signal per chat room. Never per individual message. - Grouping re-surfaces as unread. When a grouped signal's count bumps,
acknowledged_atclears — user sees a fresh unread state with the new count. This matches KakaoTalk's "5 → 6" badge update. - Payload count is authoritative. UI reads
payload.countfor the "+N" copy. Don't compute it client-side from multiple rows. - Grouping is emission-time, not render-time. The resolver does zero grouping work; it just reads pre-grouped rows. Keeps the resolver pure and fast.
- Cross-session grouping is allowed. A
session_cancelledsignal that fired 3 days ago and gets another cancellation today (same session, theoretically impossible but defensive) — the old row bumps rather than spawning a second. - Different contexts never group. Two
post_replysignals on two different posts are two separate rows. Context is part of the key.
Edge case — ungroupable types. Some signals are one-shot and should never collide: tier_promoted, milestone_reached (specific milestone). For these the dedup_key includes a temporal discriminator: tier_promoted:userId:tierTo, milestone_reached:userId:milestoneValue. Retrying the same trigger emits the same key, so re-runs are safely idempotent, but legitimate new events (next tier, next milestone) generate new rows.
8. Resolver contract
The resolver is a pure function in src/domain/rules/signal-resolver.ts. It's the single choke point between signal data and presentation.
export interface ResolverInput {
activeSignals: Signal[];
userPrefs: Map<SignalCategory, SignalPreference>;
currentScreen: ScreenContext; // tab + screen id + context IDs
now: Date;
}
export interface ResolvedProjections {
/** Banner to render at top of current screen. Only one — the highest-severity
* active signal whose context matches the current screen. */
screenBanner: Signal | null;
/** Embedded hero alert (used inside home state overrides today, extends to
* session detail + club home). At most one. */
embeddedHero: Signal | null;
/** Tab bar dot state — true if any active signal of that category. */
tabBadges: Record<TabKey, boolean>;
/** Unread notification feed count. */
unreadCount: number;
/** Feed items, sorted by (severity DESC, createdAt DESC). */
feedItems: Signal[];
/** Signals that should trigger a push via Expo. Edge-function-only path —
* client never calls this. */
pushQueue: Signal[];
/** Toasts are NOT here — toasts are optimistic-UI and not signal-backed. */
}
export function resolveSignals(input: ResolverInput): ResolvedProjections;Rules:
- Pure. No I/O, no imports from outside
domain/. Unit-tested in isolation with fixtures. - Deterministic. Same inputs → same outputs.
nowis explicit, notDate.now(). - One banner per screen max. If two signals match the screen, pick the higher severity, break ties by
createdAtDESC. - Screen-context-aware. A
dues_overduesignal projects to the home banner when on home, but to an embedded hero when on the dues screen, and to the feed only when on an unrelated screen. - Prefs clamp surfaces, not signals. Signal still exists in the feed (unless
in_app_enabled=false); only push/banner are suppressed. - Resolver never writes. Acknowledgment/resolution happen through separate mutations (
signals.acknowledge(id),signals.markResolved(dedupKey)).
9. Integration with existing systems
This is where the spec meets the codebase. Don't delete the existing systems — evolve them into projections of the signal stream.
9.1 Home feed integration
Note (2026-04-17): The home state machine (
home-state.*,HomePair,computeUserState) was deleted when the home tab was rebuilt as a Toss-style feed. The integration described below applies to feed cards instead.
Each feed card reads signals independently. Priority/visibility stays card-local — no central classifier. Signal-driven overrides (session_cancelled, dues_urgent, score_verification) map to the card that owns that surface returning non-null content. Feed cards live in src/presentation/components/home/feed/cards/.
Migration: classifyHomeState already takes a ClassifierInput. Add activeSignals: Signal[] to that input. Rewrite the override branches to check signals first, fall back to raw facts second (temporary during migration).
9.2 Belt chips
The 4 belt chips (pending_score_verification, rsvp_deadline_soon, pending_feedback, overdue_dues_soft) are currently derived from facts. Each maps 1:1 to a signal category:
| Belt chip | Signal source |
|---|---|
pending_score_verification | active match_score_pending_verification signals |
rsvp_deadline_soon | active rsvp_deadline_soon signal |
pending_feedback | active match_completed signals without feedback |
overdue_dues_soft | active dues_overdue signal (severity=high, not critical) |
Migration: classifyBeltChip continues to exist but reads from signals rather than raw facts. Same output type, different source.
9.3 NotificationEvent → Signal
NotificationEvent becomes the feed projection of Signal. The mapping is 1:1 for most types, but the new model has a richer lifecycle. Strategy:
- Phase 1 (non-breaking). Signals table added. A DB trigger mirrors every
INSERT INTO signalsintonotificationsfor backwards compat. Old notification code keeps working. - Phase 2. Notification screen rewritten to read from signals directly. Shows lifecycle badges (read/resolved/dismissed). Category filter chips.
- Phase 3.
notificationstable dropped,NotificationEvententity retired.
9.4 Weather warning banner
Currently fetched and rendered ad-hoc by weather-warning-banner.tsx. After migration:
- Edge function
fetch-weather-warningsemits asession_weather_warningsignal per affected user per session on warning day. - Banner component becomes a generic
<SignalBanner />that renders whatever signal the resolver returns for the current screen. - Weather warning becomes one of many things the banner can show, not a special case.
9.5 Toast container
Unchanged. Toasts stay tied to optimistic UI confirmations. They are not signal projections. This boundary is deliberate — don't try to unify them.
10. Signal emission paths
Signals enter the system through exactly three channels. No other path is allowed.
- DB triggers. For state changes that are already db-enforced:
AFTER INSERT ON dues → signal('dues_assigned'),AFTER UPDATE ON sessions WHEN NEW.status='cancelled' → signal('session_cancelled' for every attendee),AFTER INSERT ON match_score_calls → signal('match_score_pending_verification'). - Edge functions. For time-based or cross-system emissions:
session_starting_soon(pg_cron → edge function),session_weather_warning(weather fetcher),streak_at_risk(scheduled check). - RPCs. For signals that need complex logic or multiple row touches:
propose_referee_transferemitsmatch_referee_transfer_proposed;private.check_auto_callemitsmatch_sudden_death_neededfor tied time-cap matches.
Never from client code. RLS blocks it. The signals table has INSERT restricted to service_role. This is the non-bypassable enforcement.
11. User preference UX
The settings screen should expose one row per category (13 total), not per type. Korean-market shape — matches the Toss / Kakao / Naver settings pattern (single toggle + disclosure for detail):
┌────────────────────────────────────────────┐
│ 💰 회비 알림 [토글 ●] │
│ 미납, 납부 알림, 영수증 │
│ │
│ ├ 앱 내 표시 [토글 ●] │
│ ├ 푸시 알림 [토글 ●] │
│ ├ 알림 강도 [낮음 / 보통 / 높음] │
│ └ 조용 시간 중에도 중요 알림 받기 [토글 ●]│
└────────────────────────────────────────────┘
전체 설정
┌────────────────────────────────────────────┐
│ 방해금지 모드 22:00 – 08:00 [●] │
│ 모든 알림 일시 정지 1시간 / 계속 │
└────────────────────────────────────────────┘Structure:
- One primary toggle per category — turning it off cascades in_app + push to off. Matches KakaoTalk "특정 친구 알림 끄기" ergonomics.
- Expanded detail (on tap) exposes the 3 sub-toggles: in-app / push / critical-bypass. Most users will never open this.
- Global DND at the bottom sets default quiet_hours for all categories. Can be overridden per category but rarely is.
- Instant pause — "1시간 / 3시간 / 내일 아침까지 / 계속" — matches Toss "알림 잠시 끄기" pattern.
Korean defaults on first run (matches what users already expect from Kakao):
| Category | in_app | push | quiet_hours (KST) | critical_bypass |
|---|---|---|---|---|
| session | on | on | 22-08 | on |
| matchup | on | on | 22-08 | on |
| rsvp | on | on | 22-08 | on |
| dues | on | on | 22-08 | on |
| club | on | on | 22-08 | on |
| social | on | off | 22-08 | off |
| progression | on | off | 22-08 | off |
| rating | on | off | 22-08 | off |
| trust | on | on | 22-08 | on |
| payment | on | on | always on (bypasses quiet hours entirely for critical) | on |
| interclub | on | on | 22-08 | on |
| chat | on | on | 22-08 | on |
| system | on | on | 22-08 | on |
Social/progression/rating default to push-off because they're "nice to know" — users who want celebration pushes can opt in, but the default respects attention.
Consent logging. Every toggle flip writes a row to signal_consent_log with timestamp, IP, user agent. This is a PIPA §50 / 정보통신망법 §50 requirement — consent for "광고성 정보 수신" has to be auditable. The 13 categories include some that are transactional (not marketing) and some that could be marketing-adjacent (social, system/app-update) — log all of them to be safe.
First-run consent screen. Onboarding shows a dedicated "알림 동의" screen with:
- Transactional categories pre-checked, disabled (경기, 참가, 회비, 결제, 안전 — you can't turn these off, they're service-essential)
- Marketing-adjacent pre-checked but user can uncheck (소셜, 성취, 랭킹, 클럽 공지)
- "모두 동의" button (Korean users expect this)
Matches the Kakao/Naver onboarding flow. Users who hit "모두 동의" → full opt-in. Users who uncheck → granular state. Either way the consent log has timestamped evidence.
No per-type toggles. The moment you add "회비 due_soon vs overdue vs severely_overdue" toggles, you've lost. Users want one knob: "bug me about dues or not."
12. Anti-patterns (don't do these)
- Writing a signal from client code. RLS blocks it, but code review should also catch attempts. Signals are server-emitted, period.
- Mutating an emitted signal's severity or payload. Emit a new one. Immutability is load-bearing.
- "Temporary" scattered banner code. If a new alert doesn't fit an existing signal type, add a new type. Don't hard-code a banner into a screen.
- Per-type preference toggles. One knob per category. No exceptions.
- Using toasts for persistent state. If it needs to survive screen navigation, it's a signal. Toasts are for "you just tapped confirm — here's your receipt."
- Skipping
dedup_key. Every emission path must compute a deterministic dedup_key (e.g.dues_overdue:${duesId}). Otherwise re-running triggers floods users. - Color-only severity. WCAG 1.4.1 — every rendered signal needs icon + label + (optional) color. Already enforced in the current Badge component via variant prop.
- Letting the resolver do I/O. The resolver is pure. Fetching happens at a higher layer (a TanStack query) that passes results in.
- Marking resolved from the client. Resolution is system state. Acknowledgment is user state. Don't conflate.
- Forgetting
expiresAt. A signal without a resolve path AND no expiry becomes permanent clutter. Every type needs either auto-resolve logic or an expiry.
13. Migration plan
Nine ordered steps. Each step is a shippable PR. Scope is explicitly full implementation, not MVP — we're not shipping a feature-gated subset.
- Schema. Migration creating
signals,signal_preferences,signal_consent_log,signals_archivetables + indexes + RLS. Two pg_cron jobs for archival (30d) and purge (365d). Zero app changes.npm run checkstays green. - Entity + port + adapter + realtime.
Signalentity in domain,SignalPort, Supabase adapter, registry wiring. Supabase Realtime subscription onsignalsfiltered byrecipient_user_id = auth.uid()— exposed as a TanStack-query-integrated hookuseSignalsStream()that keeps the cache fresh. Still unused by UI. - Resolver + unit tests. Pure function in
domain/rules/signal-resolver.tswith comprehensive fixture tests. Covers banner picking, feed ordering, screen-context filtering, preference clamping, quiet-hours + critical-bypass logic, grouping passthrough. This PR lands ~60 unit tests. - Consent + preferences UX. Settings screen with 13 category rows. First-run consent screen in onboarding (transactional pre-checked + locked, marketing-adjacent pre-checked + editable, "모두 동의" button). Consent log writes on every toggle. Ready before any signals actually fire so we have consent evidence from day one.
- First category end-to-end: dues. DB trigger emits
dues_assigned/dues_due_soon/dues_overdue/dues_severely_overdue/dues_paid/dues_cleared_by_admin/dues_refund_issued. Homedues_urgentoverride reads from signals. Existing dues banner code deleted. Notification feed shows dues category via signals; other categories still use old notifications. Ships one category end-to-end to prove the pipeline and tune the UX. - Notification screen rewrite. Feed reads from signals with category filter chips, lifecycle badges, dismiss action, grouping-aware "+N" display, realtime updates via the stream hook. Old notifications table kept (mirrored by trigger) for rollback safety.
- Remaining current-scope categories. Batched PRs: session → matchup → rsvp → club → social → progression → rating → system. Each PR: trigger/edge function + remove old code path + tests. After this step, everything currently shipping is signal-backed.
- Future-phase categories scaffolded. Trust / payment / interclub / chat category enums + preference rows exist but no emitters yet. When Phases 6/7/11 ship, they just add triggers — no framework changes. This avoids a "we'll bolt it on later" regret.
- Cleanup. Drop
notificationstable. DeleteNotificationEvententity. Remove the feature flag. Close out the migration.
Feature flag. The resolver switch lives behind signals.enabled per user so we can canary-test on internal accounts (ivorybridge-2025 and a few testers) before general rollout. Flag is removed in Step 9.
Rollback contract. Steps 5-7 keep the old notifications path mirrored via trigger. If a migrated category misbehaves we flip that category's flag off and it falls back to the old path within seconds. Step 9 is the point of no return.
14. Decisions (locked)
All previously open questions are resolved. Tiebreaker: Korean-market convention (KakaoTalk / Toss / Naver) + PIPA compliance. These are committed decisions — future PRs that want to deviate need a separate RFC.
14.1 Grouping — GROUPED BY DEFAULT via dedup_key UPSERT
Every signal emission computes {type}:{primaryContextId} as the dedup key; collisions upsert the existing row, increment payload.count, bump created_at, and clear acknowledged_at so it re-surfaces. Fully specified in §7.5.
Why: Ungrouped feeds are the #1 reason Korean users disable notifications entirely. No serious Korean app emits one notification per underlying event — they all group by conversation / post / invoice. We match.
14.2 Critical-over-quiet-hours — BYPASS BY DEFAULT, USER CAN REVOKE PER CATEGORY
critical severity bypasses quiet_hours when allow_critical_bypass=true on the category preference row. Default is true for every category. Payment category has quiet_hours=null in the seed defaults — critical payment failures always push, 24/7, because Toss/KakaoPay behave that way for fraud and users expect it.
Why: Korean banking apps (Toss, KakaoPay) and delivery apps (쿠팡이츠, 배달의민족) all bypass 방해금지 모드 for critical transactional events. Users trust that "if it went off at 3am, it's real." Don't break that contract.
OS-level DND. We also respect the device's OS-level 방해금지 모드 (iOS Focus, Android DND). If the user has OS DND on, we still deliver the push to the notification center, but the device itself suppresses the sound/vibration — our responsibility ends at the push payload.
14.3 Retention — 30 DAYS FEED VISIBLE + 1 YEAR ANONYMIZED ANALYTICS
Two-tier retention driven by PIPA §21 (retention-purpose limitation):
- 30 days — active + archived signals remain in
signalstable, visible in the user's feed. Matches KakaoTalk's 알림 기록 window. - After 30 days — moved to
signals_archivetable by pg_cron. Payload contents, context IDs, and title/body keys are stripped. Only{category, severity, created_at, recipient_user_id}remain. User can no longer see these. - After 1 year —
signals_archiverows hard-deleted by pg_cron. Aggregate analytics rolled up into a separate table before deletion if needed.
Why: PIPA requires personal data to be retained only as long as necessary. 30 days is the longest defensible window for "user visibility" (nobody scrolls older than that). The 1-year anonymized retention is for preference tuning + severity calibration — stripped of PII so it falls under PIPA's "pseudonymized data" clause.
Immediate deletion on account deletion. When a user deletes their account, BOTH tables are purged for that user within 5 days — PIPA §21③ mandates this.
14.4 Realtime subscription — RETIRED (2026-05-22)
Superseded. The persistent Supabase Realtime channel this section originally mandated was retired 2026-05-22. Signals are delivered by FCM push instead — see
packages/app/src/presentation/hooks/queries/use-signals.tsheader: "signals are delivered by FCM push (OS-managed even when backgrounded), anduseNotificationHandlerbusts these query keys on push receive/tap; the app-foreground catch-up covers resume. There is NO persistent realtime channel." Read queries key offuserIdwith a 60sstaleTimeso a push cache-bust (or foreground catch-up) refetches promptly. This matches the project-wide networking rule (no persistent Realtime channels anywhere — seedocs/canon/networking.md): push + cache-bust for background delivery, focus-gated polling / refetch for foreground freshness.The subsection below is kept for historical context only;
useSignalsStream()was never shipped and should not be reintroduced.
Supabase Realtime channel on signals filtered by recipient_user_id = auth.uid() is wired up in Step 2 of the migration plan. Exposed to UI as useSignalsStream() which invalidates TanStack caches on insert/update.
Why (original rationale, superseded): Every Korean app with a notification bell updates in realtime. Tapping a notification in one place clears the badge everywhere within a second. Polling is immediately obvious ("왜 빨간 점이 안 사라지지"). Non-negotiable.
Fallback. If Realtime disconnects (network blip, backgrounded app), the client falls back to TanStack's refetchOnWindowFocus behavior. On resume, Realtime reconnects and re-syncs.
14.5 Platform scope — REACT NATIVE ONLY, FUTURE-WEB-SAFE BY CONSTRUCTION
The resolver is pure (domain/rules/signal-resolver.ts, zero React/RN imports), so it trivially runs on web if we ever ship it. No extra work. The only RN-specific pieces are the Realtime hook and the push registration (Expo-specific), which would be swapped for web equivalents when/if that phase arrives. No Phase 1 web scope.
15. Per-category Android notification channels
Added 2026-05-18 (commit 25e2f10, migration part of the notification hardening sweep). Android requires each notification to target a named channel; prior to this, send-push was setting 13 different channelId values but only the 'default' channel existed in the manifest — pushes were silently discarded.
The supabase adapter in packages/app/src/adapters/supabase/notification.supabase.ts now creates all 13 channels on boot via Notifications.setNotificationChannelAsync. Channels map 1:1 to signal_category enum values:
| Channel ID | Category | Importance | Sound | Rationale |
|---|---|---|---|---|
session | session | HIGH | default | Session start/end is time-sensitive |
matchup | matchup | HIGH | default | Match scheduling requires prompt attention |
rsvp | rsvp | HIGH | default | RSVP confirmations/cancellations are time-critical |
dues | dues | HIGH | default | Payment deadlines are financially important |
payment | payment | HIGH | default | Transaction confirmations always HIGH per Toss/KakaoPay norm |
club | club | DEFAULT | default | Club admin actions are important but not urgent |
interclub | interclub | DEFAULT | default | Inter-club challenges can wait seconds |
social | social | DEFAULT | default | Friend/mention activity follows KakaoTalk social norms |
progression | progression | DEFAULT | silent | ELO/tier updates are informational, not time-sensitive |
rating | rating | DEFAULT | silent | Manner-tag counts are informational |
trust | trust | DEFAULT | silent | Trust-tier changes are informational |
chat | chat | HIGH | default | Reserved for future chat feature; HIGH by convention |
system | system | LOW | none | Maintenance/admin signals should not interrupt |
Importance tiers follow Android's NotificationImportance scale: HIGH = makes sound + appears as heads-up notification; DEFAULT = makes sound; LOW = no sound; NONE = silent + no status bar icon.
Note: Channel creation is idempotent — setNotificationChannelAsync updates an existing channel if the ID already exists. Channel importance can only be raised by the user after creation (Android OS restriction). The app.json plugin entry (expo-notifications) is required for the channels to appear in the system notification settings for the app — this requires an EAS build (not OTA-able).
15.5 Alert-model wiring (2026-07-11, migration 00385)
Execution pass against docs/architecture/alert-model-contract.md (single source of truth for the type ledger, severities, and band semantics — this subsection is a pointer + summary, not a duplicate).
Emitters (migration 00385, server workstream):
- Payment lifecycle —
session_payment_hold_created/_submitted/_confirmed/_hold_expiredwired into the existing reserve-then-pay trigger chain (see §6 payment table above). - Join-request —
club_join_request_receivedonclub_join_request(00213) INSERT, notifying club owner + admins. - Score dispute —
match_score_disputedwired into thereject_callRPC. - Reminders rewrite — the
session_reminder_day_beforecron was rewritten to target confirmed RSVPs directly via signals (the legacyclub_alertssession path is retired); two new crons added:session_starting_soon(15-min cadence, [2h, 2h15m) window, dedup keysession_starting_soon:{sessionId}:{userId}— per-recipient, not per-session, becausededup_keyis globally UNIQUE and a session-only key would silently deliver to only the first-matched recipient) andrsvp_deadline_soon(daily, deadline within 24h, targets active members with no RSVP row). - DM rename —
dm_messageemitters migrated to the spec-canonicalchat_message_received(already in the union), plus a one-time UPDATE of unexpireddm_messagerows. Not a union addition — a rename. - Severity audit — 7 existing emitters re-aligned to the band model's severity ladder (see the audit list in
alert-model-contract.md), with an unread-row backfill so already-emitted rows don't sit at a stale severity. - Rating signals —
elo_changed/tier_promoted/tier_demotedwired directly intoprivate.apply_match_ratings(00236, tiebreak- corrected in 00323) — the single unified write path both finalization flows (admin score-entry and live-consensus agreement) delegate to, gated onprivate.tier_from_elo/tier_index_from_elo(00235). send-pushedge fn gained 13 KoreanTITLE_KOentries for the new/rescued types.
UI — 3-band model. The notification center (§9.3 above still describes the feed-projection migration path; this is the concrete band shape it landed in) renders three bands driven purely by the existing severity field — schema-free, see alert-model-contract.md §Band model:
| Band | Korean | Criteria | Treatment |
|---|---|---|---|
| T1 | 할 일 | active AND severity ∈ | pinned top band, $badgeWarningBg tint, deadline Badge when expiresAt set; leaves the band on server RESOLVE, not on ack |
| T2 | 새 소식 | severity ∈ {medium, low} AND unread | standard row, $primarySubtle unread tint |
| T3 | 이전 알림 | read | plain card; low-severity social rows grouped by (clubId, day) |
Admin-attention virtual rows (§9 integration point, non-persisted) render inside the 할 일 band as ordinary citizens — no special chrome beyond the shared warning tint. Full row-level detail (deadline Badge placement, T3 GroupRow grouping mechanics) lives in docs/architecture/screen-blueprint.md → Notifications section.
16. Related docs
- Home State Model — archived spec; home tab now uses a Toss-style feed (deleted 2026-04-17)
- Network Protocol — edge function + RPC patterns used for emission paths
- Roadmap — phase sequencing for signals tied to future features
- Attendance & Reviews — Phase 6 trust system, source of trust signals
- Revenue Model — Phase 7 payments, source of payment signals
- Alert-Model Wiring Contract — the type ledger, severity audit, and band semantics behind the 2026-07-11 wiring (§15.5 above)
- Screen Blueprint — Notifications — row-level UI implementation of the 3-band model