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>
8.2 KiB
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 onIiosThreadParticipant(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()— requestNotificationpermission → register the service worker →PushManager.subscribe({ applicationServerKey: <vapid public> })→ POST the subscription.unregisterPush(),mute(threadId)/unmute(threadId).sw.js(service worker) —pushevent →showNotification(title, {body, tag, data});notificationclick→ focus an existing tab or open the app, deep-linked todata.threadId.
7.2 App (chat-web)
- Phase 1 (no backend): on socket
messagewhiledocument.hiddenand 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)
- Phase 1 — frontend quick win (chat-web only): desktop notification while unfocused + tab-title unread + client-side presence gate. Demoable immediately, zero backend.
- Phase 2 — IIOS engine (TDD): PresenceService ·
IiosNotificationSubscription+participant.mutedmigration · endpoints · NotificationPort + WebPushDelivery · NotificationProjector (3 gates + prune). Full suite green. - 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.