docs(spec): notifications design (engine in IIOS + Web Push, per-thread mute)

Approved design: IIOS notification engine (PresenceService, NotificationProjector with
policy/presence/mute gates, swappable NotificationPort → Web Push VAPID, subscription +
participant.muted data) + host-app experience (service worker, registerPush, mute toggle,
deep-link). Trigger policy: DMs always, group only on @mention or reply-to-you. Phased:
frontend quick win → IIOS engine → SDK/app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 18:44:49 +05:30
parent e49a71feaf
commit ba2d5f4193
@@ -0,0 +1,156 @@
# 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: <vapid public> })` → 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.