Notification Conventions
Status: Active Last reviewed: 2026-10-21
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) PROJECTS fromclassifySignalfor itstododetermination instead of re-deriving urgency+activity inline. 알림 no longer draws bands (flat v2, 2026-10-21): it sections by time and filters by 처리할 일 (below).deriveBellState({ unreadVisibleTotal })→{ tone: 'unread' | 'idle', count }. The bell is 알림's unread rows, one per group, with no netting (owner review 46, D4; W4 batch 18): an urgent row is one unread row like the rest, so the bell equals the 안 읽음 value on the page. There is no attention tone; what waits on the viewer is 할 일 on 홈 and 처리할 일 on 알림, never the bell. The count issignals_unread_visible_count, which shares the one visibility rule of the feed,signals_visible_counts,signals_open_requests()andsignals_acknowledge_visible_unread(30 days, not dismissed, not chat, the preference gate; migration 00727). The obligation counts (useOpenObligationSignalCounts,getOpenObligationCounts) are retired.
- class (
Unread-rows bell + notification-center settings entry
useBellAction(packages/features/home/src/cards/use-bell-action.ts) — the home tab's bell as a Header action,coveredByWebNav(and the web nav's 알림 item), projected viauseNotificationBellState→deriveBellState. Tone → badge:unread=$badgeErrorBg/$badgeErrorText(red);idle= no badge. the action's name isnotifications.bellA11yLabel(n); the badge is the Header's count Tag. The bell is 알림's 안 읽음 with no netting (owner review 46, D4; landed W4 batch 18).- 알림's header → back, 알림, and one gear (알림 설정 →
routes.signalPreferences); no ⋯, no Select mode, no 모두 읽음, no row menu (W4 batch 5, L3-13: one home per action). The unread rows a viewer saw are acknowledged in one batch when they leave the page; a row's dot clears when it opens.
알림 (flat v2, L3-13; the owner, 2026-10-20)
- One feed, three time sections, one filter. 오늘 / 이번 주 / 이전 알림 over the last 30 days, newest first (no severity sort); the first head carries the filter (전체, 안 읽음, 처리할 일) and no 모두 읽음 (owner review 48: a row is read when it is opened, and the dots still showing count as seen when the viewer leaves; the shipped header's ⋯ keeps 모두 읽음 until the screen is rebuilt from the board). Reading a row clears its dot and never moves it.
buildNotificationSections(domain/rules/notification-feed.rules.ts) is the one sectioning; the screen isNotificationCenterScreen(screen blueprint, home). - 처리할 일 is server truth, never a client guess. It is the alerts that ask the viewer to act while their request is open:
signals_open_requests()(migration 00699) returns the ids of the viewer's visible alerts whose request is open (입금 확인: the submitted hold is unconfirmed and the viewer is its host or an official; 가입 신청: the request is pending, never revived by a later one; 참가비 입금: the viewer's hold is idle on an open paid session). A row leaves the view when its request is settled, never when it is read. The kinds live in two places held together by a parity test:REQUEST_SIGNAL_TYPESand the SQL type list. A new request kind (사유 인정 요청, 신고, 친구 요청) adds its type to both and its open rule to the function. - Deleting an alert never touches its request. 삭제 is the row's swipe (the kit
SwipeAction: a full swipe commits, a mouse drag on the web, a 삭제 custom action for screen readers) and issignals_dismiss_batch, with 되돌리기 for 8 s (signals_undismiss_batch) and no Dialog. - No admin rows in 알림. The officer counters (가입/입금확인/미납/신고/게스트) belong to 홈's 할 일 (
deriveHomeTasksplus the counters); 알림 lists alerts only. - A delayed answer notice (00754). An answer on a 사유 인정 request tells the requester only after the 10 minutes in which the answerer can undo it: the tick
signals_tick_excuse_answersemitsattendance_excuse_accepted/attendance_excuse_declinedonce, and an answer that was undone sends nothing. The request and its 3-day reminder to the deciders are two types (excuse_requested,excuse_request_reminder) so the reminder pushes again. The in-app row names the requester (the lead's ruling, 2026-11-12:signal-copy.tswords it with the actor and its fold); the push names no one (notification-copy.tsandactor-copy.tsadd no name for these types) and neither carries the category or the note.
Admin-attention grouping (retired from 알림, 2026-10-21)
- KIND-GROUPS (owner ruling, improvement plan 2026-09-23, ROUND-4) — the retired 할 일 band keyed admin work by KIND, never by club or by underlying item count. It left 알림 with the bands (above); the grouping function and the per-(club, kind) dismissal stay for 홈's 할 일.
groupAdminAttentionByKind(packages/app/src/presentation/utils/club-admin-attention.ts) buckets every open admin obligation into the canon 가입/입금확인/미납/신고/게스트 order; a kind held by exactly ONE club renders as that club's own direct row (tap → the kind's screen); a kind held by 2+ clubs renders as ONE expandable group-header row ("N개 클럽") that reveals its per-club rows only while expanded — never one row per club by default, and never a warning-toned card (a neutral row carries a warning dot instead). Every filter chip on the notification center counts the ROWS it reveals, not the raw signal quantity behind them — 전체 includes the admin rows. Dismissal has the same unit as the row: one (club, kind) pair (admin-attention-ack.store, keyed by club then kind): dismissing 가입 never hides the same club's 미납, the bell total drops by that kind only, and the row returns when its own count changes (2026-09-24 review — the first build still dismissed the whole club, hiding unrelated kinds and undercounting the bell). Evidence: decision log (ROUND 4 — KIND-GROUPS).
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.
Push message fields (send-push, W4 batch 18)
supabase/functions/send-push/push-message.ts builds the Expo message for one claimed alert (pure; the clock is a parameter). The fields:
ttl— seconds until the alert'sexpires_at, capped at four weeks; with no usableexpires_at(absent, unparseable or already past) the severity default: low 6 h, medium and high 24 h, critical 72 h.signals_claim_pushabledoes not returnexpires_atyet, so today every push takes the severity default; the builder honours the column as soon as the claim returns it.interruptionLevel(iOS) —passivefor low severity,activeotherwise. Nevertime-sensitiveuntil the W5 native build carries the Time Sensitive Notifications entitlement; sending it without the entitlement is a provider error, so the builder's type does not allow it.threadId(iOS) — the group key: thededup_keywithout its trailing:{recipient}part (a key with no user part is its own thread; no key, no thread). A grouped alert and its repeats stack under one thread.badge(iOS) — theunread_countthe claim returns (the bell's number, chat excluded); omitted when unknown, since0clears the badge.- Copy —
actor-copy.tsnames the actor for the request and reply kinds ("{name}님 외 N명이 …" once a group has several distinctpayload.actorIds; the name is resolved at read time fromcontext.actorUserId, never stored) and counts the repeats of 새 일정 / 새 글 ("새 일정 N개가 올라왔어요"). 친구 요청 and 신고 have fixed bodies that name no one. The keys are held to the client's bypush-copy-sync.test.ts; the sentences are the push's own.
Permission soft-prompt
- The notification permission status says each thing by its own name:
undeterminedis the one value that means "never asked: ask now";unavailablemeans the platform has no push (the browser build answers it from bothgetPermissionStatusandrequestPermission); the hook returnsnulluntil the read settles. Onlyundeterminedopens the soft prompt, and nothing offers to turn push on where it isunavailable. - 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.
클럽 대항전 notices (follow-up batch C, 2026-11-12)
Six types in the interclub category: interclub_challenge_received (an open request folded per club, while the challenge is proposed), interclub_challenge_accepted and interclub_challenge_declined (two notices to the proposing club's officials), interclub_challenge_cancelled (the other club's officials), interclub_match_scheduled (a session tagged: the other club's officials) and interclub_challenge_settled (every active member of both clubs, once). Ids only in context and payload (clubId is always the recipient's club, otherClubId the opposite one); no person's or club's name in a push or a payload (the client resolves titles from ids; push bodies are fixed). A tap on any of them lands on /clubs/{id}/challenges. Every type is in every registry (the signal-completeness test) and signals_open_requests carries the request kind.
가입 제안 notices (batch 57, migration 00766)
Two types in the club category. club_membership_offered is a request kind (in REQUEST_SIGNAL_TYPES and in signals_open_requests(), never folded): it goes to the offered person when an official's 지명 or 가입 제안 보내기 inserts the pending request (source = 'admin_nominated'), and it is open while that request is pending for that person. Their accept, decline, the official's 제안 취소, a guest's removal, the club's archive and the person joining the club by any other path all end the request, so the notice closes with no write; an official's 제안 취소 (and the archive) sends the person no join_request_decided, because the person never applied. Offers made before the migration got their notice from its backfill (the same dedup key, so a re-run adds nothing); it opens the club's page (the band with 수락 and 거절). club_membership_offer_answered is an event (not a request): it goes once to each active official, on a decline only (the officials already get the member-joined notice when a person accepts, so a second notice would double it); it opens 멤버 관리. Ids only in context and payload (no official is named in the person's notice); the push titles are in send-push's copy, which must be redeployed with this migration. An offer no longer sends the officials the "X applied" notice.
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.