Skip to content

Notification Conventions

Status: Active Last reviewed: 2026-07-31

How TwoMore classifies, delivers, and gates push notifications. Covers the 정보성 vs 광고성 split (정통망법 §50), Android channel setup, the iOS soft-prompt timing rule, and server-enforced frequency caps. Rationale and history live in CLAUDE.md; the machine-enforced subset is in AGENTS.md.

Alert-state model (class / lifecycle / urgency)

  • Formal model → packages/app/src/domain/rules/alert-state.ts (2026-07-31 formalization of logic that already existed in signalBand() — zero behavior change, see the table-driven regression test alert-state.test.ts). Three axes, table-driven (same idiom as STRIP_CHIP_CLASS in domain/session-strip.ts):
    • class (AlertClass: obligation | event) — obligation = urgent severity (critical|high) AND currently active (isSignalActive — not resolved/dismissed/expired); everything else is event. classifySignal(signal, now) derives this from existing Signal fields only, no schema change.
    • lifecycle (AlertLifecycle: open | read | resolved | dismissed) — priority resolvedAt > dismissedAt > acknowledgedAt > untouched. Obligations are special: acknowledging one does NOT advance it past open — only a server-side RESOLVE does (the pre-existing "stays todo even when acknowledged" rule).
    • urgency (AlertUrgency) — reuses SignalSeverity directly, no parallel scale.
    • Admin-attention rows (the virtual, non-persisted club counter rollup — presentation/utils/club-admin-attention.ts) are obligations BY CONSTRUCTION: ADMIN_ATTENTION_ALERT_STATE / classifyAdminAttention(total) — always open while the total is positive, null (self-hides) at 0.
    • signalBand() (domain/rules/signal-resolver.ts) now PROJECTS from classifySignal for its todo determination instead of re-deriving urgency+activity inline; the notification center's three bands (할 일/새 소식/이전 알림 — docs/history/alert-model-contract.md) are unchanged.
    • deriveBellState({ unreadEventCount, openObligationCount }){ tone: 'attention' | 'unread' | 'idle', count }. attention (an open obligation exists) wins precedence over unread; count is always the combined total. This is the two-state bell projection — see below.

Two-state bell + notification-center settings entry

  • NotificationBell (packages/features/home/src/cards/notification-bell.tsx) — the home tab's bell, projected via deriveBellState. openObligationCount is sourced from admin-attention only today (sumAdminAttentionTotal); todo-band SIGNAL obligations still fold into unreadEventCount until a per-class server-side count RPC exists. Tone → badge: attention = $badgeWarningBg/$badgeWarningText (amber, matches AdminAttentionRow/할 일 band); unread = $badgeErrorBg/$badgeErrorText (red, pre-existing); idle = no badge. accessibilityLabel is dynamic via notifications.bellA11yLabel(n) / notifications.bellA11yLabelWithObligation(n, m).
  • Notification-center headerRight → 모두 읽음 (ActionButton) + a settings gear (HeaderIconButton + Settings icon → routes.signalPreferences), side by side in an XStack (the canonical multi-icon AppHeader pattern — see session-detail-screen.tsx). Pull-to-refresh also refetches the admin-attention snapshots (useMyAdminClubAttention's refetch, composed from each snapshot query's own refetch since the useQueries fan-out has no single key to invalidate) so the pinned admin rows freshen alongside the signal feed.

Service vs marketing (§50)

  • Push notification kind → classify as 정보성 (service) or 광고성 (marketing) at design time per 정통망법 §50. Service pushes (RSVP, match start, dues, scores, attendance) are EXEMPT from §50 — no marketing consent required. Marketing pushes require (a) active grant for marketing_push purpose in user_consent, (b) "(광고)" prefix in title, (c) 1-tap unsubscribe deep-link, (d) 광고 prefix in the send-push edge function payload. Night-time (21:00–08:00 KST) marketing requires the SEPARATE marketing_push_night consent grant (§50-8). The 13-category signal_category enum (session / matchup / rsvp / dues / club / social / progression / rating / trust / payment / interclub / chat / system) is all 정보성 — no §50 plumbing needed for any of these categories.

Android channels

  • Android notification channels → MUST exist before send-push references them. The 13-channel set (session / matchup / rsvp / dues / club / social / progression / rating / trust / payment / interclub / chat / system) is created in notification.expo.ts via ensureAndroidChannels(). Adding a 14th category: add to signal_category enum first (migration), then add the channel config to the adapter, then bump the app version and trigger a new EAS build — channel config is native and is NOT OTA-able.

Permission soft-prompt

  • Permission soft-prompt → NEVER request notification permission on cold-mount. Use PushPermissionSoftPromptSheet from @twomore/sessions, triggered after the user's first RSVP success. iOS dismissal is one-shot — burning the prompt at cold-mount means the user can never re-enable without going to Settings manually.

Frequency caps

  • Frequency caps → signals_pick_pushable enforces 10/day per user + 3/day per (user, category) with a critical-signal bypass. Never override the cap at the call site. New high-frequency signal types must respect the cap or users will uninstall.

See also

  • AGENTS.md — COMP-7, ARCH-9 (machine-enforced notification subset).
  • Conventions › Korean Market — marketing push §50 title format, consent grants, and Alimtalk rules.
  • CLAUDE.md — orchestration core + the pointer index back to this doc.

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