diff --git a/.planning/phases/05-web-push-notifications/05-RESEARCH.md b/.planning/phases/05-web-push-notifications/05-RESEARCH.md
new file mode 100644
index 0000000..54cb79e
--- /dev/null
+++ b/.planning/phases/05-web-push-notifications/05-RESEARCH.md
@@ -0,0 +1,875 @@
+# Phase 5: Web Push Notifications — Research
+
+**Researched:** 2026-06-09
+**Domain:** Web Push (VAPID), vite-plugin-pwa injectManifest, iOS push reliability, Node.js scheduler, MariaDB schema migration
+**Confidence:** HIGH (codebase facts) / MEDIUM (library APIs via Context7)
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+- **D-01:** Coalesce list-change pushes per list within ~30–60s window. Reorder (`position`) changes do NOT push.
+- **D-02:** Event notifications show specifics (title, time, action). List pings stay generic (actor + list + change count, no item text).
+- **D-03:** Name the actor in every change notification. Two-person household.
+- **D-04:** Event-change trigger = meaningful changes only (new, delete, time/date/title/location changes). Description-only edits are silent.
+- **D-05:** Reminders fire for SHARED Family-calendar events only (is_shared=1). Native device calendar apps cover personal events.
+- **D-06:** Fixed ~15 min lead time for v1. No per-event offset.
+- **D-07:** All-day events get no reminder.
+- **D-08:** Contextual permission prompt right after PWA install or first installed launch; trigger pushManager.subscribe() on a tap gesture.
+- **D-09:** Single master on/off toggle for v1.
+- **D-10:** Dead-subscription recovery = silent auto re-subscribe on app open if OS permission is still granted. Surface UI only if OS permission itself was revoked.
+- **D-11:** iOS reliability is mandatory from day one: subscription health-check + event.waitUntil() in SW + every push MUST display a visible notification.
+- **D-12:** In-memory EventEmitter fan-out, no Redis. API is a single Node process. Push dispatch hooks same publish points as SSE.
+- **D-13:** Broker is the only Fastmail I/O boundary. Notification code reads from MariaDB cache / poller / outbox — no tsdav in notification code.
+- **D-14:** react-router is installed. Tap targets use real URLs for deep-linking.
+
+### Claude's Discretion
+
+- No quiet-hours / DND in v1.
+- Tap-to-open deep-link targets (obvious mapping).
+- Service-worker strategy: switch generateSW → injectManifest with custom SW; preserve Workbox precache + autoUpdate.
+- VAPID key generation + storage strategy.
+- Push-subscription table schema (member-count-agnostic per D-18).
+- Reminder-scheduler mechanism (cron/interval scanning shared-calendar timed events in MariaDB cache).
+- Coalescing debounce implementation.
+- Event-change detection trigger points (poller vs outbox-confirm).
+
+### Deferred Ideas (OUT OF SCOPE)
+
+- Quiet hours / Do-Not-Disturb
+- Per-event / custom reminder lead time
+- Per-category opt-out (reminder / event-change / list-change toggles)
+- Reminders for personal-calendar events
+- Notifying on the member's own changes
+
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| NOTIF-01 | User receives a Web Push reminder before an event starts | Reminder scheduler (node-cron interval scanning shared-calendar timed events at dtstart_utc in MariaDB cache), web-push sendNotification, SW push event with event.waitUntil() |
+| NOTIF-02 | User receives a Web Push alert when the other member changes a shared list | Hook publishListEvent in listEmitter.ts, coalescing debounce (30–60s per list), suppress actor's own subscription |
+| NOTIF-03 | User receives a Web Push alert when an event is added or changed | Hook syncCalendar (poller) and outboxWorker (on success + targeted re-sync), filter meaningful fields, suppress actor's own subscription |
+
+
+---
+
+## Summary
+
+Phase 5 delivers Web Push for three distinct triggers — event reminders (NOTIF-01), event changes (NOTIF-03), and list changes (NOTIF-02) — across iOS and Android. The technical implementation splits cleanly into four areas: server-side VAPID dispatch, a new reminder scheduler, PWA service-worker migration, and frontend subscription lifecycle.
+
+The most critical constraint is iOS reliability. iOS silently revokes a push subscription after approximately three pushes that do not display a visible notification. The mandatory mitigations (event.waitUntil(), every push shows a notification, subscription health-check on app open) must be present from the first commit. Missing any one of them causes silent subscription death that the user cannot observe.
+
+The second critical constraint is the service-worker migration. The existing vite-plugin-pwa config uses `generateSW` which auto-generates the entire SW. Adding `push` and `notificationclick` handlers requires switching to `injectManifest` with a custom SW source file. The migration must preserve the existing Workbox precache manifest injection, the `/callback` denylist (T-03-20), and the `autoUpdate` behavior — all of which are currently handled automatically and must be explicitly re-declared in the custom SW.
+
+**Primary recommendation:** Implement in this order — (1) migrate SW to injectManifest + add push/notificationclick skeleton, (2) add push-subscription table + API routes, (3) wire dispatch to listEmitter + sync/outbox hooks, (4) add reminder scheduler, (5) add PWA permission prompt + settings UI.
+
+---
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| VAPID key storage | API / Backend | — | Private key must never reach browser; stored in .env / DB |
+| Push subscription storage | API / Backend (MariaDB) | — | Subscriptions are server-side state; browser only holds in pushManager |
+| Push dispatch (sendNotification) | API / Backend | — | Server-side only; web-push library runs in Node.js |
+| Reminder scheduling | API / Backend (node-cron) | — | Timer + DB query; no browser involvement |
+| Coalescing debounce for list pushes | API / Backend | — | Debounce runs on publish events in the API process |
+| Event-change detection | API / Backend (poller + outbox) | — | Reads MariaDB cache; D-13 prohibits tsdav in notification code |
+| SW push event + showNotification | Browser / Service Worker | — | push event fires in SW; must call event.waitUntil(showNotification()) |
+| SW notificationclick (deep-link) | Browser / Service Worker | — | Open /calendar or /lists/:id via clients.openWindow() |
+| Permission request lifecycle | Browser / Client (React hook) | — | Must be in tap handler; iOS requires user gesture |
+| Subscription persist / health-check | Browser / Client (React hook) | API / Backend | Hook reads pushManager, POSTs subscription to API |
+| Settings toggle (master on/off) | Frontend / React PWA | API / Backend | UI toggle; DELETE subscription via API |
+| Permission-denied banner | Frontend / React PWA | — | Read Notification.permission; no API call needed |
+
+---
+
+## Standard Stack
+
+### Core
+
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| web-push | 3.6.7 | VAPID key generation + push dispatch | Listed in CLAUDE.md; only maintained Node.js VAPID push library; 5M+ weekly downloads [VERIFIED: npm registry] |
+| @types/web-push | 3.6.4 | TypeScript types for web-push | Official DefinitelyTyped types; required for strict-mode TS [VERIFIED: npm registry] |
+| workbox-precaching | 7.4.1 | Precache manifest in custom SW | Required by vite-plugin-pwa injectManifest; currently handled auto by generateSW [VERIFIED: npm registry] |
+| workbox-core | 7.x | clientsClaim + skipWaiting for autoUpdate | Required for autoUpdate behavior in injectManifest mode [ASSUMED — workbox-core is the peer of workbox-precaching; version matches Workbox 7] |
+
+### Supporting
+
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| node-cron | 4.2.1 | Reminder scheduler | Already installed in apps/api; used by poller and outboxWorker [VERIFIED: codebase] |
+
+### Installation
+
+```bash
+# API
+pnpm --filter @familysync/api add web-push
+pnpm --filter @familysync/api add -D @types/web-push
+
+# PWA (devDependencies — workbox is bundled into SW at build time)
+pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core
+```
+
+**Version verification (run at implementation time):**
+
+```bash
+npm view web-push version # confirmed 3.6.7
+npm view @types/web-push version # confirmed 3.6.4
+npm view workbox-precaching version # confirmed 7.4.1
+```
+
+---
+
+## Package Legitimacy Audit
+
+| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
+|---------|----------|-----|-----------|-------------|---------|-------------|
+| web-push | npm | ~9 yrs (2024-01-16 last pub) | 5.09M/wk | github.com/web-push-libs/web-push | OK | Approved |
+| @types/web-push | npm | ~8 yrs (2024-10-22 last pub) | 1.68M/wk | github.com/DefinitelyTyped/DefinitelyTyped | OK | Approved |
+| workbox-precaching | npm | ~8 yrs (2026-05-04 last pub) | 7.92M/wk | github.com/googlechrome/workbox | OK | Approved |
+
+**Packages removed due to SLOP verdict:** none
+**Packages flagged as suspicious (SUS):** none
+
+---
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+Browser (PWA) API (Node.js / Hono)
+───────────────────────────── ──────────────────────────────────────────
+[InstallPrompt/PushPermissionPrompt]
+ │ tap: requestPermission()
+ ↓
+[pushManager.subscribe(vapidPublicKey)]
+ │ PushSubscription {endpoint, keys}
+ │ POST /api/push/subscription
+ ─────────────────────────────────→ [pushRouter]
+ │ INSERT push_subscriptions (userId, endpoint, p256dh, auth)
+ ↓
+ [MariaDB: push_subscriptions]
+
+── Triggers (server-side) ──────────────────────────────────────────────────
+
+[node-cron every 1 min] [poller/sync.ts — on calendarEvents upsert]
+ │ SELECT shared timed events │ detect meaningful change (new/updated uid)
+ │ WHERE dtstart_utc BETWEEN │ skip actor's subscription
+ │ NOW()+14min AND NOW()+16min │
+ ↓ ↓
+[reminderScheduler.ts] [eventChangeDispatcher.ts]
+ │ SELECT push_subscriptions │ SELECT push_subscriptions
+ │ WHERE userId != event.userId? │ WHERE userId NOT IN (actor)
+ │ (all members for shared events) │
+ ↓ ↓
+[pushDispatcher.ts] ←─────────────[listChangeDispatcher.ts (coalesced)]
+ │ webpush.sendNotification() ↑
+ │ payload: {web_push:8030, [publishListEvent hook in listEmitter.ts]
+ │ notification:{title,body, │ debounce 30-60s per (listId, actorId)
+ │ navigate, ...}} │ suppress actor's own subscription
+ │
+ │ on 410/404 → DELETE push_subscriptions (prune expired)
+ │ on success → subscription stays
+ ↓
+[Push Service (APNs/FCM)]
+ ↓
+[Browser / iOS SW]
+
+── PWA Service Worker (sw.ts) ──────────────────────────────────────────────
+[push event]
+ └→ event.waitUntil(
+ self.registration.showNotification(data.notification.title, {
+ body, tag, data: {url}
+ })
+ )
+[notificationclick event]
+ └→ clients.openWindow(event.notification.data.url)
+```
+
+### Recommended Project Structure
+
+New files this phase:
+
+```
+apps/api/src/
+├── routes/
+│ └── push.ts # POST /api/push/subscription, DELETE, GET /api/push/vapid-public-key
+├── lib/
+│ ├── pushDispatcher.ts # webpush.sendNotification wrapper + 410/404 pruning
+│ └── pushCoalescer.ts # per-(listId,actorId) debounce for list-change pushes
+├── broker/
+│ └── reminderScheduler.ts # node-cron 1-min interval: scan shared timed events ±1min window
+└── db/schema.ts # add push_subscriptions table
+
+apps/pwa/src/
+├── sw.ts # NEW custom SW: precacheAndRoute + push + notificationclick
+├── components/
+│ ├── PushPermissionPrompt.tsx
+│ ├── SettingsSheet.tsx
+│ └── PermissionDeniedBanner.tsx
+└── hooks/
+ └── usePushSubscription.ts # subscribe/unsubscribe/health-check lifecycle
+
+apps/api/src/db/migrations/
+└── 0003_push_subscriptions.sql # generated by drizzle-kit generate
+```
+
+### Pattern 1: web-push VAPID dispatch (TypeScript / ESM)
+
+```typescript
+// Source: https://github.com/web-push-libs/web-push/blob/master/README.md
+import webpush from 'web-push'
+
+// Call once at API startup (index.ts isMainModule() guard)
+webpush.setVapidDetails(
+ 'mailto:admin@familysync.bergerhouse.net',
+ process.env.VAPID_PUBLIC_KEY!,
+ process.env.VAPID_PRIVATE_KEY!,
+)
+
+// Dispatch helper — prunes expired subscriptions on 410/404
+async function dispatchPush(
+ subscription: { endpoint: string; p256dh: string; auth: string },
+ payload: object,
+ dbRowId: number,
+): Promise {
+ const sub = {
+ endpoint: subscription.endpoint,
+ keys: { p256dh: subscription.p256dh, auth: subscription.auth },
+ }
+ const body = JSON.stringify({
+ web_push: 8030,
+ notification: payload,
+ })
+ try {
+ await webpush.sendNotification(sub, body, {
+ TTL: 300, // 5 min: notification has already expired if not delivered soon
+ urgency: 'normal',
+ })
+ } catch (err: unknown) {
+ const statusCode = (err as { statusCode?: number }).statusCode
+ if (statusCode === 410 || statusCode === 404) {
+ // Subscription expired — delete from DB to avoid future failed sends
+ await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, dbRowId))
+ }
+ // Other errors: log and continue (transient failures; next send will retry)
+ console.error('[pushDispatcher] sendNotification error:', statusCode, (err as Error).message)
+ }
+}
+```
+
+### Pattern 2: Custom Service Worker (sw.ts) — injectManifest
+
+```typescript
+// Source: https://github.com/vite-pwa/vite-plugin-pwa/blob/main/docs/guide/inject-manifest.md
+import { precacheAndRoute } from 'workbox-precaching'
+import { clientsClaim } from 'workbox-core'
+
+declare let self: ServiceWorkerGlobalScope
+
+// autoUpdate behavior: claim all clients immediately on activate
+self.skipWaiting()
+clientsClaim()
+
+// Inject Workbox precache manifest (plugin populates self.__WB_MANIFEST at build time)
+precacheAndRoute(self.__WB_MANIFEST)
+
+// CRITICAL: every push MUST call showNotification (D-11 / iOS requirement)
+self.addEventListener('push', (event: PushEvent) => {
+ let title = 'FamilySync'
+ let options: NotificationOptions = { body: 'You have a new notification' }
+
+ if (event.data) {
+ try {
+ const data = event.data.json() as {
+ notification?: { title?: string; body?: string; navigate?: string }
+ title?: string
+ body?: string
+ tag?: string
+ data?: { url?: string }
+ }
+ // Support both Declarative Web Push format (iOS 18.4+) and legacy format
+ const notif = data.notification ?? data
+ title = notif.title ?? title
+ options = {
+ body: notif.body ?? options.body,
+ tag: (data as { tag?: string }).tag ?? undefined,
+ data: { url: (notif as { navigate?: string }).navigate ?? (data as { data?: { url?: string } }).data?.url ?? '/' },
+ }
+ } catch {
+ // Malformed payload — still show a generic notification (iOS: never drop silently)
+ }
+ }
+
+ // event.waitUntil is MANDATORY — iOS revokes subscription after ~3 silent pushes (D-11)
+ event.waitUntil(self.registration.showNotification(title, options))
+})
+
+self.addEventListener('notificationclick', (event: NotificationEvent) => {
+ event.notification.close()
+ const url: string = (event.notification.data as { url?: string })?.url ?? '/'
+ event.waitUntil(
+ (self.clients as Clients).matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
+ // Focus existing window if already open
+ for (const client of clientList) {
+ if ('url' in client && (client as WindowClient).url === url && 'focus' in client) {
+ return (client as WindowClient).focus()
+ }
+ }
+ return (self.clients as Clients).openWindow(url)
+ }),
+ )
+})
+```
+
+### Pattern 3: vite.config.ts migration to injectManifest
+
+```typescript
+// Source: https://github.com/vite-pwa/vite-plugin-pwa/blob/main/docs/guide/inject-manifest.md
+VitePWA({
+ strategies: 'injectManifest',
+ srcDir: 'src',
+ filename: 'sw.ts',
+ registerType: 'autoUpdate',
+ injectManifest: {
+ // Preserve existing SW denylist behavior (T-03-20: /callback must not be precached)
+ globIgnores: ['**/node_modules/**', '**/callback**'],
+ },
+ manifest: {
+ // ... same manifest config as current generateSW setup ...
+ },
+ // Note: navigateFallback moves from workbox: {} to injectManifest: {} or is handled
+ // directly in the custom SW via WorkboxRouter if needed
+})
+```
+
+### Pattern 4: Push subscription schema (Drizzle, MariaDB)
+
+```typescript
+// apps/api/src/db/schema.ts addition
+import { varchar, text, timestamp, int, mysqlTable, index, unique } from 'drizzle-orm/mysql-core'
+
+export const pushSubscriptions = mysqlTable(
+ 'push_subscriptions',
+ {
+ id: int().primaryKey().autoincrement(),
+ userId: int('user_id')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ endpoint: text('endpoint').notNull(),
+ p256dh: text('p256dh').notNull(),
+ auth: varchar('auth', { length: 256 }).notNull(),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
+ },
+ (t) => [
+ // One endpoint per user (a member can have multiple devices, but endpoint is globally unique)
+ unique('uniq_push_endpoint').on(t.endpoint),
+ index('idx_push_subscriptions_user_id').on(t.userId),
+ ],
+)
+```
+
+**Migration:**
+
+```bash
+# MUST use generate+migrate, never db:push (drizzle-mariadb-push-unsafe.md)
+pnpm --filter @familysync/api db:generate
+pnpm --filter @familysync/api db:migrate
+```
+
+The migration file will be generated at `apps/api/src/db/migrations/0003_.sql`.
+
+### Pattern 5: Reminder Scheduler (node-cron, 1-min interval)
+
+```typescript
+// apps/api/src/broker/reminderScheduler.ts
+import { schedule } from 'node-cron'
+import { and, eq, gte, lte, isNull, not } from 'drizzle-orm'
+import { db } from '../db/client.js'
+import { calendars, calendarEvents, pushSubscriptions } from '../db/schema.js'
+
+// Fire once per minute; scan window: [now+14min, now+16min] (D-06: 15-min lead)
+export function startReminderScheduler(): void {
+ schedule('* * * * *', async () => {
+ const now = new Date()
+ const windowStart = new Date(now.getTime() + 14 * 60 * 1000)
+ const windowEnd = new Date(now.getTime() + 16 * 60 * 1000)
+
+ // D-05: shared events only; D-07: timed events only (allDay=false)
+ const events = await db
+ .select({ uid: calendarEvents.uid, title: calendarEvents.uid /* swap for title field */ })
+ .from(calendarEvents)
+ .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
+ .where(
+ and(
+ eq(calendars.isShared, true),
+ eq(calendarEvents.allDay, false),
+ gte(calendarEvents.dtstartUtc, windowStart),
+ lte(calendarEvents.dtstartUtc, windowEnd),
+ not(isNull(calendarEvents.dtstartUtc)),
+ ),
+ )
+
+ for (const event of events) {
+ // Get all subscriptions for all users (shared event — notify all members)
+ const subs = await db.select().from(pushSubscriptions)
+ for (const sub of subs) {
+ await dispatchPush(sub, { title: event.uid, body: 'Starts in 15 min', ... }, sub.id)
+ }
+ }
+ })
+}
+```
+
+**Critical schema note:** `calendarEvents` does NOT have a `title` column — only `uid`, `rawVevent`, and `dtstartUtc`. The scheduler must extract the VEVENT SUMMARY from `rawVevent` using ical.js, or the schema must be extended with a `title` column (recommended — avoids ical.js parsing on every reminder fire).
+
+### Pattern 6: List-change coalescing debounce
+
+```typescript
+// apps/api/src/lib/pushCoalescer.ts
+const pendingCoalesced = new Map }>()
+
+export function coalesceListPush(
+ listId: number,
+ actorId: number,
+ actorName: string,
+ listName: string,
+ dispatch: (payload: object, excludeUserId: number) => void,
+ windowMs = 45_000,
+): void {
+ const key = `${listId}:${actorId}`
+ const existing = pendingCoalesced.get(key)
+ if (existing) {
+ existing.count++
+ clearTimeout(existing.timer)
+ }
+ const entry = existing ?? { count: 1, timer: null! }
+ entry.timer = setTimeout(() => {
+ pendingCoalesced.delete(key)
+ dispatch(
+ {
+ title: `${actorName} updated ${listName}`,
+ body: `${entry.count} change${entry.count === 1 ? '' : 's'}`,
+ navigate: `/lists/${listId}`,
+ },
+ actorId, // D-03: suppress own notification
+ )
+ }, windowMs)
+ if (!existing) pendingCoalesced.set(key, entry)
+}
+```
+
+### Pattern 7: usePushSubscription hook (React PWA)
+
+```typescript
+// apps/pwa/src/hooks/usePushSubscription.ts
+// Manages: subscribe, unsubscribe, health-check on mount (D-10)
+export function usePushSubscription() {
+ // On mount: if permission granted but no subscription → silently re-subscribe (D-10)
+ useEffect(() => {
+ if (Notification.permission !== 'granted') return
+ navigator.serviceWorker.ready.then(async (reg) => {
+ const existing = await reg.pushManager.getSubscription()
+ if (!existing) {
+ // Silent re-subscribe (D-10) — no user gesture needed (permission already granted)
+ await subscribeAndPost(reg)
+ }
+ })
+ }, [])
+
+ // subscribe: must be called inside a tap handler (D-08 / iOS requirement)
+ async function subscribe(reg: ServiceWorkerRegistration): Promise {
+ const sub = await reg.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
+ })
+ await fetch('/api/push/subscription', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(sub.toJSON()),
+ credentials: 'include',
+ })
+ }
+
+ async function unsubscribe(): Promise {
+ const reg = await navigator.serviceWorker.ready
+ const sub = await reg.pushManager.getSubscription()
+ if (sub) await sub.unsubscribe()
+ await fetch('/api/push/subscription', { method: 'DELETE', credentials: 'include' })
+ }
+
+ return { subscribe, unsubscribe }
+}
+```
+
+### Anti-Patterns to Avoid
+
+- **Calling pushManager.subscribe() outside a user gesture:** iOS silently fails. Always call inside onClick/onTap handler, never on component mount or useEffect.
+- **Silent pushes (no showNotification in push handler):** iOS revokes subscription after ~3 silent pushes. event.waitUntil(showNotification(...)) is mandatory on every push event, even if payload is malformed.
+- **Using db:push for schema migration:** drizzle-kit push emits false destructive diff on populated MariaDB (truncates tables). Always use `db:generate` + `db:migrate`.
+- **Using generateSW with custom push handler:** generateSW auto-generates the entire SW from options only — there is no hook to inject push event listeners. Must switch to injectManifest.
+- **Storing VAPID private key in code / git:** Store in .env (VAPID_PRIVATE_KEY). Never commit.
+- **Fan-out to all users for a list change:** Only fan out to users who can access the list (list_shares join table). Use same access check as SSE route.
+- **Duplicate reminder pushes:** The 1-min scheduler with a ±1-min window will fire twice for an event if it falls exactly at the boundary. Deduplicate via a reminder_sent flag or a separate `sent_reminders` table keyed by (eventUid, scheduledAt bucket).
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| VAPID signing + encryption | Custom crypto | web-push | RFC 8292 + Message Encryption for Web Push; 40+ lines of crypto primitives per send |
+| Push payload encryption | Manual AES-128-GCM | web-push.sendNotification | Handles p256dh key agreement + content encryption per IETF RFC 8291 |
+| Service worker precache manifest | Manual file list | workbox-precaching + self.__WB_MANIFEST | Build-time injection; stale hash mismatches cause update failures |
+| VAPID key generation | crypto.generateKeyPair | webpush.generateVAPIDKeys() or web-push CLI | Returns URL-safe Base64 directly; format required by push services |
+| Subscription expiry cleanup | Custom cron | 410/404 error handler in dispatchPush | Push services send 410 exactly when subscription is gone; polling misses edge cases |
+
+---
+
+## Runtime State Inventory
+
+> Not a rename/refactor phase. Section omitted.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: iOS subscription silently revoked after ~3 silent pushes
+**What goes wrong:** Push notifications stop arriving on iOS with no error. The subscription endpoint still exists in the DB. The push service returns 200 but the notification never appears.
+**Why it happens:** iOS Safari enforces that every push event results in a visible notification. Three consecutive push events without showNotification() cause APNs to mark the subscription dead.
+**How to avoid:** Every push event handler MUST call event.waitUntil(self.registration.showNotification(...)) — even for malformed payloads (fall back to a generic message). No silent pushes, ever.
+**Warning signs:** Users stop receiving notifications after a period of working correctly. DB shows no pruned subscriptions (410 errors never appear because the subscription is dead but not explicitly invalidated by APNs).
+
+### Pitfall 2: pushManager.subscribe() outside a user gesture fails silently on iOS
+**What goes wrong:** The subscribe call returns a rejected promise or does nothing. No error surfaced to the user.
+**Why it happens:** iOS requires pushManager.subscribe() to be invoked directly within a user tap event handler — not in a useEffect, not in a setTimeout, not after an await boundary. Any async hop breaks the user-gesture context.
+**How to avoid:** The "Enable Notifications" button onClick must call pushManager.subscribe() synchronously (before any awaits) or use the existing tap event reference. See D-08 and UI-SPEC surface 1.
+**Warning signs:** Subscribe works on Android/Chrome but silently fails on iOS.
+
+### Pitfall 3: vite-plugin-pwa injectManifest — missing workbox-precaching devDependency
+**What goes wrong:** Build fails with `Cannot find module 'workbox-precaching'` or the SW bundles without precache support.
+**Why it happens:** In generateSW mode, vite-plugin-pwa bundles Workbox internally. In injectManifest mode, the custom SW source is compiled by Vite — workbox-precaching must be an explicit devDependency.
+**How to avoid:** `pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core`
+**Warning signs:** TypeScript error in sw.ts on `import { precacheAndRoute }`.
+
+### Pitfall 4: /callback denylist lost after SW migration
+**What goes wrong:** After migrating to injectManifest, the OIDC /callback route is served from SW cache instead of reaching the server. This causes the login loop bug (T-03-20).
+**Why it happens:** The existing `workbox.navigateFallbackDenylist` in vite.config.ts only applies to the generateSW strategy. injectManifest does not read from `workbox:` key — the SW must implement the navigation denylist explicitly (via Workbox Router or a fetch event handler checking the URL).
+**How to avoid:** In sw.ts, add a fetch handler that falls through for /callback and /api/ requests. Or use workbox-routing NavigationRoute with denylist.
+**Warning signs:** After re-login, the app loops at /callback.
+
+### Pitfall 5: Duplicate reminder fires for events at the window boundary
+**What goes wrong:** A user gets two reminder pushes for the same event ~1 minute apart.
+**Why it happens:** The 1-minute cron runs at T=0 and T=1; an event at dtstart_utc=T+15 falls in both [T+14, T+16] windows.
+**How to avoid:** Track sent reminders. Simplest approach: a `sent_reminders` table with `(eventUid, reminderBucket CHAR(16))` where bucket = the UTC minute of the scheduled fire. Unique key on (eventUid, reminderBucket) prevents double-insert → skip dispatch on duplicate key.
+**Warning signs:** Members report receiving identical reminder notifications 1 minute apart.
+
+### Pitfall 6: calendarEvents has no title column — must parse rawVevent
+**What goes wrong:** Reminder copy shows the VEVENT UID instead of the event title (e.g. "abc123-def456-..." instead of "Dentist").
+**Why it happens:** The existing schema stores the VEVENT SUMMARY only in rawVevent (text blob), not as a dedicated indexed column. The scheduler query cannot SELECT a title.
+**How to avoid:** Add a `title` varchar column to calendar_events (populated during sync from ical.js SUMMARY). This is a new migration (0004) but avoids ical.js parsing on every reminder check. Alternatively, parse rawVevent with ical.js in the scheduler — correct but slower.
+**Recommended:** Add `title` column to calendar_events schema in Wave 0 (same migration as push_subscriptions, or a separate migration 0004).
+**Warning signs:** Notification titles are raw UIDs.
+
+### Pitfall 7: web-push ESM import — requires default import with @types/web-push
+**What goes wrong:** TypeScript error `Module '"web-push"' has no exported member 'sendNotification'` or runtime error on named import.
+**Why it happens:** web-push 3.6.7 ships CommonJS only. In an ESM project (apps/api `"type":"module"`), it must be imported as the default export: `import webpush from 'web-push'` (not named imports). @types/web-push provides the types for this pattern.
+**How to avoid:** Always use `import webpush from 'web-push'` (default import).
+**Warning signs:** TypeScript compiles but `webpush.setVapidDetails` is undefined at runtime.
+
+### Pitfall 8: VAPID public key must be served to the PWA as an environment variable
+**What goes wrong:** pushManager.subscribe() fails with "invalid applicationServerKey" if the PWA uses a hardcoded or stale key.
+**Why it happens:** The public key must match the private key used by web-push to sign notifications. If they are mismatched (e.g., key regenerated without updating the PWA build), the push service rejects.
+**How to avoid:** Expose VAPID_PUBLIC_KEY to the Vite build via `VITE_VAPID_PUBLIC_KEY` environment variable. Alternatively, add a GET /api/push/vapid-public-key endpoint (unauthenticated, public). The hook fetches it at subscribe time — this also allows key rotation without a rebuild.
+**Warning signs:** pushManager.subscribe() rejects with DOMException; existing subscriptions fail to send after key rotation.
+
+---
+
+## Code Examples
+
+### VAPID key generation (one-time CLI)
+
+```bash
+# Source: https://github.com/web-push-libs/web-push/blob/master/README.md
+npx web-push generate-vapid-keys --json
+# → {"publicKey":"B...","privateKey":"I..."}
+# Add to .env:
+# VAPID_PUBLIC_KEY=B...
+# VAPID_PRIVATE_KEY=I...
+```
+
+### Event payload format (Declarative Web Push + legacy SW compatible)
+
+```json
+// Source: https://webkit.org/blog/16535/meet-declarative-web-push/
+// Dual-format payload: "web_push":8030 enables iOS 18.4+ declarative path;
+// title/body/tag/data fields are read by the SW push handler for older iOS + Android.
+{
+ "web_push": 8030,
+ "notification": {
+ "title": "Dentist",
+ "body": "Starts in 15 min",
+ "navigate": "/calendar?date=2026-06-10&event=abc123"
+ },
+ "title": "Dentist",
+ "body": "Starts in 15 min",
+ "tag": "reminder-abc123",
+ "data": { "url": "/calendar?date=2026-06-10&event=abc123" }
+}
+```
+
+### SW navigateFallback preservation in injectManifest mode
+
+```typescript
+// Source: https://github.com/vite-pwa/vite-plugin-pwa/blob/main/docs/guide/inject-manifest.md
+// In sw.ts — replicate the existing navigateFallback + denylist behavior:
+import { NavigationRoute, registerRoute } from 'workbox-routing'
+import { createHandlerBoundToURL } from 'workbox-precaching'
+
+// Deny /callback, /api/*, /health from SW navigation handling (T-03-20)
+const navigationHandler = createHandlerBoundToURL('/index.html')
+const navigationRoute = new NavigationRoute(navigationHandler, {
+ denylist: [/^\/callback/, /^\/api\//, /^\/health/],
+})
+registerRoute(navigationRoute)
+```
+
+Alternative — add `workbox-routing` and `workbox-precaching` as devDependencies if this approach is used.
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Safari required separate APS certificate for push | VAPID (RFC 8292) — same as Chrome/Firefox | Safari 16+ (2022) | Single web-push flow works across all browsers |
+| Traditional Web Push required service worker JS always | Declarative Web Push — SW optional | iOS 18.4 / Safari 18.4 (April 2025) | Payload format change; SW handler can be simpler |
+| generateSW sufficient for most PWAs | injectManifest required when custom SW events needed | vite-plugin-pwa 0.12+ | Need to add workbox-precaching explicitly |
+| CRA for React PWAs | Vite + vite-plugin-pwa | 2023+ (CRA deprecated Feb 2025) | Already using correct stack |
+
+**Declarative Web Push (iOS 18.4+):**
+The dual-format payload approach (embedding both `"web_push":8030 + notification{}` AND the legacy `title/body/tag/data` fields in the same JSON body) is backward compatible and handles all iOS versions from 16.4+ through 18.4+ in a single payload. iOS 16.4–18.3 uses the SW push event path; iOS 18.4+ can use the declarative path as a fallback but still processes the SW push event if a SW is installed. [CITED: https://webkit.org/blog/16535/meet-declarative-web-push/]
+
+---
+
+## Codebase Ground-Truth (verified by reading source)
+
+These facts were confirmed by direct inspection and are the planner's authoritative source. [VERIFIED: codebase]
+
+### 1. Migration workflow confirmed
+- `drizzle.config.ts` uses dialect `mysql`, schema at `./src/db/schema.ts`, migrations out to `./src/db/migrations`.
+- Scripts: `db:generate` → `drizzle-kit generate`; `db:migrate` → `drizzle-kit migrate`; `db:push` exists but is UNSAFE per memory note.
+- Latest migration: `0002_yielding_mattie_franklin.sql` (adds utf8mb4_bin collation to list_items.rank).
+- Next migration will be numbered `0003_.sql`.
+- The `customType` pattern for special column types (e.g., utf8mb4_bin collation) is established in schema.ts and should be used again if needed.
+
+### 2. web-push NOT installed
+`apps/api/package.json` does not include `web-push`. Must be installed as part of Wave 0.
+
+### 3. workbox-precaching NOT installed in apps/pwa
+`apps/pwa/package.json` has `vite-plugin-pwa ^1.3.0` but neither `workbox-precaching` nor `workbox-core`. Must be installed as devDependencies.
+
+### 4. vite.config.ts is generateSW mode
+Confirmed: `VitePWA({ registerType: 'autoUpdate', workbox: { navigateFallback, navigateFallbackDenylist, runtimeCaching: [] } })`. Migration to `injectManifest` MUST:
+- Preserve the `navigateFallbackDenylist` entries: `/^\/callback/`, `/^\/api\//`, `/^\/health/`
+- Preserve `runtimeCaching: []` (no API caching)
+- Re-add `skipWaiting()` + `clientsClaim()` for autoUpdate
+
+### 5. listEmitter.ts publish points
+`publishListEvent(listId, event)` is called in the lists routes (confirmed by import chain). Push dispatch for NOTIF-02 hooks this same function. The coalescer wraps the dispatch, not the emitter itself.
+
+### 6. poller.ts calls syncCalendar on ctag change
+`runPoll()` calls `syncCalendar(client, davCal, cred.userId)` when ctag changes. This is where external event changes (other member's writes arriving at Fastmail) are detected. NOTIF-03 for external changes should hook here or inside `syncCalendar`.
+
+### 7. outboxWorker.ts calls triggerTargetedResync on success
+After a successful CalDAV write, `triggerTargetedResync` runs and calls `syncCalendar`. NOTIF-03 for this-member writes (notifying the OTHER member) should hook at the point where outboxWorker marks a row `done` and the re-sync detects the new/changed event.
+
+### 8. calendarEvents schema has NO title column
+`calendarEvents` columns: id, calendarId, uid, etag, objectUrl, rawVevent, dtstartUtc, dtstartDate, allDay, hasRrule, updatedAt. No `title` or `summary` column. The reminder scheduler and event-change dispatcher must either parse `rawVevent` or the schema must be extended (recommended: add `title varchar(500)`).
+
+### 9. index.ts startup pattern
+Background workers are started only inside the `isMainModule()` guard to prevent test contamination. The new `startReminderScheduler()` must follow this same pattern.
+
+### 10. API test pattern
+Route tests in `tests/routes/` use a real MariaDB connection with `vi.mock('../../src/auth/devBypass.js', ...)` to inject a user. The new `tests/routes/push.test.ts` should follow this pattern. Pure-unit tests (pushDispatcher, pushCoalescer) mock the DB. The `tests/broker/` directory holds worker tests with DB mocking.
+
+### 11. Test setup truncates list tables only
+`test/setup.ts` afterEach truncates `list_items`, `list_shares`, `lists`. When `push_subscriptions` is added, the setup must be updated to also truncate it.
+
+---
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | web-push 3.6.7 is CommonJS-only; ESM project must use default import `import webpush from 'web-push'` | Standard Stack, Pitfall 7 | Named import works at runtime → no impact; but TypeScript types may differ |
+| A2 | workbox-core version 7.x is compatible with workbox-precaching 7.4.1 | Standard Stack | Version mismatch causes runtime error in SW; verify with `npm view workbox-core version` |
+| A3 | iOS 18.4+ Declarative Web Push still processes the SW push event when a SW is installed | Code Examples, dual-format payload | If iOS 18.4+ skips the push event entirely when web_push:8030 is present, the dual-format approach is unnecessary; simpler — no impact on correctness |
+| A4 | workbox-routing and createHandlerBoundToURL are available in workbox-precaching@7.4.1 suite | Code Examples, navigateFallback | May need `workbox-routing` as a separate devDependency; check if it is a sub-package |
+| A5 | listEmitter.publishListEvent is called in the route handlers rather than a service layer | Codebase Ground-Truth | If called elsewhere, the coalescer attachment point changes |
+
+---
+
+## Open Questions
+
+1. **Does event-change detection require a new syncCalendar hook or a separate table diff?**
+ - What we know: `syncCalendar` does an `onDuplicateKeyUpdate` upsert but does not return which rows changed.
+ - What's unclear: To detect NOTIF-03 changes (new vs modified vs deleted event), the sync must compare old vs new state. The current sync has no "what changed" output.
+ - Recommendation: Add a `pushChangedEvents()` side-effect callback parameter to `syncCalendar` (or a post-sync query comparing `updatedAt` timestamps) that returns newly upserted/deleted events for push dispatch. Alternatively, use a DB trigger or a separate "last_seen_etag" comparison in the scheduler.
+
+2. **Should VAPID_PUBLIC_KEY be injected at build time (VITE_VAPID_PUBLIC_KEY) or fetched at runtime (GET /api/push/vapid-public-key)?**
+ - What we know: Build-time injection is simpler. Runtime fetch allows key rotation without rebuilds.
+ - What's unclear: How often VAPID keys will rotate in practice.
+ - Recommendation: Use GET /api/push/vapid-public-key endpoint (unauthenticated). Fetched once by usePushSubscription hook before subscribe. Cached in sessionStorage.
+
+3. **Reminder deduplication strategy: column flag vs separate table?**
+ - What we know: The 1-min cron window approach risks double-firing for events at the window boundary.
+ - What's unclear: Whether a `sent_reminders` table is overkill for a two-person household.
+ - Recommendation: Add a `sentAt` timestamp column to calendarEvents for reminder tracking, or use a simple in-memory Set of `${eventUid}:${minuteBucket}` pairs per process (acceptable for single-process deployment per D-12).
+
+---
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| node-cron | Reminder scheduler | Yes | ^4.2.1 | — (already installed in apps/api) |
+| MariaDB | push_subscriptions table | Yes | 11.x (Unraid) | — |
+| node 22 LTS | web-push (VAPID uses Web Crypto) | Yes | 22.x | — (web-push requires Node 18+) |
+| vite-plugin-pwa 1.3.x | injectManifest strategy | Yes | ^1.3.0 | — (already installed in apps/pwa) |
+| web-push | Push dispatch | No (not installed) | — | Must install: `pnpm --filter @familysync/api add web-push` |
+| workbox-precaching / workbox-core | Custom SW build | No (not installed) | — | Must install as devDependencies in apps/pwa |
+
+**Missing dependencies with no fallback:**
+- `web-push` in apps/api — blocks all push dispatch
+- `workbox-precaching` in apps/pwa — blocks SW injectManifest build
+
+---
+
+## Validation Architecture
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | Vitest ^4.1.8 |
+| Config file | apps/api/vitest.config.ts, apps/pwa/vitest.config.ts |
+| Quick run command | `pnpm --filter @familysync/api exec vitest run tests/routes/push.test.ts` |
+| Full suite command | `pnpm test` (root, runs API suite) |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| NOTIF-01 | Reminder fires for shared timed events ~15min before start | unit (reminderScheduler) | `pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts` | No — Wave 0 |
+| NOTIF-01 | All-day events do not get reminders (D-07) | unit | same file | No — Wave 0 |
+| NOTIF-01 | Non-shared events do not get reminders (D-05) | unit | same file | No — Wave 0 |
+| NOTIF-02 | List-change push fires for other member | unit (pushCoalescer) | `pnpm --filter @familysync/api exec vitest run tests/lib/pushCoalescer.test.ts` | No — Wave 0 |
+| NOTIF-02 | Coalescing collapses burst into one push (D-01) | unit | same file | No — Wave 0 |
+| NOTIF-02 | Reorder changes do not push (D-01) | unit (lists route) | existing `tests/routes/lists.test.ts` — extend | Partial |
+| NOTIF-03 | Event-change dispatch on new/updated event (NOTIF-03) | unit (eventChangeDispatcher) | `pnpm --filter @familysync/api exec vitest run tests/lib/eventChangeDispatcher.test.ts` | No — Wave 0 |
+| NOTIF-03 | Description-only change does NOT push (D-04) | unit | same file | No — Wave 0 |
+| D-11 | Push subscription POST/DELETE API | integration | `pnpm --filter @familysync/api exec vitest run tests/routes/push.test.ts` | No — Wave 0 |
+| D-11 | 410/404 from push service prunes subscription | unit (pushDispatcher) | `pnpm --filter @familysync/api exec vitest run tests/lib/pushDispatcher.test.ts` | No — Wave 0 |
+| D-08 | Permission prompt renders after install | PWA component (playwright-cli) | `playwright-cli evaluate "document.querySelector('[aria-label=\"Enable push notifications\"]')"` | No — Wave 0 |
+
+### Sampling Rate
+- **Per task commit:** `pnpm --filter @familysync/api exec vitest run` (unit tests; skip integration DB tests)
+- **Per wave merge:** `pnpm test` (full API suite) + playwright-cli smoke on permission prompt
+- **Phase gate:** Full suite green + human verify on iOS device (push delivery, Home Screen required)
+
+### Wave 0 Gaps
+- [ ] `tests/broker/reminderScheduler.test.ts` — NOTIF-01 unit tests
+- [ ] `tests/lib/pushCoalescer.test.ts` — NOTIF-02 coalescing unit tests
+- [ ] `tests/lib/pushDispatcher.test.ts` — 410/404 pruning unit tests
+- [ ] `tests/lib/eventChangeDispatcher.test.ts` — NOTIF-03 dispatch unit tests
+- [ ] `tests/routes/push.test.ts` — subscription POST/DELETE integration tests
+- [ ] `test/setup.ts` update — add `push_subscriptions` to afterEach truncation
+- [ ] `apps/pwa/src/sw.ts` — custom SW source file (required for injectManifest build)
+- [ ] Install: `pnpm --filter @familysync/api add web-push && pnpm --filter @familysync/api add -D @types/web-push`
+- [ ] Install: `pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core`
+
+---
+
+## Security Domain
+
+### Applicable ASVS Categories (Level 1)
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes | Push routes behind oidcAuthMiddleware; subscription belongs to authenticated user |
+| V3 Session Management | no | Push subscription is not session state |
+| V4 Access Control | yes | Subscription POST/DELETE scoped to c.get('user').id; push fan-out must not cross user boundaries |
+| V5 Input Validation | yes | zod validation on subscription body (endpoint string, p256dh, auth) |
+| V6 Cryptography | yes | web-push handles VAPID signing; NEVER hand-roll; VAPID_PRIVATE_KEY in env only |
+
+### Known Threat Patterns for this Stack
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| VAPID private key exposure | Information Disclosure | Store in .env; never in code; never in git |
+| Unauthorized push subscription (user A subscribes on behalf of user B) | Spoofing | Subscription POST always uses authenticated userId from OIDC session |
+| Push to wrong user's subscriptions | Tampering | fan-out queries filter by userId and list access (same as SSE scope check) |
+| Endpoint enumeration via POST /api/push/subscription | Information Disclosure | Endpoint is user-specific; server does not expose other users' endpoints |
+| Malformed subscription body causing crypto crash | Denial of Service | zod validation before DB insert; web-push errors caught per-subscription |
+| VAPID key in Docker image layers | Information Disclosure | Pass VAPID keys as environment variables at runtime (Docker Compose .env or secrets) |
+
+---
+
+## Project Constraints (from CLAUDE.md)
+
+- **MariaDB only** (no PostgreSQL). Drizzle ORM with mysql2 driver. All migrations via `db:generate` + `db:migrate`.
+- **`db:push` is UNSAFE** on populated MariaDB — never use it after data exists.
+- **Single Node process** — in-memory EventEmitter fan-out is correct; no Redis.
+- **Broker-only CalDAV I/O (D-13)** — notification code must not call tsdav.
+- **React PWA only** — no native app. Service worker runs in browser.
+- **iOS 16.4 minimum** — push requires Home Screen install; pushManager.subscribe() must be in a user gesture.
+- **Every push must show a visible notification** — no silent pushes (iOS revokes after ~3).
+- **web-push 3.6.7** is the designated VAPID library (CLAUDE.md stack table).
+- **node-cron already installed** in apps/api — no new scheduler library needed.
+- **playwright-cli** is available at `/usr/local/bin/playwright-cli` for browser-side verification.
+- **Hono** is the API framework — push routes follow same pattern as existing routers.
+- **`apps/api/src/db/schema.ts`** is the single source of truth for DB schema. New table goes here.
+
+---
+
+## Sources
+
+### Primary (MEDIUM confidence — Context7 from official docs)
+- `/web-push-libs/web-push` — VAPID key generation, sendNotification API, error codes (410/404), TypeScript usage
+- `/vite-pwa/vite-plugin-pwa` — injectManifest strategy, autoUpdate with custom SW, self.__WB_MANIFEST
+
+### Secondary (MEDIUM confidence — web search + official blog)
+- [https://webkit.org/blog/16535/meet-declarative-web-push/](https://webkit.org/blog/16535/meet-declarative-web-push/) — Declarative Web Push payload format, iOS 18.4+ availability
+- [https://webkit.org/blog/16574/webkit-features-in-safari-18-4/](https://webkit.org/blog/16574/webkit-features-in-safari-18-4/) — Safari 18.4 feature confirmation
+
+### Codebase (HIGH confidence — direct inspection)
+- `apps/api/src/db/schema.ts` — confirmed column list, customType pattern, existing table structure
+- `apps/api/src/lib/listEmitter.ts` — confirmed publishListEvent signature and call pattern
+- `apps/api/src/broker/poller.ts` + `outboxWorker.ts` — confirmed event change detection hooks
+- `apps/pwa/vite.config.ts` — confirmed generateSW mode, denylist, runtimeCaching
+- `apps/pwa/src/components/InstallPrompt.tsx` — confirmed isInstalled(), WalkthroughSheet pattern
+- `apps/api/package.json` / `apps/pwa/package.json` — confirmed web-push and workbox NOT installed
+- `apps/api/drizzle.config.ts` + migration journal — confirmed db:generate+migrate workflow
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack (web-push, workbox): MEDIUM — confirmed via npm registry + Context7 from GitHub README
+- Architecture: HIGH — based on direct codebase inspection; patterns derived from existing workers
+- Pitfalls: HIGH (iOS) — confirmed in CLAUDE.md and STATE.md; HIGH (DB migration) — confirmed in memory note; MEDIUM (others) — based on library docs
+- Service worker migration: MEDIUM — Context7 docs; runtime behavior on iOS needs human verification
+
+**Research date:** 2026-06-09
+**Valid until:** 2026-07-09 (30 days — libraries are stable)