Skip to content

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:

SystemLives inProducesSurfaces
Home state machine (12 baseline + 3 overrides)domain/rules/home-state.* (deleted 2026-04-17)HomePairReplaced by feed cards
Belt chips (4 kinds)domain/rules/home-state.rules.ts (deleted)BeltChipReplaced by feed cards
Notification events (25 types)domain/entities/notification.entity.tsNotificationEventBell feed + push
Weather warning bannerpresentation/components/ui/weather-warning-banner.tsxAd-hocHome header
Toast containerpresentation/components/ui/toast-container.tsxAd-hocScreen overlay
Dues alert cardhome-pair-heroes.tsx DuesHero (deleted)Replaced by feed cardHome 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.

TermDurationTriggerSurfaceExample
SignalPersistent, lifecycle-managedAny system/user eventNone directly — it's the data"dues_overdue" emitted
Toast2-4 sec transientOwn user action (optimistic UI confirmation)Overlay"참가 확정됐어요"
Inline validationUntil fixedUser inputBeside field"초대코드가 올바르지 않아요"
BannerUntil resolved or dismissedSignal projectionTop of relevant screen / embedded in hero"회비 3건 미납"
BadgeUntil acknowledgedSignal projection (unread count)Icon corner, tab bar dot on bell
Alert dialogBlocking until responseCritical decisionModal"정말 삭제할까요?"
Notification entryPersistent logSignal projection (any category user opted in to)Bell feed + pushEverything that fired
Home stateSession lifecycle-derivedClassifier outputHome zonesdues_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)

ts
// 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)

sql
-- 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

sql
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:

SeverityHome bannerEmbedded heroTab bar dotBell feedPushModal
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

TypeSeverityWhen it firesResolves when
session_player_forfeitedhighDrift 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 throwingAdmin acks
session_createdlowAdmin creates session in a club user is member of24h after fires
session_updatedmediumTime/venue/capacity changed on session user is attending48h after fires
session_cancelledcriticalSession cancelled within 24h of start timeSession start time passes
session_starting_soonhighT-2h before user's sessionMatch_started or expired
session_rsvp_closinghighT-1h before RSVP deadline, user hasn't respondedUser RSVPs or deadline passes
session_waitlist_promotedhighSpot opened, user promoted from waitlistUser acknowledges
session_reminder_day_beforemedium18:00 day before user's sessionSession day arrives
session_late_cancel_alerthigh (admin)Attendee cancels < 24h beforeAdmin acks
session_underfilled_alerthigh (admin)T-24h, attendance < minAttendance threshold met or session day arrives
session_weather_warninghighTMI score ≤ 30 for session day (24h before)Weather improves or session happens

matchup

TypeSeverityWhenResolves
matchup_publishedhighAdmin publishes bracket, user is in itUser views bracket
matchup_updatedmediumUser's match slot changesUser views bracket
match_assigned_to_courtmediumDuring live session, user assigned to courtMatch completes
match_startedmediumUser's match goes in_progressMatch completes
match_completedmediumUser's match completes24h later
match_result_postedmediumScore submitted for user's matchUser acks
match_score_pending_verificationhighScore call is pending for a match participantScore call accepted/rejected/voided
match_score_disputedcriticalOpposing team disputes the scoreAdmin resolves
match_referee_transfer_proposedhighCurrent referee proposes a hand-offAccepted, declined, or expires
match_sudden_death_neededhighTime cap expires while tied and rules require sudden deathScore call opens or match exits live

rsvp

TypeSeverityWhenResolves
rsvp_deadline_soonhighT-4h before RSVP deadline, user hasn't respondedUser RSVPs or deadline passes
rsvp_pendingmedium24h after session created, user hasn't RSVPedUser RSVPs or 48h later
rsvp_confirmedlowOwn confirmation echo (optional — many apps skip)6h later
rsvp_waitlistedmediumUser added to waitlistUser removed from waitlist or promoted
rsvp_friend_joinedlowA followed user RSVPs to a session user is attending48h later

dues

TypeSeverityWhenResolves
dues_assignedmediumDues row created for user3 days later or paid
dues_due_soonhighT-3 days before due_datePaid or overdue fires
dues_overduehighDay after due_datePaid
dues_severely_overduecritical7 days after due_datePaid
dues_paidlowPayment confirmed24h later
dues_cleared_by_adminmediumAdmin manually marks paid/waivedUser acks
dues_refund_issuedmediumAdmin refunds userUser acks

club

TypeSeverityWhenResolves
club_application_receivedhigh (admin)New member applies to joinAdmin approves/rejects
club_application_approvedhighUser's application approvedUser acks
club_application_rejectedmediumUser's application rejectedUser acks
club_member_joinedlowNew member joins club (existing members)48h later
club_member_leftlow (admin)Member leaves48h later
club_role_changedhighUser promoted to admin / demotedUser acks
club_settings_updatedlowAdmin changes club settings48h later
club_announcementmediumAdmin posts announcement7 days later
club_join_request_receivedhigh (admin)INSERT on club_join_request (00213, singular table name) — owner+adminsAdmin approves/rejects
join_request_decidedmediumDrift rescue (2026-07-11, 00385) — DB already emitted this (00328 join-decision notify); reuses signal.club.joinApproved/joinRejected i18n via server-set titleKey/bodyKeyUser acks
club_ownership_transferredhighDrift rescue (2026-07-11, 00385) — DB already emitted this (00214 ownership transfer)User acks
club_archivedhighDrift rescue (2026-07-11, 00385) — DB already emitted this (00214 archive)User acks

social

TypeSeverityWhenResolves
post_newlowNew post in user's club board48h later
post_replymediumReply to user's postUser views post
post_mentionhighUser @mentioned in a postUser views post
post_reactionlowSomeone reacted to user's post24h later
friend_followedlowUser followed by another user48h later
friend_rsvped_to_my_sessionlowFollowed user RSVPs to session user is attending48h later

progression

TypeSeverityWhenResolves
streak_startedlow3rd consecutive win48h later
streak_at_riskmedium3+ streak, 7 days idleUser plays a match
streak_brokenlowLoss after streak ≥ 324h later
milestone_reachedmediumMatches 10/50/100/250/5007 days later
achievement_unlockedmediumAny achievement unlock7 days later
challenge_progresslowSignificant progress (≥50%) on monthly challenge24h later
challenge_completedmediumChallenge finished7 days later
personal_bestmediumNew PB stat (win rate, rally count, etc)7 days later

rating

TypeSeverityWhenResolves
elo_changedlowAfter each match24h later
tier_promotedmediumELO crosses tier boundary up7 days later
tier_demotedmediumELO crosses tier boundary down7 days later

trust (Phase 6)

TypeSeverityWhenResolves
trust_tier_changedmediumTrust tier recalculation crosses boundary7 days later
manner_tag_receivedlowAnother user tagged manner on user7 days later
late_cancel_penalty_appliedhighLate cancel deducts trustUser acks
no_show_reportedcriticalAdmin reports no-showUser acks

payment (Phase 7)

TypeSeverityWhenResolves
subscription_startedlowSubscription activated24h later
subscription_renewingmediumT-3 days before auto-renewRenewal happens or cancelled
subscription_payment_failedcriticalPayment attempt failsPayment retried successfully
subscription_cancelledmediumUser cancels (echo)24h later
plan_upgradedlowPlan upgraded (echo)24h later
transaction_fee_chargedlowAny transaction fee24h later

Session participation-fee reserve-then-pay lifecycle (added 2026-07-11, migration 00385 — see CLAUDE.md "Session participation fees + dual-rail payments"):

TypeSeverityWhenRecipientResolves
session_payment_hold_createdhigh (expiresAt = hold deadline)session_payments INSERT (pending) triggerpayerSubmitted, confirmed, or expired
session_payment_submittedhighsubmitted_at NULL→set trigger (송금 완료 tap)session host (created_by) + dues managersHost confirms or rejects
session_payment_confirmedmediumstatus → paid/waived triggerpayer (payload flags waived)User acks
session_payment_hold_expiredhighexpire_unpaid_session_holds cron appendpayerUser 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)

TypeSeverityWhenResolves
interclub_challenge_receivedhigh (admin)Another club challenges user's clubAdmin accepts/declines
interclub_challenge_acceptedhighUser's challenge acceptedUser acks
interclub_match_scheduledhighInterclub match date setUser acks

chat (Phase 11)

TypeSeverityWhenResolves
chat_message_receivedlow (down-regulated — chat has its own UI)New message in room user is inUser opens room
chat_mentionmediumUser @mentioned in chatUser opens room
chat_room_invitedmediumAdded to new chat roomUser opens room

system

TypeSeverityWhenResolves
app_update_availablelowNew build in storesUser updates
feature_unlockedlowUser unlocks a feature (tier/plan-based)7 days later
maintenance_scheduledmediumPlanned downtime within 48hWindow passes
data_sync_errorhighClient-side sync failure persistsSync 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_overdue signal 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:

Signaldedup_key
post_reply on postId=abcpost_reply:abc
dues_overdue on duesId=xyzdues_overdue:xyz
session_cancelled on sessionId=s1session_cancelled:s1
chat_message_received in roomId=r9chat_message_received:r9

The signals table has UNIQUE(dedup_key). On collision, the trigger upserts rather than inserting a duplicate:

sql
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-resolved

Rules:

  1. One dedup_key per logical context. post_reply:postId is one signal regardless of reply count. chat_message_received:roomId is one signal per chat room. Never per individual message.
  2. Grouping re-surfaces as unread. When a grouped signal's count bumps, acknowledged_at clears — user sees a fresh unread state with the new count. This matches KakaoTalk's "5 → 6" badge update.
  3. Payload count is authoritative. UI reads payload.count for the "+N" copy. Don't compute it client-side from multiple rows.
  4. 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.
  5. Cross-session grouping is allowed. A session_cancelled signal 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.
  6. Different contexts never group. Two post_reply signals 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.

ts
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:

  1. Pure. No I/O, no imports from outside domain/. Unit-tested in isolation with fixtures.
  2. Deterministic. Same inputs → same outputs. now is explicit, not Date.now().
  3. One banner per screen max. If two signals match the screen, pick the higher severity, break ties by createdAt DESC.
  4. Screen-context-aware. A dues_overdue signal 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.
  5. Prefs clamp surfaces, not signals. Signal still exists in the feed (unless in_app_enabled=false); only push/banner are suppressed.
  6. 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 chipSignal source
pending_score_verificationactive match_score_pending_verification signals
rsvp_deadline_soonactive rsvp_deadline_soon signal
pending_feedbackactive match_completed signals without feedback
overdue_dues_softactive 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:

  1. Phase 1 (non-breaking). Signals table added. A DB trigger mirrors every INSERT INTO signals into notifications for backwards compat. Old notification code keeps working.
  2. Phase 2. Notification screen rewritten to read from signals directly. Shows lifecycle badges (read/resolved/dismissed). Category filter chips.
  3. Phase 3. notifications table dropped, NotificationEvent entity retired.

9.4 Weather warning banner

Currently fetched and rendered ad-hoc by weather-warning-banner.tsx. After migration:

  1. Edge function fetch-weather-warnings emits a session_weather_warning signal per affected user per session on warning day.
  2. Banner component becomes a generic <SignalBanner /> that renders whatever signal the resolver returns for the current screen.
  3. 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.

  1. 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').
  2. 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).
  3. RPCs. For signals that need complex logic or multiple row touches: propose_referee_transfer emits match_referee_transfer_proposed; private.check_auto_call emits match_sudden_death_needed for 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):

Categoryin_apppushquiet_hours (KST)critical_bypass
sessiononon22-08on
matchuponon22-08on
rsvponon22-08on
duesonon22-08on
clubonon22-08on
socialonoff22-08off
progressiononoff22-08off
ratingonoff22-08off
trustonon22-08on
paymentononalways on (bypasses quiet hours entirely for critical)on
interclubonon22-08on
chatonon22-08on
systemonon22-08on

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)

  1. Writing a signal from client code. RLS blocks it, but code review should also catch attempts. Signals are server-emitted, period.
  2. Mutating an emitted signal's severity or payload. Emit a new one. Immutability is load-bearing.
  3. "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.
  4. Per-type preference toggles. One knob per category. No exceptions.
  5. 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."
  6. Skipping dedup_key. Every emission path must compute a deterministic dedup_key (e.g. dues_overdue:${duesId}). Otherwise re-running triggers floods users.
  7. 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.
  8. Letting the resolver do I/O. The resolver is pure. Fetching happens at a higher layer (a TanStack query) that passes results in.
  9. Marking resolved from the client. Resolution is system state. Acknowledgment is user state. Don't conflate.
  10. 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.

  1. Schema. Migration creating signals, signal_preferences, signal_consent_log, signals_archive tables + indexes + RLS. Two pg_cron jobs for archival (30d) and purge (365d). Zero app changes. npm run check stays green.
  2. Entity + port + adapter + realtime. Signal entity in domain, SignalPort, Supabase adapter, registry wiring. Supabase Realtime subscription on signals filtered by recipient_user_id = auth.uid() — exposed as a TanStack-query-integrated hook useSignalsStream() that keeps the cache fresh. Still unused by UI.
  3. Resolver + unit tests. Pure function in domain/rules/signal-resolver.ts with 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.
  4. 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.
  5. 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. Home dues_urgent override 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.
  6. 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.
  7. 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.
  8. 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.
  9. Cleanup. Drop notifications table. Delete NotificationEvent entity. 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 signals table, visible in the user's feed. Matches KakaoTalk's 알림 기록 window.
  • After 30 days — moved to signals_archive table 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 yearsignals_archive rows 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.ts header: "signals are delivered by FCM push (OS-managed even when backgrounded), and useNotificationHandler busts these query keys on push receive/tap; the app-foreground catch-up covers resume. There is NO persistent realtime channel." Read queries key off userId with a 60s staleTime so a push cache-bust (or foreground catch-up) refetches promptly. This matches the project-wide networking rule (no persistent Realtime channels anywhere — see docs/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 IDCategoryImportanceSoundRationale
sessionsessionHIGHdefaultSession start/end is time-sensitive
matchupmatchupHIGHdefaultMatch scheduling requires prompt attention
rsvprsvpHIGHdefaultRSVP confirmations/cancellations are time-critical
duesduesHIGHdefaultPayment deadlines are financially important
paymentpaymentHIGHdefaultTransaction confirmations always HIGH per Toss/KakaoPay norm
clubclubDEFAULTdefaultClub admin actions are important but not urgent
interclubinterclubDEFAULTdefaultInter-club challenges can wait seconds
socialsocialDEFAULTdefaultFriend/mention activity follows KakaoTalk social norms
progressionprogressionDEFAULTsilentELO/tier updates are informational, not time-sensitive
ratingratingDEFAULTsilentManner-tag counts are informational
trusttrustDEFAULTsilentTrust-tier changes are informational
chatchatHIGHdefaultReserved for future chat feature; HIGH by convention
systemsystemLOWnoneMaintenance/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 lifecyclesession_payment_hold_created / _submitted / _confirmed / _hold_expired wired into the existing reserve-then-pay trigger chain (see §6 payment table above).
  • Join-requestclub_join_request_received on club_join_request (00213) INSERT, notifying club owner + admins.
  • Score disputematch_score_disputed wired into the reject_call RPC.
  • Reminders rewrite — the session_reminder_day_before cron was rewritten to target confirmed RSVPs directly via signals (the legacy club_alerts session path is retired); two new crons added: session_starting_soon (15-min cadence, [2h, 2h15m) window, dedup key session_starting_soon:{sessionId}:{userId} — per-recipient, not per-session, because dedup_key is globally UNIQUE and a session-only key would silently deliver to only the first-matched recipient) and rsvp_deadline_soon (daily, deadline within 24h, targets active members with no RSVP row).
  • DM renamedm_message emitters migrated to the spec-canonical chat_message_received (already in the union), plus a one-time UPDATE of unexpired dm_message rows. 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 signalselo_changed / tier_promoted / tier_demoted wired directly into private.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 on private.tier_from_elo/tier_index_from_elo (00235).
  • send-push edge fn gained 13 Korean TITLE_KO entries 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:

BandKoreanCriteriaTreatment
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 unreadstandard row, $primarySubtle unread tint
T3이전 알림readplain 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.

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