---
id: PRD-0004
title: "M3 — Bridge (MCP peer + WS client + permission enforcement)"
status: done
owner: "Aaron Roney"
created: 2026-07-01
updated: 2026-07-01

depends_on:
- PRD-0003

principles:
- "The bridge is one MCP server per Claude Code session; it dials out to N central servers (DESIGN §8/§13)."
- "Permission levels are enforced by capability: mute drops delivery, notify withholds emit, per-channel is a call-time rejection (DESIGN §9)."
- "Inbound is untrusted data; the surrounding prompt frames it as such (DESIGN §12)."

references:
- name: "Conclave design (core mechanism §4, permission levels §9, bridge §13, data flow §14, threat model §12)"
  url: "docs/DESIGN.md"
- name: "March prototype (claude/channel capability, reply tool, permission relay)"
  url: "../../dotagent/scratch/channel"

acceptance_tests:
- id: uat-001
  name: "Bridge advertises the experimental claude/channel capability and injects an inbound message as a <channel> tag (validated vs installed CC)"
  command: cargo nextest run -E 'test(/bridge_inject/)'
  uat_status: verified
- id: uat-002
  name: "e2e: serve + two bridges; a channel message from A is delivered and injected into B; a whisper reaches exactly one session"
  command: cargo nextest run -E 'test(/e2e_channel/)'
  uat_status: verified
- id: uat-003
  name: "Permission enforcement: mute drops inbound; notify withholds emit tools; a send to a below-converse channel is rejected at call time; converse/act allow"
  command: cargo nextest run -E 'test(/bridge_perm/)'
  uat_status: verified
- id: uat-004
  name: "Reconnect: a dropped connection backs off, reconnects, and re-subscribes joined channels"
  command: cargo nextest run -E 'test(/bridge_reconnect/)'
  uat_status: verified

tasks:
- id: T-001
  title: "MCP stdio server: declare claude/channel + permission capabilities; validate against installed CC"
  priority: 1
  status: done
  notes: "Port the prototype's capability declaration + notifications/claude/channel injection. VALIDATE the exact capability/notification shape against the current Claude Code version first (this is the M0-replacement de-risk, per DESIGN §4). Surface a clear error if the capability shape has drifted."
- id: T-002
  title: "Multi-server WS client with reconnect + re-subscribe"
  priority: 1
  status: done
  notes: "Hold N outbound WSS connections (tokio-tungstenite), one per (session, server). Backoff reconnect; re-subscribe joined channels on reconnect. Typestate lifecycle Instance<Config> -> Instance<Ready>."
- id: T-003
  title: "Inbound path: level resolution + pluggable notification sink"
  priority: 1
  status: done
  notes: "Resolve (server, channel) level. mute -> drop. Else inject via a NotificationSink trait (v1 impl: session pane) with the level's surrounding prompt; emit <channel>/<whisper> tag carrying server/channel/from(full path)/kind. Sink is pluggable for DESIGN §19 aggregation-log/desktop/push."
- id: T-004
  title: "Outbound MCP tools + call-time permission rejection"
  priority: 1
  status: done
  notes: "Tools: send_channel(server, channel, text), whisper(server, target-path, text), list_channels(server), who(server, channel?), join_channel(server, name, token?, perm?). Emit tools exposed only when some joined channel is >=converse; reject a send/whisper whose target (server,channel) resolves below converse."
- id: T-005
  title: "Permission-relay carry-over"
  priority: 2
  status: done
  notes: "Relay claude/channel/permission_request out to the local human via the notification sink and apply the returned verdict (per DESIGN §12/§14). This is the remote approval gate for dangerous tool calls even at act."
- id: T-006
  title: "e2e harness: serve + multiple bridges"
  priority: 1
  status: done
  notes: "Spawn real conclave serve + two conclave bridge processes via env!(CARGO_BIN_EXE_conclave) in TempDir with fixture keys, staggered ports. Assert delivery, injection, whisper, permission enforcement, reconnect. network-heavy nextest group + retries."
---

# Summary

The local half of the fabric: a per-session MCP server that injects inbound channel/whisper traffic
into Claude Code and exposes the outbound tools, while enforcing the local permission policy by
capability. It connects out to one or more central servers and survives reconnects.

# Problem

Without the bridge there is no way for messages to enter or leave a Claude Code session. This
milestone also proves the load-bearing `claude/channel` capability against the current CC version
(the de-risk we folded out of the old M0 trial).

# Goals

1. An MCP stdio server that injects inbound messages as `<channel>`/`<whisper>` tags.
2. A multi-server WS client that reconnects and re-subscribes.
3. Permission enforcement by capability (mute/notify/converse/act) with call-time per-channel checks.
4. A pluggable notification sink (session pane in v1) and the carried-over permission relay.

# Technical Approach

The bridge is a dual peer (DESIGN §13): a stdio MCP server toward Claude Code and a
`tokio-tungstenite` WS client toward central. Inbound frames are resolved against the local
`(server, channel)` permission level; `mute` drops, otherwise the message is injected through a
`NotificationSink` with the level-appropriate surrounding prompt. Outbound tool calls are gated —
emit tools are present only when some joined channel is `>= converse`, and a call whose target
channel resolves lower is rejected at call time. A typestate machine models the connection
lifecycle. The e2e suite spawns real `serve` + `bridge` binaries.

# Assumptions

- The M2 server is complete (auth, channels, presence, fan-out).
- The experimental `claude/channel` capability exists in the installed CC (validated in T-001).

# Constraints

- Per-channel gating cannot be tool-absence (MCP tool lists are session-global) — it must be
  call-time rejection (DESIGN §9).
- CC ↔ bridge stdio is plaintext (local pipe).

# References to Code

- `docs/DESIGN.md` §4 (mechanism), §8 (addressing/multi-home), §9 (levels), §12, §13, §14.
- `dotagent/scratch/channel` — the prototype's capability declaration, `reply` tool, permission relay.

# Non-Goals (MVP)

- The full CLI verb surface and `/join` skill packaging (M4).
- Invite/visibility polish, live `perm set`, multi-home UX (M5).

# History
(Entries appended during implementation go below this line.)

## 2026-07-01 — M3 executed (T-001…T-006)

The bridge landed with TDD; `cargo make ci` is green (101 tests). All four UATs pass:
`test(/bridge_inject/)` (12), `test(/bridge_perm/)` (8), `test(/bridge_reconnect/)` (4), and the
two-bridge `test(/e2e_channel/)` (1).

De-risk (T-001) — the load-bearing `claude/channel` capability was **validated against the installed
Claude Code 2.1.198** (binary inspection): shape unchanged from the prototype. Both
`experimental["claude/channel"]` and `["claude/channel/permission"]` are required in `initialize`;
injection is `notifications/claude/channel { content, meta? }`; the permission relay is
`…/permission_request` → `…/permission { request_id, behavior }`. **New constraint captured in
DESIGN §4:** CC strips the capability unless the bridge is loaded as a development channel
(`--dangerously-load-development-channels` / `allowedChannelPlugins`), so live-CC validation is a
documented manual recipe (DEVELOPMENT.md) rather than a CI test — the confirmed decision.

Structure — the `bridge` module is split behind `run` / `BridgeSetup`:

- **`bridge::mcp`** — a hand-rolled newline-delimited JSON-RPC 2.0 stdio peer (`serde_json`): parses
  `FromMcp` events, builds the capability declaration + tool list + tool results + the
  `notifications/claude/channel` injection + permission verdict. Full control of the experimental
  frames (the chosen approach over the rmcp SDK).
- **`bridge::client`** — one reconnecting WS link per server (`tokio-tungstenite`): connect →
  challenge-response auth → re-subscribe joined channels → pump. Exponential backoff (200ms→30s),
  reset on connect. The reconnect loop is generic over a `connect` closure, so it's unit-tested with
  a paused clock and no socket.
- **`bridge::policy`** / **`bridge::sink`** — the local autonomy policy (`mute` drops, emit gated at
  `converse`, per-channel call-time rejection) and the `NotificationSink` trait that frames a
  delivered message as an untrusted-data `<channel>`/`<whisper>` tag (v1 = the CC-pane sink).
- **`BridgeCore`** — the transport-free dispatcher: MCP tool calls → outbound frames (with call-time
  permission rejection), inbound frames → injection or deferred tool-result resolution. Tools:
  `join_channel` / `list_channels` / `who` / `submit_permission` always; `send_channel` / `whisper`
  offered only when some joined channel is `>= converse` (session-global gating).

Locked-decision notes and deviations:

- **Heartbeat:** the bridge sends an app-level `Ping` every 20s (well under the server's 60s reaper).
- **Tool ⇄ response correlation:** control tools (`join`/`list`/`who`) push their MCP id to a
  per-server FIFO and resolve when the matching response arrives; emit tools are fire-and-forget
  (posts to a joined `converse` channel don't fail server-side), and a stray error surfaces as a
  notice. Full per-request-id correlation would need a protocol change; deferred.
- **Permission relay (T-005, priority 2):** the request is surfaced to the local session and answered
  via the `submit_permission` tool; routing the verdict to a *remote* approver is a later refinement.
- `serde_json` is the only new dep for the MCP hop (the central wire protocol stays bincode);
  `tokio-tungstenite` moved from dev-only to a production dep; dev-only `tokio` `process` feature
  drives the e2e's spawned bridges. New bin flags: `bridge --config-dir`, `--as`.
