# Notifications layer + Settings UI — design

audience: AI coding agents first. slug: `notifications-layer`
source request: owner 2026-08-15 — web UI for factory notifications with ALL available hooks togglable; Settings → Notifications → Factory; architecture must generalize ("we are going to be adding notifications layer to every module"); two adapters now: systray and Telegram (via botmaster); per-run/per-project overrides are a SEPARATE queued run (`notification-overrides`) — add the seams now, build later; Telegram channel-per-project config already exists in botmaster — USE that data, never duplicate it.

## Architecture

One event pipeline, one rules store, N emitting modules, 2 adapters.

```
module emitter → notify client (CLI/HTTP, fire-and-forget) → collector /notify
  → rules engine (notification_rules, scope-aware) → adapter fan-out
      → telegram adapter → botmaster notify module (channel resolution = botmaster's data)
      → systray adapter → existing systray notification surface
  → journal (every decision: delivered / suppressed-by-rule / adapter-failed)
```

- **Collector owns routing and settings** (it already owns the DB and serves the web UI). Emitters never talk to adapters directly — they emit events; rules decide delivery.
- **Fail-open end to end:** an emitter can never be blocked by the collector being down (client timeout ≤800ms, warn stderr); an adapter failure journals and never affects the other adapter.
- **Edge-triggered doctrine applies:** one notification per event occurrence; no cooldowns, no repeats. Per-channel rate floor (collapse same-module non-crash events within 30s) mirrors the botmaster review decision.

## Seams

**Event catalog** — how the UI knows "all available hooks" without hardcoding: each module ships a catalog entry (JSON registered with the collector at startup or checked in under `collector/src/notify/catalog/<module>.json`):
```
{ module: "factory", events: [
    { id: "factory.run.start",  label: "Run started",  severity: "info" },
    { id: "factory.run.finish", label: "Run finished", severity: "info" },
    { id: "factory.run.crash",  label: "Run crashed",  severity: "critical" } ] }
```
The just-built factory start/finish/crash notifications (parallel lane, factory-botmaster-notify) become the FIRST catalog module: its direct `botmaster` calls are refactored to emit through this pipeline in this run — one integration, not two systems. Severity `critical` maps to audible (Telegram `disable_notification: false`); `info` is silent — consistent with the botmaster duplex review.

**Rules store** (collector SQLite):
```
notification_rules(id, scope TEXT DEFAULT 'global', module, event_id, adapter, enabled, params JSON, updated_at)
```
`scope` is the override seam: `'global'` only honored in this run; `'project:<name>'` and `'run:<id>'` values are ACCEPTED by the schema and API but ignored by the engine until the `notification-overrides` run — resolution order (run > project > global) is specified there, not here. Defaults: factory events enabled on Telegram, crash also on systray.

**Notify client** — `od-notify <module> <event-id> [--json payload]` on PATH + an importable TS function for in-process emitters (controller/collector modules). Both hit `POST /notify/emit`.

**Adapters** (collector-side, small):
- `telegram`: calls the botmaster notify module's send path with the resolved channel. Channel resolution: botmaster's existing per-project channel config (resolve.ts / its store) — the adapter passes `project` and lets botmaster resolve; NO channel table in the collector. Params may name an explicit channel override per rule.
- `systray`: writes to the existing systray notification mechanism (discover: modules/systray's notification entry point; model after how current systray alerts arrive — never a parallel path).

**Settings UI** — `Settings → Notifications → Factory` in apps/web: a matrix (rows = catalog events, columns = adapters, cells = toggle) rendered from `GET /notify/catalog` + `GET /notify/rules?scope=global`; toggling writes `PUT /notify/rules`. The page is module-generic: `Factory` is the first tab; any module that ships a catalog entry appears automatically. Astryx primitives; owner language from catalog `label`s, never event ids on faces. A journal strip ("last 20 notifications, delivered/suppressed and why") makes routing debuggable from the UI.

## Error handling

Unknown event id emitted → journaled as `unknown-event`, delivered to nothing, visible in the journal strip (a module shipping events without catalog entries is a bug made visible, not silent). Rules store unreadable → engine fails to DEFAULTS (factory crash → Telegram) rather than silence — a routing outage must never eat a crash notice. Adapter send failure → journal + one retry, then drop with journal entry (no queues that replay hours-old noise).

## Testing

Rules engine (scope filtering incl. ignoring non-global scopes, defaults fallback, unknown event), adapter fan-out isolation (one adapter failing never blocks the other), catalog endpoint, UI matrix render + toggle round-trip (vitest), client fail-open when collector down. Live acceptance: toggle a factory event off in the UI → real factory run → Telegram shows only the enabled events; crash path delivers on both adapters.

## Architecture decisions

- Routing in the collector, not in each emitter — emitters stay one-line; rules change without touching modules.
- Botmaster owns Telegram channel data (owner directive: same data, never duped). Collector rules reference projects/channels by name only.
- Catalog as data (JSON per module), not code registration in the UI — the UI stays generic forever.
- Scope column shipped now but inert — the overrides run flips the engine, not the schema (no migration later).
- No new daemon; pipeline lives in the collector process.
