Skip to content

Push Notifications Rollout Plan (+ Decision Log) โ€‹

Date: 2026-07-25 Status: Live tracker (section 2) + decision log (section 3) Spec: ../specs/2026-07-25-push-notifications-design.mdIssue: Part of #161 (v1 ships the core-loop categories + prefs; notification history, achievements/streaks, and quiet hours remain open on the issue, so it stays open rather than auto-closing) Branch: worktree-heavy-hitters (dedicated heavy-hitter worktree off origin/dev, per operator 2026-07-25)

1. Phases โ€‹

PhaseDeliversDepends on
P1: Shared send helperpushNotifications.js module: prefs check, token load, chunked send, invalid-token pruning. Unit tests with mocked Admin SDK.nothing
P2: Triggerswave-created, wave-accepted, message-created triggers with the 5-min per-connection throttle. Emulator tests for trigger logic.P1
P3: Client generalizationregisterForPush (generalizing registerForNotices), contextual permission prompt after first wave/lantern, foreground toast.nothing (parallel with P1/P2)
P4: PreferencesnotificationPrefs map + rules + settings toggles UI (design skill pass) + trigger enforcement.P1
P5: Verificationtest-plan skill: unit + emulator runs locally; live dev-site browser pass with the test account; document the user-input remainder (real iOS device).P1-P4
Stretch: venue activity nudgeFavorite-venue aggregate nudge, k >= 3, opt-in. Only if P1-P5 land early.P1-P4 + open question 1

One PR for the theme (rule 12). npm run validate once before opening it; /code-review before the PR per memory rule; PR opens as draft to dev.

2. Live status โ€‹

  • [x] Consolidation pass (rule 13): #161 canonical, no competing PRs/specs; existing moderation FCM rail found and adopted as the base
  • [x] Spec written
  • [x] P1 send helper (services/functions/firebase/modules/pushNotifications.js: prefs gate, chunked multicast, dead-token pruning)
  • [x] P2 triggers (wave-created, wave-accepted, message-created with 5-min throttle + block check; registered in main.js)
  • [x] P3 client generalization (registerForPush + alias, foreground toast, EnablePushPrompt post-first-wave nudge on Dashboard)
  • [x] P4 preferences (notificationPrefs map + helpers, NotificationSettingsSection on the Settings tab, rules guard for server-only pushState)
  • [x] Tests green so far: 9 vitest (client service), 9 node:test (trigger logic), 177 rules-emulator tests incl. 3 new pushState guards; eslint 0 errors; lint:stories passes
  • [x] P5 test plan written: docs/engineering/testing/runs/push-notifications-v1/README.md (12 scenarios; automated coverage recorded in its header)
  • [x] /code-review round applied (10 findings, all fixed: D13-D18); suites re-run green: 9 vitest client, 10 node:test triggers, 178 rules-emulator, 437 auth-api, eslint clean
  • [x] Storybook visual check (scenario 11): all 5 stories screenshotted headless, on-brand, zero page errors
  • [ ] P5 remaining: scenarios 3-9 + 12 need the branch on dev (triggers deploy with merge); scenario 10 (physical iPhone) is operator-only; 1-2 + 6 agent-drivable on dev with the test account
  • [ ] npm run validate + /code-review, then draft PR to dev (on operator go-ahead per rule 11)
  • [ ] Stretch venue nudge (not in this PR)

3. Decision log (all executive decisions, per operator directive 2026-07-25) โ€‹

#DecisionRationale
D1Push notifications (#161) chosen as the first "heavy hitter"Longest lead time of the December-alpha gates; the lantern-evening mechanic depends on it; operator deferred to recommendation.
D2Dedicated worktree heavy-hitters created from origin/dev (commit 089c4d54), .env.local symlinked, deps installedOperator explicitly requested a dedicated worktree; rule 15 (branch from origin/dev) and rule 12 (worktree setup steps) followed.
D3Reuse the moderation-notice FCM rail (token store, SW, send pattern) instead of new infraIt exists, is shipped, and matches the "do not add an 8th service" guidance; product push is the same transport with different triggers.
D4Sends implemented as Cloud Functions Firestore triggers, not a Cloud Run endpointRule 9 reserves Cloud Functions for event-driven triggers, which this is; no client-callable HTTP surface is added.
D5Payload privacy invariants: generic copy, no E2EE content, no counterpart identity, no venue+person linkage, inbox-level deep links onlyPrivacy-architecture axiom (prefer losing data over leaking it); push transits Google/Apple infra and lock screens.
D6v1 scope cut to waves + messages + prefs; achievements, streaks, quiet hours, history deferred; venue nudge is stretchDecember alpha gates only on the core loop nudges; the rest is retention polish that postdates real users.
D7Dev test-account credentials saved to auto-memory (not the repo) and read by scripts from LANTERN_TEST_PHONE / LANTERN_TEST_PINOperator asked to stop re-supplying them; memory is the right home, and AGENTS.md rule 6 keeps credentials out of committed code and docs.
D8Message pushes throttled per (recipient, connection) at 5 minutes, server-written lastPushAtBurst chat would spam lock screens; client-writable throttle state would let a spammer bypass it.
D9Moderation send path NOT refactored onto the shared helper in this PRKeep blast radius small; safety-critical path stays untouched until the helper has soaked (open question 3 in the spec).
D10Contextual permission prompt = dismissible card after the first sent wave, once per device (localStorage flag); Settings tab is the durable opt-inPost-wave is the moment the user most wants a reply; a card button preserves the user gesture iOS requires; localStorage keeps it from nagging.
D11pushState on connections locked to server-only in firestore.rules (+3 emulator tests)Participants could otherwise pre-date the throttle and silently mute their peer's message pushes.
D12Test placement: client service in apps/web vitest; trigger pure-logic via node --test in the functions workspace (manual-run like its neighbors, noted in the test plan); rules via npm run test:rulesFollows where each surface's existing tests live; no new CI wiring invented mid-theme.
D13(post /code-review) All product pushes are DATA-ONLY FCM messages; the SW owns display, collapse tag, and click routingReview verified against the FCM SDK source: a notification payload gets auto-displayed by the SDK (tagged copy with a dead-end tap) AND duplicated by onBackgroundMessage. Data-only eliminates both.
D14Moderation notice sender also moved to data-only payload (supersedes D9 for payload shape ONLY; helper adoption stays deferred)Suppressing the SW duplicate alone would leave notices with just the SDK copy, whose tap is a confirmed dead end: a strict regression. Same fix, same invariant, one rail.
D15EnablePushPrompt reports the real outcome (token => enabled, else dismissed); dashboard storage reads/writes are throw-safe; dismissal still retires the nudge per deviceReview findings: denied prompts were misreported as enabled; localStorage throws could fail a delivered wave or strand the card on screen.
D16Notification category set/defaults + push kinds live in @lantern/shared/notifications, imported by both the client and the triggersTwo hand-mirrored default maps had already drifted (venueActivity); shared module makes drift impossible (precedent: USER_ROLES).
D17sendPushToUser runs prefs/tokens/suppression reads in parallel and reads prefs with a field maskThe message trigger fires per chat message; serial round trips and full-doc deserialization (encrypted blobs) are pure wall-clock cost.
D18pushState barred at connection CREATE too (rule + emulator test)Review found the update-only guard left a poisoned-create mute attack open.

4. What still needs operator input โ€‹

  • Spec open questions 1-3 (venue-follow shape, wave-accepted copy explicitness, helper adoption timing). None block P1-P4.
  • P5 will end with a short list of physically-unautomatable checks (real iOS home-screen install, lock-screen rendering); those are yours.
  • Permission prompts from the harness, if any denied tool call blocks work ("enter a 1 liner" offer noted).

Built with VitePress