Design: Channels Push Delivery
Context
SPEC-0011 realizes ADR-0013: switchboard pushes work into live harness sessions over the Claude Code Channels standard, an MCP extension that adds a server→session push. ADR-0007 chose a durable, pull-based todo primitive precisely because the MCP request/response tool model has no push; Channels adds a real push path, but its delivery is best-effort and unacknowledged by the standard's own definition. ADR-0013 resolves the tension: Channels is a notify layer over the durable queue, never the ledger. The durable todo is the delivery of record; the channel notification is a doorbell.
Transport is decided and owned elsewhere: ADR-0017
/ SPEC-0014 serve every vended endpoint exclusively over Streamable
HTTP, and the channel capability rides that same session — one transport, one credential, one
scope. The original MVP's local stdio adapter (switchboard channel, internal/channel/adapter.go)
and its /agent/stream SSE source are retired by that decision. What remains in this capability is
the push semantics layer: eligibility (sender gate), shape (summary + snake_case meta),
fan-out filtering by endpoint scope, and the lossy, degrade-to-pull delivery contract.
Goals / Non-Goals
Goals
- Deliver push ergonomics (no polling lag when a session is live) without weakening ADR-0007 durability.
- Reuse the vended MCP session as the channel carrier — no second connection, credential, or scope.
- Make the failure mode automatic and lossless: no attached session ⇒ the todo waits for pull.
- Keep attribution and injection safety: only verified, human-attributed todos push; payloads cannot
break out of the
<channel>wrapper.
Non-Goals
- Channels as the system of record (rejected option A in ADR-0013) — push never gates correctness.
- Two-way channels for the MVP: the reply tool and permission relay
(
claude/channel/permission) are designed in ADR-0013 but deferred; push is one-way. - Transport, session, and tool-serving mechanics — governed by SPEC-0014.
- Guaranteeing ordering or delivery beyond what the queue provides.
Decisions
Notify layer over the durable queue (not the ledger)
Choice: A todo transition (create/assign) emits at most one notifications/claude/channel per
in-scope attached session, carrying a summary + identifiers; the agent still claims/completes via the
durable verbs. Push is a doorbell; the durable todo is the work.
Rationale: The standard drops events silently when no session is attached, so push cannot be the
record. Backing push with the durable queue gives "fast and safe" — zero durability loss.
Alternatives considered:
- Channels as system of record: rejected — lossy/unacknowledged; work would vanish, the exact failure ADR-0007 exists to prevent.
- Pull-only: rejected — forfeits a now-available push path and pays polling latency on every idle drain even when a session is attached.
Doorbells ride the vended Streamable HTTP session (no adapter)
Choice: The notification hub publishes directly to attached SPEC-0014 sessions filtered by endpoint queue scope; there is no client-side bridge process. Rationale: ADR-0017 removed the stdio adapter and its REST/SSE plumbing; emitting on the session the agent already holds eliminates a process, a credential hand-off, and a reconnection protocol. Alternatives considered:
- Local stdio adapter bridging to central SSE (the original MVP): retired — required the binary on every agent host and duplicated transport code (ADR-0017).
- A dedicated notification WebSocket/SSE endpoint separate from MCP: rejected — second transport to authenticate and keep alive for no semantic gain.
snake_case meta keys, no lease in the push
Choice: meta uses identifier-safe snake_case keys (todo_id, queue, kind, source); the
notification carries no lease.
Rationale: The standard silently drops meta keys that are not identifiers, so hyphenated keys
would vanish. Withholding the lease forces the agent through the idempotent claim, preserving
ownership/lease semantics.
Alternatives considered:
- Inlining the todo payload/lease into the push: deferred — notification-only keeps the injection surface and payload size small.
Drop-on-full fan-out
Choice: The hub buffers a bounded number of doorbells per subscriber and drops on a full buffer rather than blocking the publisher. Rationale: The queue is the ledger; a dropped doorbell is recoverable by pull. Blocking the publisher on a slow session would couple todo creation to session liveness. Alternatives considered:
- Unbounded buffering: rejected — unbounded memory growth on a stalled consumer.
Architecture
Risks / Trade-offs
- Push is lossy/unacknowledged → Back every push with the durable queue; a missed doorbell is recovered by the pull worker loop. Push never gates correctness.
- Harness support for channels over HTTP MCP may lag stdio channels → tools work regardless; agents degrade to pull until harness support lands (tracked in SPEC-0014's open questions).
- Two-way channels widen the injection surface → Mitigated by the existing verification +
attribution sender gate and by neutralizing
</channel>breakouts; two-way is deferred regardless. - Research-preview gates (Claude Code version and org policy for Channels) → Documented as operational constraints; harnesses without Channels fall back to pull with no configuration.
- Duplicate at-least-once notifies → Idempotency-key dedup + idempotent
claimmake duplicates harmless.
Migration Plan
The push-semantics layer migrates with SPEC-0014's cutover: the hub's publish target changes from
the retired /agent/stream SSE subscribers to attached MCP sessions, preserving notify shape,
sender gate, scope filtering, and degrade-to-pull unchanged. No data migration; the stdio adapter's
deletion is governed by SPEC-0014's migration plan.
Open Questions
- Inline payload: should a small payload be inlined into the push, or remain notification-only (summary + ids)? MVP is notification-only.
- Two-way: when to enable the reply tool and permission relay (
claude/channel/permission), and how the first-verdict-wins consent maps onto durable approval-todos (ADR-0010). - Session liveness tracking: how precisely to detect an attached/loaded channel so pushes are attempted only when they can land (today the fan-out simply drops on no subscriber).