Spec: Session notifications (daemon side)

Purpose

The daemon is the source of truth for session state. When a session needs the operator — a checkpoint, an ask, an approval gate, or simply "run complete" — the daemon must surface that signal through the right channel, at the right loudness, without double-notifying an operator who is already looking at the session.

This spec defines the daemon half of the session notification pipeline:

  • Normalising the existing events channel emissions into two notification event classes.
  • A presence gate that decides between in-app delivery (live WS) and tier-3 fan-out (plugin channels + optional Web Push).
  • A channel contract that plugin notification services implement.
  • The Web Push bridge — VAPID keypair management, push subscription storage, the push event handler.
  • The settings resolution for routing decisions, with set-not-merge override semantics.

The UI half (tier 1 vs tier 2, bell, toast, SW showNotification) lives in ui/notifications.md. The plugin channel specs (ntfy, pushover) live in plugins/. Web Push is in this spec because the daemon owns the private key, subscription store, and the push dispatch.

This spec is not normative for:

  • The transport format of events channel frames — see http-api.md § WebSocket protocol.
  • The DSL of an approval workflow primitive (separate roadmap item); attention.required will emit from it once it lands.
  • The UI's tier-1 vs tier-2 selection — see ui/notifications.md.

Constraints (from ADRs)

Constraint Source
Two notification event classes: attention.required, run.completed ADR-0047 §1
Tier-3 fires only when the operator has zero live events subscriptions ADR-0047 §2
Plugin-push is the primary tier-3 mechanism; Web Push is optional,rides ADR-0046's SW under §6 constraints ADR-0047 §4, §6
Settings precedence: system default → project.local.yaml → per-session bell; overrides are set-not-merge ADR-0047 §5, ADR-0015
Storage default is SQLite; storage helpers are scoped per-project ADR-0005
Web Push: VAPID keypair is daemon-held; one PushSubscription per SW; userVisibleOnly: true mandatory; permission only from explicit operator gesture; no blind approve/reject from notification body ADR-0047 §6

Event model

Event classes

Class Triggered by Actionable? Persistent?
attention.required checkpoint, ask, approval gate yes — operator must respond yes — surfaces in the bell until cleared
run.completed terminal state of a run (run.ended with outcome{"success", "failed", "cancelled"}) no — informational no — toast/push only; not retained

Both event classes carry the same envelope (see Payload below). The class discriminator is the only field the router uses to look up routing rules; everything else is opaque payload that channels render verbatim.

Payload

interface NotificationEvent {
  class: "attention.required" | "run.completed";
  session_id: string;
  project_id: string;
  run_id: string;
  summary: string;          // short human label, e.g. "Session "music-site" is asking a question"
  deep_link: string;        // absolute path the UI/client should navigate to
  emitted_at: number;       // millisecond epoch
  // Optional, class-specific context the channel may render:
  attention_kind?: "checkpoint" | "ask" | "approval_gate";
  run_outcome?: "success" | "failed" | "cancelled";
}

deep_link is always of the form /projects/<slug>/sessions/<sid> optionally followed by ?run=<run_id> or ?attention=<attention_kind>. Channels MUST NOT fabricate deep links; they relay the daemon-provided URL.

Wire: events channel

The two classes ride the existing events WebSocket channel (http-api.md § WebSocket protocol) as new type values on the existing frame envelope:

type payload
attention.required NotificationEvent (above) with class: "attention.required"
run.completed NotificationEvent (above) with class: "run.completed"

The legacy run.ended frame continues to be emitted with its current { run_id, outcome } payload. run.completed is a strict superset (the notification envelope) emitted alongside it. The router, not the runner, emits run.completed. This keeps the existing telemetry stream untouched while giving the notification pipeline its own normalised event.

attention.required is emitted by the runner at the same call site as interaction.requested / compaction.checkpoint. See Emission points.

The wire schema for these types lives in @kaged/wire and is normative.


Architecture

                       ┌──────────────────────────────┐
   primary-runner ───▶ │  NotificationEvent normaliser│ ──┐
   (checkpoint/ask/    │  (per-call, no state)        │   │
   approval/run-end)   └──────────────────────────────┘   │
                                                            ▼
                                          ┌────────────────────────────┐
                                          │  NotificationRouter        │
                                          │  • resolves routing rules  │
                                          │  • presence gate           │
                                          │  • fans out to channels    │
                                          └────────────────────────────┘
                                              │           │           │
                          ┌───────────────────┘           │           └────────────────────┐
                          ▼                               ▼                                ▼
              ┌───────────────────────┐     ┌───────────────────────────┐    ┌──────────────────────────┐
              │ in-app channel        │     │ web-push channel          │    │ plugin channels          │
              │ (events WS publish)   │     │ (VAPID-signed push)       │    │ (notification.dispatch   │
              │                       │     │                           │    │  system-plugin hook)     │
              │ always fires when     │     │ only at tier-3, only if   │    │ ntfy, pushover, etc.     │
              │ ≥1 live subscriber    │     │ subscription on file      │    │ only at tier-3           │
              └───────────────────────┘     └───────────────────────────┘    └──────────────────────────┘

The router is the single orchestrator. It owns:

  1. Resolving the routing table for (operator, project, session, event_class).
  2. Querying the WS registry for the operator's live events subscription count.
  3. Emitting the events-channel frame when ≥1 live connection exists.
  4. Fanning out to plugin channels and the web-push channel when zero live connections exist.

The router does not decide tier-1 vs tier-2. That is the UI's job, via Page Visibility (see ui/notifications.md).


Presence gate

The presence gate is the only thing standing between "operator saw it inline" and "operator's phone buzzed." It MUST be correct.

Definition

For a given operator identity (resolved per ADR-0036):

  • Tier-1/2 (live) — at least one events channel subscription held by any WS connection authenticated as that operator. Cross-session: an operator viewing project A's session counts as live for project B's run.completed on a different session. The UI's tier-1-vs-2 split is per-session; the daemon's live-vs-tier-3 split is per-operator.
  • Tier-3 (away) — zero live events subscriptions for that operator.

Implementation

The router queries the WS registry:

interface PresenceRegistry {
  /** Number of open `events` channel subscriptions for this operator. */
  liveEventsCount(operatorId: string): number;
}

The existing packages/daemon/src/runtime/ws-registry.ts tracks subscribers per session. The router needs the per-operator roll-up. The system-wide registry (system-ws-registry.ts) already maintains a flat Set of sockets; both feed into the same presence query.

⚠️ v1 is per-operator, not per-device. If the operator has a desktop open and a phone closed, the phone does not get a tier-3 push because the desktop counts as presence. Per-device refinement is deferred (see Out of scope). Documented in ADR-0047 §"Makes harder".

Decision matrix

Live count Routing decision
≥ 1 Publish on events channel. Skip plugin channels and Web Push.
0 Skip events publish. Fan out to every enabled tier-3 channel: plugin channels (always) and Web Push (if subscription on file).

If events publish fails (socket disconnect mid-dispatch), the router does not retry as tier-3. The next event will hit the gate afresh.


Channel contract

Every channel — built-in or plugin — implements the same contract:

interface NotificationChannel {
  /** Stable channel id. Lowercase, kebab-case. Examples: "in-app", "web-push", "ntfy", "pushover". */
  readonly id: string;

  /**
   * Human-readable label, shown in the UI's notification settings panel.
   * The label travels via /api/v1/me; it does NOT travel in the event payload.
   */
  readonly label: string;

  /**
   * Delivers a notification. MUST NOT throw on delivery failure — log and return
   * a DeliveryOutcome so the router can audit the result. The router awaits all
   * channels in parallel and does not let one slow channel block another.
   *
   * Implementation MAY apply its own backoff/retry internally; the router
   * treats the call as a single dispatch.
   */
  send(notification: NotificationEvent, context: ChannelContext): Promise<DeliveryOutcome>;
}

interface ChannelContext {
  operatorId: string;
  /** Resolved routing config (after set-not-merge) for this channel + event class. */
  config: Record<string, unknown>;
}

type DeliveryOutcome =
  | { status: "delivered"; external_id?: string }
  | { status: "failed"; reason: string; retryable: boolean };

Channels are registered at daemon startup:

  • Built-in in-app channel — always registered.
  • Built-in web-push channel — registered iff VAPID configuration resolves (see Web Push).
  • Plugin channels — registered via the notification.channel.register system-plugin hook (see Plugin channels).

Unregistered channels referenced in routing config are a startup warning, not a startup error. The daemon continues; routing entries pointing at unknown channels are skipped with an audit log entry.


Built-in in-app channel

Always registered. Wraps publishSessionEvent (or the system-wide equivalent for cross-session notifications).

Behavior:

  1. Receives the NotificationEvent.
  2. Decides which session(s) to publish to:
    • For attention.required — publish to the session identified by session_id. Operators viewing that session receive it (tier-1 path).
    • For run.completed — publish to the system-wide events channel; the UI's bell store consumes it and routes by session_id.
  3. The frame type is the class (attention.required or run.completed); the frame payload is the full envelope.

in-app always returns { status: "delivered" }. It cannot fail in a meaningful way — if no socket receives it, that is a presence-gate concern, not a delivery concern.


Built-in web-push channel (optional)

Registered iff VAPID configuration resolves (see VAPID). When absent, the channel is not registered and routing entries referencing it are skipped with a warning (same as any unknown channel).

Dispatch

  1. Look up all PushSubscription rows for the operator in SQLite (Storage).
  2. For each subscription, build a Web Push request per RFC 8030 + RFC 8291:
    • Body: JSON-encoded NotificationEvent (the same payload the in-app channel publishes; the SW decodes and renders).
    • TTL: 24 hours for attention.required, 1 hour for run.completed. Attention is persistent; completion is not.
    • Urgency: high for attention.required, normal for run.completed (RFC 8030 §5.3).
    • Topic: kaged:<project_id>:<session_id> so a later event replaces an unread one of the same class on the same session. Reduces notification spam if a checkpoint is auto-resolved and then re-raised.
  3. Sign with the VAPID private key.
  4. POST to each subscription's endpoint. 404/410 → subscription is gone; delete the row, continue. 429 → respect Retry-After if present, otherwise exponential backoff up to 3 attempts.
  5. Return aggregated DeliveryOutcome. If any subscription delivered, the channel reports delivered. If all failed with retryable errors, report failed, retryable: true. If all failed with non-retryable (4xx other than 429), report failed, retryable: false and audit-log.

⚠️ userVisibleOnly: true is mandatory. Every push MUST produce a visible notification (see ADR-0047 §6). The daemon does not send "silent" pushes for state sync. If a future feature needs silent push, that's a separate ADR.

⚠️ No approve/reject from the notification body. The push payload carries the deep link only; the SW's notificationclick focuses/opens the session at the deep link. The actual decision happens in-app with full context. See ui/notifications.md § notificationclick.

VAPID

The daemon holds a single VAPID keypair per installation:

Source Precedence
[notifications.web_push.vapid_private_key_pem] in local.toml highest — operator-provided keypair
KAGED_VAPID_PRIVATE_KEY_PEM env var middle — useful for k8s secrets
${KAGED_HOME}/vapid.pem (auto-generated on first need) lowest — file is mode 0600, owned by the daemon user

Resolution happens at startup. On successful resolution, the daemon exposes the public key via GET /api/v1/notifications/vapid-public-key (see http-api.md) and registers the web-push channel. On failure, the channel is not registered and the endpoint returns 503.

The subject is mailto:<operator_email> if [ui]/[notifications.web_push] provides one, otherwise mailto:noreply@kaged.local. The subject is informational — push services use it for abuse reports — and never appears in user-facing UI.

⚠️ Changing the VAPID key invalidates all existing subscriptions (see ADR-0047 §6). The daemon does not auto-rotate. Rotation procedure:

  1. Operator generates a new keypair, swaps it into config.
  2. Daemon restarts; existing subscriptions 410 on first push; daemon deletes the rows.
  3. Clients re-subscribe on next session (the UI's subscription flow runs on focus when permission is granted but no subscription is on file).

Multi-daemon Web Push topology (shared VAPID across daemons, or a dedicated push relay) is out of scope; see Out of scope.

Storage

Push subscriptions live in SQLite, one row per (operator_id, endpoint, p256dh):

CREATE TABLE push_subscriptions (
  id            TEXT PRIMARY KEY,            -- random id
  operator_id   TEXT NOT NULL,
  endpoint      TEXT NOT NULL,               -- full push service URL
  expiration_ms INTEGER,                     -- epoch ms or NULL (never expires)
  p256dh        TEXT NOT NULL,               -- base64url ECDH public key
  auth          TEXT NOT NULL,               -- base64url auth secret
  created_ms    INTEGER NOT NULL,
  user_agent    TEXT,                        -- advisory, from subscribe request
  UNIQUE (operator_id, endpoint, p256dh)
);

CREATE INDEX push_subscriptions_by_operator ON push_subscriptions (operator_id);

Per ADR-0005, storage helpers are scoped per-project. Push subscriptions are not per-project — they're a per-operator property — so they live in the daemon-wide storage (the same DB that holds users, audit log, sessions metadata). The helpers are in packages/storage and the daemon-wide migration ships with this spec.

The daemon never exposes the auth or p256dh values back to any client after subscription. GET /api/v1/notifications/subscriptions returns only { id, endpoint, user_agent, created_ms } — enough for the UI to show "your devices" and allow deletion.

Permission model

  • The daemon NEVER requests permission. The browser does, in response to an explicit operator gesture in the UI (ui/notifications.md § Web Push subscription flow).
  • The UI POSTs the resulting PushSubscription JSON to POST /api/v1/notifications/subscriptions (see http-api.md).
  • The daemon stores it without interpretation. The daemon does not validate browser-side state — if the operator revokes permission at the browser level, the next push returns 410 and the daemon reaps the row.

Plugin channels

Plugin channels are system plugins that register a notification.channel.register hook in addition to the standard DaemonHooks. The hook hands the plugin a NotificationChannel adapter that the daemon's router will call:

// Extension to @kaged/plugin-types DaemonHooks
interface DaemonHooks {
  // ...existing hooks...

  /**
   * Fires at daemon startup once the notification router is ready.
   * The plugin registers its channel(s) by calling registrar.register(channel).
   * Hooks fire in plugin-load order. Registration is idempotent on channel.id
   * (later registration with the same id replaces the earlier — useful for hot reload).
   */
  "notification.channel.register": (registrar: NotificationChannelRegistrar) => void | Promise<void>;
}

interface NotificationChannelRegistrar {
  register(channel: NotificationChannel): void;
  unregister(channelId: string): void;
}

The hook fires after daemon.ready and after the router is constructed. Plugins that come up after the router (lazy-loaded) get the hook fired on their successful setup().

The daemon treats plugin channels the same as built-ins:

  • They appear in GET /api/v1/notifications/channels.
  • They receive send() calls when tier-3 dispatch fires.
  • Their DeliveryOutcome is audited.
  • A plugin channel that throws synchronously is treated as { status: "failed", reason: "threw", retryable: false } and the router continues.

The reference plugin channels (ntfy, pushover) are documented in plugins/ntfy.md and plugins/pushover.md.


Routing resolution

Routing table

A routing table is a Record<event_class, Record<channel_id, ChannelConfig>> plus per-channel enable/disable bits. Defaults:

[notifications.routing.attention_required]
in-app    = { enabled = true }   # always on; cannot be disabled
ntfy      = {}
web-push  = {}

[notifications.routing.run_completed]
in-app    = { enabled = true }   # always on; cannot be disabled
ntfy      = {}

A channel is eligible for an event class iff its entry exists with enabled != false. Channels that have no entry are not eligible. This means the default routing above produces:

  • attention.requiredin-app always; ntfy, web-push only if those channels are registered (otherwise skipped with a warning).
  • run.completedin-app always; ntfy if registered.

Resolution layers (set-not-merge)

Per ADR-0047 §5 overrides are set-not-merge — a session override fully replaces the resolved routing for that session. This mirrors the existing federation rule (ADR-0015).

Resolution order (later layer wins outright):

  1. System default[notifications.routing.*] in local.toml. Always present (daemon ships defaults).
  2. Project override[notifications.routing.*] in <project>/.kaged/project.local.yaml. If present, fully replaces the system default routing for that project.
  3. Session override — the per-session bell value set via PUT /api/v1/projects/:slug/sessions/:sid/notification-bell. If present, fully replaces the project-resolved routing for that session.

The resolved table is what the router consults at dispatch time. The resolver is a pure function of (systemDefault, projectOverride, sessionOverride); it does not consult the channel registry. Unknown channel ids in the resolved table are simply skipped at dispatch.

Per-session bell values

The bell on the session UI is not a free-form field — it's an enum that maps to a resolved routing table:

Bell value Meaning Effect
"default" (omitted) No session override; inherit project routing.
"all" All channels Resolves to system-default routing. Equivalent to "default" but explicit.
"attention-only" Attention events only Resolves to routing where run.completed is empty (only attention.required propagates).
"silent" Nothing Resolves to empty routing. No notifications at all — including in-app tier-3 dispatch. The UI still renders inline state when the session is focused.

Setting the bell is a PUT with { bell: "default" | "all" | "attention-only" | "silent" }. The value is stored on the session row in sessions table and surfaced via GET /api/v1/projects/:slug/sessions/:sid and the system-wide session list.

⚠️ "silent" does NOT prevent in-app delivery when the operator is live — it suppresses tier-3 only. The operator always sees attention events for a session they're actively viewing. To suppress all signal for a session, mute it in the UI (a separate, UI-only concern).


Emission points

The runner emits attention.required and run.completed at the same call sites as the existing telemetry events. The runner does not call the router directly — it calls the normaliser, which fans out to both the existing telemetry publish and the router.

Existing emission Notification event Trigger
interaction.requested (primary-runner) attention.required (attention_kind: "ask" | "approval_gate") kind field in the existing payload maps through
compaction.checkpoint (primary-runner) attention.required (attention_kind: "checkpoint") compaction-initiated checkpoint
run.ended outcome success/failed/cancelled (primary-runner) run.completed (run_outcome: <outcome>) terminal state

⚠️ The run.ended emission continues unchanged. The router emits run.completed alongside it. The two events have different consumers (telemetry vs notifications) and must not collapse into one — collapsing would force every consumer of run.ended to also handle the notification envelope.

The summary and deep_link fields are constructed from the project registry + session metadata. The runner does not invent strings.


Settings schema

local.toml

[notifications]

[notifications.routing.attention_required]
# in-app is always enabled and cannot be disabled — entry shown for documentation only.
in-app    = { enabled = true }
ntfy      = {}                       # eligible iff ntfy plugin channel is registered
web-push  = {}                       # eligible iff web-push channel is registered

[notifications.routing.run_completed]
in-app    = { enabled = true }
ntfy      = {}                       # eligible iff ntfy plugin channel is registered
# web-push omitted by default — run completion is informational, not push-worthy

[notifications.web_push]
# subject = "mailto:operator@example.com"   # optional; default mailto:noreply@kaged.local
# vapid_private_key_pem = """-----BEGIN PRIVATE KEY-----
# ...
# -----END PRIVATE KEY-----"""

project.local.yaml

# Fully replaces the system-default routing for this project.
notifications:
  routing:
    attention_required:
      ntfy: { tag: "music-site" }     # passes per-project tag to the ntfy channel
      pushover: { priority: 1 }
    run_completed:
      ntfy: {}

⚠️ The presence of notifications: in project.local.yaml triggers set-not-merge — the system-default routing is not consulted for this project. Operators who want to add a channel at the project layer must redeclare the full set they want active. This matches ADR-0015 and is documented in federated-config.md.

Per-session bell

PUT /api/v1/projects/:slug/sessions/:sid/notification-bell
Content-Type: application/json

{ "bell": "silent" }

See http-api.md.


Audit events

The router emits audit events for every tier-3 dispatch:

Event When Carries
notification.dispatched A tier-3 channel returned { status: "delivered" } operator_id, channel_id, event_class, session_id
notification.failed A tier-3 channel returned { status: "failed" } operator_id, channel_id, event_class, session_id, reason, retryable
notification.subscription.added Operator POSTed a new push subscription operator_id, user_agent
notification.subscription.removed Push subscription deleted (410, operator DELETE, or expiration) operator_id, endpoint_hash
notification.vapid.rotated VAPID key changed at startup (subtle: only fires when the file/env value differs from the previous boot) operator_id (system), subject

These ride the existing audit log; no new transport.


Security

  • VAPID private key is the crown jewel. Stored at ${KAGED_HOME}/vapid.pem mode 0600 owned by the daemon user. Never returned by any endpoint. Audit log records every read.
  • Push subscription auth/p256dh secrets are never surfaced post-Subscribe. The daemon uses them only to encrypt outgoing pushes.
  • userVisibleOnly: true is enforced: the daemon refuses to register a subscription that lacks it. Browsers set the flag automatically when permission is granted; the daemon double-checks on POST.
  • No blind action from notification body. Push payloads carry the deep link only; the SW's notificationclick opens the session, never approves/rejects.
  • Rate limiting. A misbehaving session that fires 100 attention.required events in 60 seconds is rate-limited: the router coalesces identical (session_id, attention_kind) events within a 5-second window into one notification per channel. Audit logs the coalescing.

CLI surface

Notification CLI is minimal — settings live in local.toml and project.local.yaml. The daemon ships:

  • kaged notifications channels — list registered channels (built-in + plugin).
  • kaged notifications subscriptions — list push subscriptions for the current operator.
  • kaged notifications test --class attention.required --channel ntfy — emit a synthetic event for end-to-end verification.

No CLI for editing routing rules — that's local.toml/project.local.yaml territory, edited by hand or via the UI.


Testing notes

The router is the highest-risk component. Test surface:

  • Event normaliser — every emission point produces the correct NotificationEvent. Test the pure function, not the runner wiring.
  • Presence gate(operatorId, liveCount) → tier. Cover the boundary (0 → tier-3, 1 → in-app, 2 → in-app).
  • Routing resolution — three-layer precedence with set-not-merge. Cover every bell value. Cover absent layers.
  • Channel dispatchsend() is called on every eligible channel, never on ineligible ones. Cover unknown-channel skip.
  • Web Push signing — known-vector test against RFC 8291 test vectors. Don't test against a live push service.
  • Web Push dispatch — 200/4xx/5xx handling, subscription reaping on 410, Retry-After respect.
  • Plugin channel hook — register/unregister lifecycle, hot-reload idempotence.
  • Rate limiting — 100 events/60s coalesces to ≤20 dispatches per channel.

Open questions

  1. Per-device presence. v1 uses per-operator presence: tier-3 is suppressed if any client is connected, which can miss a closed phone while a desktop is open. Multi-device refinement deferred per ADR-0047 §"Makes harder".

  2. Multi-daemon Web Push topology. One VAPID identity per daemon; multiple daemons ⇒ multiple subscriptions ⇒ "which daemon is buzzing me?" UX problem. Shared VAPID vs dedicated push relay — open. Tracked in ADR-0047 §"Out of scope".

  3. Approval-gate primitive in the workflow DSL. When it lands, it emits attention.required from a fourth call site. The router is agnostic to the source; the new emission point just needs to call the normaliser. Tracked separately.

  4. Notification reply actions. RFC 8030 supports topic + urgency + reply actions; we use topic + urgency only today. "Quick reply" (typing a response inline in the OS notification) is conceivable but conflicts with the no-blind-action rule. Deferred.


Amendments

(none yet)


References