# Notifications — Design Spec > Push/desktop notifications for the IIOS chat app (and, by reuse, any host app on IIOS). > Engine in IIOS (generic, governed, reusable); experience in the host app. Spans two > repos: **iios** (engine) and **chat-web** (SDK + app). **Status:** approved design (2026-07-09) · next step: implementation plan. --- ## 1. Goal Deliver notifications to a user **when the conversation isn't in front of them** — including when the app/tab is **closed** (Web Push) — without notifying them about the chat they're actively viewing. Build the reusable **notification engine** in IIOS so every app on the platform (chat, support SDK, …) inherits it. ## 2. Locked decisions | Decision | Choice | |---|---| | **Scope** | The IIOS **engine + Web Push** (works when the app is closed), phased. | | **Trigger policy** | **DMs always** push; **group** messages push **only on @mention** (or a reply to you). Otherwise: unread badge + inbox only. Never for the thread you're actively viewing. | | **Preferences (v1)** | **Per-thread mute** only. A muted thread sends no push (badge still updates). Quiet hours / global mute / per-level are deferred. | | **Delivery** | A swappable **NotificationPort**; the v1 adapter is **Web Push (VAPID)**. Email/FCM are later adapters. | | **Ownership** | Engine (decide/deliver/store) = IIOS. Experience (render/tap/service-worker) = host app/SDK. | ## 3. Architecture *Engine in IIOS, experience in the host app.* A notification is generic — "inform an actor about an interaction" — so **no chat vocabulary enters the kernel**; DM-vs-group stays the opaque `membership` thread attribute that **policy** reads (consistent with reactions, mentions, media). ``` message.sent (outbox event) │ ▼ NotificationProjector ── gate 1: policy (DM? or in a group: @mentioned OR a reply to you?) │ ── gate 2: presence (connected AND viewing that thread? → skip) │ ── gate 3: mute (thread muted for this actor? → skip) ▼ NotificationPort.deliver(subscription, payload) ← Web Push (VAPID) adapter ▼ browser push service → service worker → OS notification → tap → open thread ``` ## 4. Components (IIOS engine) ### 4.1 PresenceService The `/message` gateway already tracks connected sockets and the thread each has joined (`open_thread` = socket.io room join). PresenceService answers **"is actor A currently viewing thread T?"**. Backed by Redis (reuses the existing socket.io Redis adapter) so it holds across replicas. On disconnect, presence is cleared. ### 4.2 NotificationProjector Reacts to `message.sent` (same idempotent-consumer pattern as `InboxProjector`; it may live alongside it). For each recipient participant (not the sender), runs the three gates in §3, then dispatches to each of the actor's active subscriptions. Idempotent per event id. **Gate 1 (policy) detail:** DM (`membership='dm'`) → eligible. Group → eligible only if the recipient is in the message's `mentions[]` **or** the message's `parentInteractionId` points to a message that recipient authored (a reply to them). Otherwise → not eligible (badge/inbox only). ### 4.3 NotificationPort + WebPushDelivery `interface NotificationPort { deliver(sub, payload): Promise<'sent'|'gone'|'failed'> }`. `WebPushDelivery` signs with VAPID and POSTs to the subscription endpoint. A `'gone'` (HTTP 404/410) result → the projector **prunes** that subscription row. Swappable to email/FCM later without touching the projector. ### 4.4 Payload Built from **generic** fields: `{ title: senderName, body: text snippet (or "sent an attachment"), tag: threadId, data: { threadId, interactionId } }`. No chat-specific logic in the kernel; `data.threadId` drives the app's deep-link. ## 5. Data (IIOS Postgres, tenant-scoped) - **`IiosNotificationSubscription`** (new) — `{ id, scopeId, actorId, kind: 'webpush', endpoint (unique), p256dh, auth, userAgent?, createdAt, lastSeenAt }`. `@@index([actorId])`. - **Mute** — a `muted Boolean @default(false)` column on **`IiosThreadParticipant`** (per actor+thread; already the join row). - **Notification record / feed** — **reuse `IiosInboxItem`** (already the feed; unread = OPEN, read = DONE). No new "notifications" table. - **VAPID keys** — env only (`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`), generated once; private key is a secret, public key is served to clients. ## 6. Endpoints (IIOS) | Method | Path | Body | Purpose | |---|---|---|---| | GET | `/v1/notifications/vapid-public-key` | — | public key the client needs to subscribe | | POST | `/v1/notifications/subscribe` | `{kind, endpoint, keys:{p256dh, auth}, userAgent?}` | store/refresh a push subscription for the caller | | DELETE | `/v1/notifications/subscribe` | `{endpoint}` | remove a subscription | | POST | `/v1/threads/:id/mute` / `/unmute` | — | set/clear the caller's `muted` flag on the thread | All Bearer-authed and tenant-scoped (subscriptions/mutes belong to the caller's actor+scope). ## 7. SDK + app (host experience) ### 7.1 SDK (chat-web `lib/notifications.ts`; belongs in `@insignia/iios-kernel-client`) - `registerPush()` — request `Notification` permission → register the service worker → `PushManager.subscribe({ applicationServerKey: })` → POST the subscription. - `unregisterPush()`, `mute(threadId)` / `unmute(threadId)`. - **`sw.js`** (service worker) — `push` event → `showNotification(title, {body, tag, data})`; `notificationclick` → focus an existing tab or open the app, deep-linked to `data.threadId`. ### 7.2 App (chat-web) - **Phase 1 (no backend):** on socket `message` while `document.hidden` **and** not viewing that thread → `new Notification(...)`; tab-title `(N)` unread from the conversation list; optional sound. Presence-gated client-side. - **Phase 3:** an "Enable notifications" prompt (calls `registerPush`), a 🔕 **mute** toggle in the conversation header, and deep-link handling when a notification is tapped. ## 8. Error handling - **Presence unknown** (Redis blip) → treat as **absent** (better to notify than silently miss). Mute + policy are still enforced. - **Mute** is **fail-closed** — if the mute read fails, do **not** send (respect the intent). - **Dead subscription** (web-push 404/410) → delete the row; a re-subscribe re-adds it. - **VAPID unset** → the engine logs "push disabled" and no-ops; the app is unaffected (Phase-1 desktop notifications still work). - Delivery is best-effort/at-least-once; the client de-dupes visible notifications by `tag`. ## 9. Testing - **Unit (projector):** DM→deliver · group-no-mention→skip · group-@mention→deliver · present-in-thread→skip · muted→skip · sender-never-notified. - **Unit (adapter/store):** subscription CRUD; prune on 410; WebPushDelivery with a mocked transport. - **Unit (presence):** connected+viewing vs connected-elsewhere vs disconnected. - **e2e:** subscribe → send while the recipient is absent → delivery invoked; send while the recipient is viewing the thread → not invoked; mute → not invoked. ## 10. Generic-safety The projector consumes generic `message.sent` events + the opaque `mentions[]` list; DM-vs- group is read from the opaque `membership` attribute (in policy, not kernel branching). The payload is built from generic fields. Grep must show no `'dm'`/`'group'` literals in the notification engine — same guardrail as the rest of IIOS. ## 11. Phasing (drives the plan) 1. **Phase 1 — frontend quick win (chat-web only):** desktop notification while unfocused + tab-title unread + client-side presence gate. Demoable immediately, zero backend. 2. **Phase 2 — IIOS engine (TDD):** PresenceService · `IiosNotificationSubscription` + `participant.muted` migration · endpoints · NotificationPort + WebPushDelivery · NotificationProjector (3 gates + prune). Full suite green. 3. **Phase 3 — SDK + app:** `sw.js` + `registerPush` + enable-prompt · mute toggle · deep-link on click. e2e verified. ## 12. Out of scope (deferred) Quiet hours · global mute · per-thread level (all/mentions/none) · email + mobile (FCM/APNs) adapters · notification bell/feed UI beyond the existing Inbox · rich/actionable notifications (reply-from-notification) · digest/batching.