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 โ
| Phase | Delivers | Depends on |
|---|---|---|
| P1: Shared send helper | pushNotifications.js module: prefs check, token load, chunked send, invalid-token pruning. Unit tests with mocked Admin SDK. | nothing |
| P2: Triggers | wave-created, wave-accepted, message-created triggers with the 5-min per-connection throttle. Emulator tests for trigger logic. | P1 |
| P3: Client generalization | registerForPush (generalizing registerForNotices), contextual permission prompt after first wave/lantern, foreground toast. | nothing (parallel with P1/P2) |
| P4: Preferences | notificationPrefs map + rules + settings toggles UI (design skill pass) + trigger enforcement. | P1 |
| P5: Verification | test-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 nudge | Favorite-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,EnablePushPromptpost-first-wave nudge on Dashboard) - [x] P4 preferences (
notificationPrefsmap + helpers,NotificationSettingsSectionon the Settings tab, rules guard for server-onlypushState) - [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) โ
| # | Decision | Rationale |
|---|---|---|
| D1 | Push 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. |
| D2 | Dedicated worktree heavy-hitters created from origin/dev (commit 089c4d54), .env.local symlinked, deps installed | Operator explicitly requested a dedicated worktree; rule 15 (branch from origin/dev) and rule 12 (worktree setup steps) followed. |
| D3 | Reuse the moderation-notice FCM rail (token store, SW, send pattern) instead of new infra | It exists, is shipped, and matches the "do not add an 8th service" guidance; product push is the same transport with different triggers. |
| D4 | Sends implemented as Cloud Functions Firestore triggers, not a Cloud Run endpoint | Rule 9 reserves Cloud Functions for event-driven triggers, which this is; no client-callable HTTP surface is added. |
| D5 | Payload privacy invariants: generic copy, no E2EE content, no counterpart identity, no venue+person linkage, inbox-level deep links only | Privacy-architecture axiom (prefer losing data over leaking it); push transits Google/Apple infra and lock screens. |
| D6 | v1 scope cut to waves + messages + prefs; achievements, streaks, quiet hours, history deferred; venue nudge is stretch | December alpha gates only on the core loop nudges; the rest is retention polish that postdates real users. |
| D7 | Dev test-account credentials saved to auto-memory (not the repo) and read by scripts from LANTERN_TEST_PHONE / LANTERN_TEST_PIN | Operator asked to stop re-supplying them; memory is the right home, and AGENTS.md rule 6 keeps credentials out of committed code and docs. |
| D8 | Message pushes throttled per (recipient, connection) at 5 minutes, server-written lastPushAt | Burst chat would spam lock screens; client-writable throttle state would let a spammer bypass it. |
| D9 | Moderation send path NOT refactored onto the shared helper in this PR | Keep blast radius small; safety-critical path stays untouched until the helper has soaked (open question 3 in the spec). |
| D10 | Contextual permission prompt = dismissible card after the first sent wave, once per device (localStorage flag); Settings tab is the durable opt-in | Post-wave is the moment the user most wants a reply; a card button preserves the user gesture iOS requires; localStorage keeps it from nagging. |
| D11 | pushState 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. |
| D12 | Test 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:rules | Follows 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 routing | Review 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. |
| D14 | Moderation 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. |
| D15 | EnablePushPrompt reports the real outcome (token => enabled, else dismissed); dashboard storage reads/writes are throw-safe; dismissal still retires the nudge per device | Review findings: denied prompts were misreported as enabled; localStorage throws could fail a delivered wave or strand the card on screen. |
| D16 | Notification category set/defaults + push kinds live in @lantern/shared/notifications, imported by both the client and the triggers | Two hand-mirrored default maps had already drifted (venueActivity); shared module makes drift impossible (precedent: USER_ROLES). |
| D17 | sendPushToUser runs prefs/tokens/suppression reads in parallel and reads prefs with a field mask | The message trigger fires per chat message; serial round trips and full-doc deserialization (encrypted blobs) are pure wall-clock cost. |
| D18 | pushState 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).