Design: Notification Sinks and Queue Digests
Context
SPEC-0029 lets an owner be told about dead letters, quarantine, relay exhaustion, rule matches and queue health through Gotify and Apprise. What it builds on:
- Dead-letter paths already exist in
internal/store/todos.go:FailTodo(andFailTodoOperatorOwned) at the attempt cap,ReapExpiredat the cap, anddeadLetterEndpointTodosin the revocation cascade. Each runs in a transaction, which is where the outbox row is written. - Routing actions are
routing.Action(internal/routing/routing.go):queue,drop,endpoints,exclusive,once,work_order.notifyis one more field, validated like the others. - The connection vault from SPEC-0028 (
outbound_connections) holds the credentials. If SPEC-0028's vault story has not landed when this work starts, its migration and store wrapper are the first story here; the two specs share it by design. internal/push.Validatorguards every dial, as it does for ADR-0029's hooks.- SPEC-0022's shift grammar and embedded zone database give the digest schedule its parser.
Goals / Non-Goals
Goals
- Per-owner, per-team alerting on a shared instance, with no instance-wide sink.
- Never lose the message that matters (a dead letter), and never flood.
- One place for all outbound credentials.
- A digest that is honest about what it could not measure.
Non-Goals
- Human work tracking of any kind (REQ-1).
- Rich per-service formatting. Messages are plain text; Apprise renders per service.
- Operator alerting. Operators use ADR-0028 metrics and Alertmanager.
- Claude Code permission relay (ADR-0034, future work).
Decisions
A transactional outbox, not the notify-hook worker's in-memory queue
Choice: notification_outbox, written in the triggering transaction, drained by a worker with
FOR UPDATE SKIP LOCKED.
Rationale: ADR-0029's doorbell can be lossy because the queue still holds the todo. A dead letter notice cannot: the dead letter is the thing nobody is looking at. The outbox also makes dedup windows, budgets and "exactly one instance delivers" correct across replicas without coordination.
Alternatives considered:
- In-process queue: rejected, loses messages on restart and multiplies budgets by instance count.
- A trigger or
LISTEN/NOTIFY: rejected as the primary path;NOTIFYMAY wake the worker early, but polling the outbox remains the source of truth.
Dedup by folding into the pending row
Choice: a partial unique index on (sink_id, dedup_key) WHERE state = 'pending'; an insert that
conflicts does count = count + 1, last_event_at = now().
Rationale: the storm case (hundreds of quarantines) becomes one row and one message with a count, with no separate dedup cache.
Budgets computed from delivery records
Choice: before sending, count this sink's sent rows in the last hour; if over budget, leave the row
pending with held = true. When budget returns, the worker claims all held rows for the sink at once
and sends one coalesced message.
Rationale: correct across instances and restarts, and no token-bucket state to persist.
Sinks reference vault connections
Choice: notification_sinks.connection_id references outbound_connections, with the same owner
scope enforced by the store on insert and by the worker on every delivery.
Rationale: one set of credential rules (SPEC-0028 REQ-3), one rotate button, one test.
relay.exhausted replaces todo.dead_lettered for relay todos
Choice: the dead-letter hook asks the attempt history (SPEC-0034) whether any attempt was a relay
attempt; if so, it enqueues relay.exhausted instead.
Rationale: one message per failure, carrying the attempt count and last Cairn handle a human needs.
Until SPEC-0034 exists, every dead letter is todo.dead_lettered.
The digest is a query, not a counter
Choice: the builder runs one grouped query per section at send time, each with a timeout; a section that fails or whose source does not exist is marked unavailable.
Rationale: the same reason SPEC-0023 computes its gauges at scrape time: a query cannot disagree with the table it reads, and a flat zero from a broken counter is exactly the false calm this exists to prevent.
Apprise stateful mode is the recommended path
Choice: the UI offers "configuration key" first and "Apprise URLs" second, with the scheme allowlist enforced on the second.
Rationale: with a key, service tokens never reach Switchboard, and the Apprise server's own
APPRISE_ALLOW_SERVICES governs what it may call.
Agent verbs exist, and are off until an owner grants them
Choice: NotificationVerbs() = ["list_sinks", "set_sink", "delete_sink", "test_sink", "set_sink_subscriptions", "notify_owner"], included in AllVerbs() so the vend wizard, quick vend and
consent screen enumerate them (unchecked), and excluded from the basics vend's BasicVerbs(), the same
helper SPEC-0028 introduces for reply and the connection verbs. The MCP handlers call the same
service functions as the operator API, so the SSRF, scheme, ceiling and budget checks cannot drift.
Rationale: Joe, 2026-09-22: risky options are fine when they are configurable and off by default. An agent that can redirect or silence alerts, or page a person, holds a real power, so the owner grants it deliberately; refusing it outright only pushed agent-driven setups back to a human.
Schema
The migration number is the next free one when the story lands.
-- Connection kinds gotify and apprise are rows in SPEC-0028's outbound_connections.
CREATE TABLE notification_sinks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_human_id uuid REFERENCES humans(id) ON DELETE CASCADE,
owner_team_id uuid, -- FK to teams(id) once SPEC-0033 lands
name text NOT NULL,
kind text NOT NULL, -- gotify|apprise
connection_id uuid NOT NULL REFERENCES outbound_connections(id) ON DELETE CASCADE,
options jsonb NOT NULL DEFAULT '{}', -- priority/type maps, config_key, tag
enabled boolean NOT NULL DEFAULT true,
created_by_human_id uuid NOT NULL REFERENCES humans(id),
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (num_nonnulls(owner_human_id, owner_team_id) = 1)
);
CREATE UNIQUE INDEX notification_sinks_name
ON notification_sinks (COALESCE(owner_human_id, owner_team_id), name);
CREATE TABLE notification_subscriptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sink_id uuid NOT NULL REFERENCES notification_sinks(id) ON DELETE CASCADE,
event_types text[] NOT NULL,
queues text[] NOT NULL DEFAULT '{}',
endpoint_ids uuid[] NOT NULL DEFAULT '{}',
webhook_ids uuid[] NOT NULL DEFAULT '{}',
min_severity text NOT NULL DEFAULT 'low',
include_excerpt boolean NOT NULL DEFAULT false -- opt-in: up to 280 chars of the subject body
);
CREATE TABLE notification_outbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sink_id uuid NOT NULL REFERENCES notification_sinks(id) ON DELETE CASCADE,
owner_scope_id uuid NOT NULL,
event_type text NOT NULL,
severity text NOT NULL,
dedup_key text NOT NULL,
count int NOT NULL DEFAULT 1,
subject jsonb NOT NULL, -- todo id, queue, cause, title (truncated), links; payload text only as the opt-in excerpt
state text NOT NULL DEFAULT 'pending', -- pending|sent|undeliverable|partial
held boolean NOT NULL DEFAULT false,
attempts int NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_error_class text,
created_at timestamptz NOT NULL DEFAULT now(),
last_event_at timestamptz NOT NULL DEFAULT now(),
sent_at timestamptz
);
CREATE UNIQUE INDEX notification_outbox_dedup
ON notification_outbox (sink_id, dedup_key) WHERE state = 'pending';
CREATE INDEX notification_outbox_due
ON notification_outbox (next_attempt_at) WHERE state = 'pending';
CREATE TABLE queue_digests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_human_id uuid REFERENCES humans(id) ON DELETE CASCADE,
owner_team_id uuid,
name text NOT NULL,
schedule text NOT NULL, -- TZ=… Mon-Fri 09:00,17:00
queues text[] NOT NULL DEFAULT '{}',
sink_ids uuid[] NOT NULL DEFAULT '{}',
cairn_connection_id uuid REFERENCES outbound_connections(id) ON DELETE SET NULL,
cairn_ttl interval NOT NULL DEFAULT '7 days',
skip_when_quiet boolean NOT NULL DEFAULT true,
age_threshold interval NOT NULL DEFAULT '1 hour',
last_sent_at timestamptz,
CHECK (num_nonnulls(owner_human_id, owner_team_id) = 1)
);
routing.Action gains:
Notify string `json:"notify,omitempty"` // sink name in the webhook's owner scope
NotifySeverity string `json:"notify_severity,omitempty"` // low|normal|high
Message Shapes
Gotify:
{"title": "Dead letter · forge", "priority": 8,
"message": "“PR 482 opened in stump.wtf/switchboard” failed 5 of 5 attempts.\nBoard: https://sb.example.net/todos/td_…\nOrigin: gitea stump.wtf/switchboard#482"}
Apprise (stateful):
POST /notify/ops
{"title": "Quarantine · inbox", "type": "warning", "format": "text",
"body": "3 deliveries from an untrusted sender on webhook “forge” (and 2 more).\nBoard: https://sb.example.net/events?cause=quarantine"}
Digest (sink text when a Cairn artifact is linked):
Switchboard digest · Mon 09:00 · 2 need attention
forge: 3 dead letters · oldest pending 2h 10m · 0 claimed
inbox: quiet
Unheard doorbells: unavailable (not measured on this instance)
Full digest: https://cairn.example.net/a/abc123
Operator API
GET|POST /api/v1/sinks
PATCH|DELETE /api/v1/sinks/{id}
POST /api/v1/sinks/{id}/test
GET|POST /api/v1/sinks/{id}/subscriptions
DELETE /api/v1/sinks/{id}/subscriptions/{sid}
GET|POST /api/v1/digests
PATCH|DELETE /api/v1/digests/{id}
POST /api/v1/digests/{id}/preview -> the digest text, not sent
GET /api/v1/notifications?state=undeliverable
MCP (off by default, REQ-5 and REQ-6): list_sinks, set_sink {name, kind, connection, options, enabled}, delete_sink {name}, test_sink {name}, set_sink_subscriptions {sink, subscriptions}
(a full replace, argument required) and notify_owner {title, severity?, todo_id?}.
The web UI mirrors these under Settings → Notifications, and on a team's settings page once SPEC-0033 adds one. Digest preview is how an owner checks honest absence before scheduling.
Architecture
Package Layout
internal/notify/ subscriptions matcher, outbox writer (called inside store txns), worker
internal/notify/sink/ gotify.go apprise.go (Send, Test)
internal/notify/digest/ schedule parser (reuses the SPEC-0022 grammar), section queries, renderer
internal/routing/ Action.Notify, validation, trace field
internal/web/notifications.go, internal/server/api.go
The outbox writer takes the store's transaction (querier) so the dead-letter update and the outbox
insert share it; it never opens its own.
Risks / Trade-offs
- Tenant-run relays on private networks. → The operator's private-host allowlist bounds reach; the docs show the Apprise and Gotify hosts an operator must list.
- Apprise as an SSRF relay. → Scheme allowlist for stored URLs, stateful mode recommended, and a
documented
APPRISE_ALLOW_SERVICESsetting for the Apprise server. - Outbox growth under a storm. → Folding into pending rows keeps one row per dedup key; delivered rows are pruned after 30 days.
- Digest cost. → A handful of grouped queries per digest at most a few times a day per scope, each with a timeout.
- Dependence on parallel specs. → Every dependent section degrades to "unavailable" until its source exists, which is the honest-absence rule doing its job.
Migration Plan
Additive tables and one new optional routing field; existing rules parse unchanged. Rollback: disable the worker (sinks go quiet, the outbox holds rows), then drop the tables.
Open Questions
- Should a sink subscribe to unheard doorbells in real time? Resolved (design review 2026-09-22): no for v1, as proposed. Unheard doorbells reach humans through the digest until SPEC-0025's thresholds are measured.
- Should owners be allowed a budget above the operator default? Resolved (design review 2026-09-22): yes, as proposed, within an operator ceiling (REQ-11).
- Gotify markdown. Resolved (design review 2026-09-22): left out of v1, so every sink receives the same text.
- Durable outbox or best-effort delivery? Resolved (design review 2026-09-22): the transactional outbox (REQ-10).
- Agent paging. Resolved (design review 2026-09-22):
notify_owneris the only agent paging verb. It is fire-and-forget, owner-granted and off by default. There is no acknowledgement, assignee or ticket state.