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 insignalBand()— zero behavior change, see the table-driven regression testalert-state.test.ts). Three axes, table-driven (same idiom asSTRIP_CHIP_CLASSindomain/session-strip.ts):- class (
AlertClass:obligation|event) —obligation= urgent severity (critical|high) AND currently active (isSignalActive— not resolved/dismissed/expired); everything else isevent.classifySignal(signal, now)derives this from existingSignalfields only, no schema change. - lifecycle (
AlertLifecycle:open|read|resolved|dismissed) — priority resolvedAt > dismissedAt > acknowledgedAt > untouched. Obligations are special: acknowledging one does NOT advance it pastopen— only a server-side RESOLVE does (the pre-existing "stays todo even when acknowledged" rule). - urgency (
AlertUrgency) — reusesSignalSeveritydirectly, 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)— alwaysopenwhile the total is positive, null (self-hides) at 0. signalBand()(domain/rules/signal-resolver.ts) now PROJECTS fromclassifySignalfor itstododetermination 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 overunread;countis always the combined total. This is the two-state bell projection — see below.
- class (
Two-state bell + notification-center settings entry
NotificationBell(packages/features/home/src/cards/notification-bell.tsx) — the home tab's bell, projected viaderiveBellState.openObligationCountis sourced from admin-attention only today (sumAdminAttentionTotal); todo-band SIGNAL obligations still fold intounreadEventCountuntil a per-class server-side count RPC exists. Tone → badge:attention=$badgeWarningBg/$badgeWarningText(amber, matchesAdminAttentionRow/할 일 band);unread=$badgeErrorBg/$badgeErrorText(red, pre-existing);idle= no badge. accessibilityLabel is dynamic vianotifications.bellA11yLabel(n)/notifications.bellA11yLabelWithObligation(n, m).- Notification-center headerRight → 모두 읽음 (
ActionButton) + a settings gear (HeaderIconButton+Settingsicon →routes.signalPreferences), side by side in anXStack(the canonical multi-icon AppHeader pattern — seesession-detail-screen.tsx). Pull-to-refresh also refetches the admin-attention snapshots (useMyAdminClubAttention'srefetch, composed from each snapshot query's ownrefetchsince theuseQueriesfan-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_pushpurpose inuser_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 SEPARATEmarketing_push_nightconsent grant (§50-8). The 13-categorysignal_categoryenum (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.tsviaensureAndroidChannels(). Adding a 14th category: add tosignal_categoryenum 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
PushPermissionSoftPromptSheetfrom@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_pushableenforces 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.