---
id: PRD-0008
title: "M8 — Bridge & CLI Reliability"
status: done
owner: "Aaron Roney"
created: 2026-07-02
updated: 2026-07-02

depends_on:
- PRD-0006

principles:
- "The bridge is the product surface an agent actually touches: a tool call must always terminate — with a result or an error, never a hang."
- "Inbound bodies are untrusted text rendered into a live agent session; a sender must not be able to escape the framing or forge a trusted block."
- "Correlate responses explicitly, never by arrival order — out-of-band frames must not steal another call's answer."
- "Every behavioral fix ships a regression test (constitution §8); prefer in-memory duplex fakes over sockets."

references:
- name: "Conclave design (bridge/MCP §4, permission levels §9, presence/reconnect §10/§16)"
  url: "docs/DESIGN.md"
- name: "PRD-0004 — bridge (MCP peer, WS client, permission enforcement)"
  url: ".prds/PRD-0004.md"

acceptance_tests:
- id: uat-001
  name: "An out-of-band Error frame (e.g. whisper to an offline target) does not pop or misroute an unrelated deferred tool call"
  command: cargo nextest run -E 'test(/correlation/)'
  uat_status: verified
- id: uat-002
  name: "A link drop fails every pending deferred tool call with an error (no permanent hang) and clears the queue before resubscribe"
  command: cargo nextest run -E 'test(/link_down_fails_pending/)'
  uat_status: verified
- id: uat-003
  name: "A connect/link failure surfaces to the session as a tool error; join is not reported successful until the server confirms"
  command: cargo nextest run -E 'test(/after_confirmation/) + test(/link_state/) + test(/rejected_join/)'
  uat_status: verified
- id: uat-004
  name: "A CLI verb against a server that accepts TCP but never replies times out with a clear error instead of hanging forever"
  command: cargo nextest run -E 'test(/control_timeout/)'
  uat_status: verified
- id: uat-005
  name: "An inbound body containing a closing tag or a forged <whisper> block cannot break out of the injection framing"
  command: cargo nextest run -E 'test(/inject_escape/)'
  uat_status: verified
- id: uat-006
  name: "config.toml writes are atomic and crash-safe; a rejected join leaves no perm override; perm set requires an explicit scope"
  command: cargo nextest run -E 'test(/config_atomic/) + test(/perm_scope/)'
  uat_status: verified
- id: uat-007
  name: "Full gate is green (cargo make uat)"
  command: cargo make uat
  uat_status: verified

tasks:
- id: T-001
  title: "Explicit request<->response correlation (retire the FIFO pop)"
  priority: 1
  status: done
  notes: "bridge.rs:553 resolve_error pops the pending FIFO for ANY inbound Error frame, but send_channel/whisper reply success immediately without pushing an id (bridge.rs:407/435) and the server answers failed ChannelMsg/Whisper (session.rs:244-253) and force-drop (session.rs:47) with Error frames — so an Error steals the front deferred call's slot and shifts every later response by one for the process lifetime. Correlate explicitly: a client-side sequence number the server echoes, or at minimum only resolve pending when the frame type matches the front request's expected success type, and route ChannelMsg/Whisper failures to the notice path. Reconnect-resubscribe Joins (client.rs:95, no pending id) must likewise not resolve unrelated requests. Finding #3 (HIGH)."
- id: T-002
  title: "Fail pending tool calls on link drop"
  priority: 1
  status: done
  notes: "bridge.rs:143 — pending ids are never cleared when a link drops, so deferred tool calls hang forever and the FIFO stays shifted after reconnect. Surface a LinkDown event from run_link into the dispatcher (an internal message on inbound_tx), fail all pending ids for that server with a tool_error_result (\"connection lost; retry\"), and clear the queue before resubscribe runs. Finding #4 (HIGH). Depends on T-001's correlation model."
- id: T-003
  title: "Surface connect/link failures; do not pre-confirm join"
  priority: 1
  status: done
  notes: "client.rs:84 — connect failures are retried silently; tools hang or falsely report success while the link is down. bridge.rs:377 — tool_join records the channel as joined before server confirmation and never rolls back on failure/kick/ban. Emit link state to the session and gate join success on the server's Joined ack; roll back local subscription state on kick/ban. Findings #20, #21 (MED)."
- id: T-004
  title: "Timeouts on the one-shot control client"
  priority: 2
  status: done
  notes: "control.rs:110 — no connect or response deadline, so every CLI verb hangs forever against a server that accepts TCP but never replies. Add a connect timeout and an overall response deadline (tokio::time::timeout) with a clear error and non-zero exit. Finding #23 (MED)."
- id: T-005
  title: "Harden inbound injection framing (security-critical)"
  priority: 2
  status: done
  notes: "sink.rs:46 — the untrusted body is interpolated into the <channel>/<whisper> tag with no escaping, so any sender can close the framing and forge a fake trusted block (sender/harness impersonation) inside a live agent session. Escape or neutralize the delimiters (e.g. reject/replace the closing sentinel, or encode the body) so a body can never terminate its own frame or open a new one. Add adversarial-body tests. Finding #18 (MED, security)."
- id: T-006
  title: "CLI correctness papercuts"
  priority: 2
  status: done
  notes: "bin.rs:204 — `join --perm` persists the elevated override before the server accepts the join, so a rejected join leaves the override in config.toml. bin.rs:238 — `perm set <level> --server X` with neither --channel nor --whisper silently writes the whisper scope, and --channel + --whisper silently ignores --channel; require an explicit, unambiguous scope. identity.rs:312 — config.toml writes are non-atomic and unlocked: concurrent CLIs clobber and a crash mid-write leaves truncated TOML that breaks every later verb — write to a temp file + rename, with an advisory lock. Findings #24, #25, #26 (MED)."
- id: T-007
  title: "Bridge/MCP + keystore polish"
  priority: 3
  status: done
  notes: "bridge.rs:575 — admin tools are advertised session-globally whenever ANY connected server asserts admin=true; scope advertisement per-server so one malicious/compromised server can't flip admin-tool availability for the whole multi-home session (#31). mcp.rs:129 — a tools/call with missing/non-string params.name is silently dropped, leaving the JSON-RPC request unanswered forever; reply with a JSON-RPC error (#32). identity.rs — zeroize plaintext seed copies outside the SecretBox (#28) and create the keyfile with 0600 from the start (open with mode, not chmod-after) to close the world-readable window (#33). Findings #31, #32 (LOW), #28, #33 (LOW)."
---

# Summary

Make the bridge and CLI reliable and safe under real use: every deferred tool call terminates (a
result or an error, never a hang), responses are correlated explicitly instead of by arrival order,
inbound untrusted text cannot escape its injection framing, and CLI verbs never hang or silently
misconfigure. These are the defects an operator will *feel* the moment they drive a live server from
a session — including during PRD-0009 deployment testing.

# Problem

The post-v1 review confirmed two HIGH bridge defects that break response integrity: an out-of-band
`Error` frame pops the wrong entry off the per-server FIFO used to correlate MCP responses
(`bridge.rs:553`), permanently desyncing every subsequent tool answer; and pending request ids are
never cleared on a link drop (`bridge.rs:143`), so deferred tool calls **hang forever** after a
disconnect. Around these sit silent connect-failure handling, premature join confirmation,
unbounded CLI hangs against a dead server, non-atomic `config.toml` writes that can brick the CLI,
and an unescaped injection path that lets any sender forge a "trusted" block inside an agent
session.

# Goals

1. Explicit, collision-proof request↔response correlation; out-of-band frames never steal a call's answer.
2. Link drops fail pending calls promptly; no permanent hangs anywhere in the tool path.
3. Untrusted inbound bodies cannot break out of `<channel>`/`<whisper>` framing.
4. CLI verbs are bounded (timeouts), configuration writes are crash-safe, and scopes are explicit.

# Technical Approach

Replace order-based correlation with an explicit key: tag each deferred control request with a
client sequence number the server echoes on its reply (append-only protocol field), and resolve the
`pending` map by that key — Error frames from fire-and-forget sends route to the notice path and
never pop a deferred slot. Model link state as an internal event on the inbound channel so the
dispatcher can fail-and-clear all pending calls for a server before resubscribe. Wrap the control
client in `tokio::time::timeout`. Escape the injection delimiters in the sink so a body cannot emit
its own closing sentinel. Make `config.toml` writes temp-file-plus-rename under an advisory lock,
and require an explicit `--channel`/`--whisper`/default scope from `perm set`.

# Assumptions

- M0–M5 complete and green; server-side hardening (PRD-0007) is independent and can land in parallel.
- Any protocol addition (a correlation id) stays append-only and forward-compatible.

# Constraints

- Keep the hand-rolled JSON-RPC MCP peer and the transport-agnostic BridgeCore design (no framework swap).
- Permission semantics (mute/notify/converse/act, whisper as a distinct scope) are unchanged — only their plumbing is hardened.

# References to Code

- `src/bridge.rs` — dispatcher, pending FIFO (:143), resolve_error (:553), tool_join (:377), admin-tool advertisement (:575).
- `src/bridge/client.rs` — reconnect/link (:84), resubscribe (:95).
- `src/bridge/sink.rs` — injection framing (:46).
- `src/bridge/mcp.rs` — JSON-RPC decode/dispatch (:129).
- `src/control.rs` — one-shot control client (:110).
- `src/bin.rs` — CLI verbs (join :204, perm set :238); `src/identity.rs` — config + keystore (:312).

# Non-Goals (MVP)

- Remote-approver permission relay and full per-request-id protocol redesign beyond a minimal correlation id (DESIGN/PRD-0004 deferral).
- Server-side and deployment fixes (PRD-0007 / PRD-0009).

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

## 2026-07-03 — T-007: bridge/MCP + keystore polish (LOW); PRD-0008 complete

- **#31:** `defer_admin` now refuses an admin op unless the target server actually asserted admin for
  this user (`admin_servers`), so one server claiming `admin=true` can't confer admin power against
  another home. Test: `bridge_admin_tool_is_scoped_to_a_server_that_asserted_admin`.
- **#32:** a `tools/call` with a missing/non-string `name` now resolves to an `UnknownRequest` (→
  JSON-RPC `method not found`) instead of being dropped to hang the request forever. Test:
  `parse_tools_call_without_a_name_is_answered_not_dropped`.
- **#33:** the keyfile is created with mode `0600` from the start (`create_key_file` via
  `OpenOptions::mode` on unix), closing the world-readable window; the existing perms test
  (`mode & 0o777 == 0o600`) covers it, `restrict_permissions` remains for a pre-existing file.
- **#28:** every transient plaintext seed copy is now zeroized — the generated seed (`Zeroizing`),
  `from_seed`'s argument, `load_identity`'s intermediates, and the base64 keyfile string.

All seven M8 tasks are done and the full gate is green (157 tests). PRD-0008 → done.

## 2026-07-03 — T-006: CLI correctness papercuts

- **#24 (join --perm ordering):** `run_join` now performs the `Join` and bails on an `Error` frame
  *before* persisting the `--perm` override, so a rejected join no longer leaves a stale elevated
  override in `config.toml` (the perm value is still parsed up front to fail fast on a bad level).
  The success path is covered by `cli_control_register_machine_key_join_and_perm`.
- **#25 (perm set scope):** with `--server`, exactly one explicit scope is now required — `--channel
  <name>` or `--whisper`; neither (previously silently wrote the whisper scope) and both are
  rejected. Test: `test(/perm_scope/)`.
- **#26 (atomic config write):** `save_config` writes via a per-process temp file + `rename`
  (`atomic_write`), so a crash mid-write can only truncate the temp — the live `config.toml` is
  replaced in one atomic step and never left partial to brick later verbs. Test:
  `test(/config_atomic/)`. Deferred: an advisory lock to also prevent concurrent-CLI lost updates
  (rare for a human-run CLI; the atomic write already rules out corruption). Gate green (155 tests).

## 2026-07-03 — T-005: escape the injection framing

`sink.rs` interpolated the untrusted `body` (and the server-supplied `channel` attribute) raw into
the `<channel>`/`<whisper>` tag, so a body containing `</channel>` closed the frame and a following
`<channel from="admin/...">` block appeared as a *forged trusted* block inside the agent session
(sender/harness impersonation, #18). Fix: XML-escape (`& < > "`) the body and every interpolated
attribute in `tag()`, so a body can neither terminate its own frame nor open a new tag. Adversarial
tests (`test(/inject_escape/)`) confirm exactly one real closing tag survives, no forged opening tag
survives as markup, and a quote in a server-supplied channel name can't break out of its attribute.
Gate green (153 tests).

## 2026-07-03 — T-004: control-client timeouts

`control.rs` bounded both blocking points so a CLI verb can't hang on a dead-but-listening server
(#23): `connect` wraps `connect_async` in a `CONNECT_TIMEOUT` (10s) and each reply wait wraps the
frame-read loop in a `RESPONSE_TIMEOUT` (15s) — both surface a clear "timed out" error and a non-zero
exit. Split out `connect_with_timeout`/`recv_with_timeout` so `test(/control_timeout/)` can inject a
150ms bound and assert the timeout in <200ms against (a) a listener that accepts TCP but never speaks
the WS upgrade and (b) one that completes the upgrade then goes silent. Gate green (150 tests).

## 2026-07-03 — T-001/T-002: link-driven response correlation

The bridge's per-server FIFO (`pending`) could not correlate correctly: `send_channel`/`whisper`
replied "sent" immediately and the server acked nothing on success, so their *failure* `Error`s were
orphans that popped an unrelated deferred call's slot (#3); and a link drop never cleared `pending`,
so deferred calls hung forever (#4). Reconnect resubscribe `Join`s (issued in `client.rs`, bypassing
the dispatcher) drew `Joined` acks that likewise stole slots.

Fix (chosen with the user — confirmed sends + link-driven pending):
- **Server acks successful `ChannelMsg`/`Whisper`** (`session.rs`; the `Ack` variant already exists —
  no wire change). Sends are now *confirmed*: `send_channel`/`whisper` defer and their result
  reflects real delivery, and their errors correlate instead of misrouting.
- **`run_link` emits `LinkEvent::{Up,Down}`** on the shared inbound channel; resubscribe moved out of
  `client.rs` into the dispatcher. On **Down** the dispatcher fails every pending tool call for that
  server ("connection lost; retry") and clears the queue (#4). On **Up** it re-subscribes each joined
  channel, enqueuing a silent `Resubscribe` sentinel per `Join` so its `Joined` ack is consumed
  instead of answering a tool (#3). `pending` is now `VecDeque<Pending::{Tool,Resubscribe}>`.
- Every server-bound request now enqueues exactly one entry and the server responds to each in order,
  so the in-order FIFO is correct.

TDD: `test(/correlation/)` (a whisper-then-who where the whisper's Error and the who's Presence must
resolve their own ids, not swap), `test(/link_down_fails_pending/)` (a dropped link fails the pending
who), and a resubscribe-ack-is-silent test. Full gate green (145 tests). Note: sends are now
synchronous (one round-trip) rather than fire-and-forget — a UX improvement (real delivery
confirmation). The e2e server-spawn timing flake in `wait_for_listener` is pre-existing (retry-covered).

## 2026-07-03 — T-003: confirm-first join + link-state notices

- **#20 (pre-confirm join):** `tool_join` no longer records the channel in the local `joined` set
  before the server confirms — the insert moved to the `Joined` handler, so a rejected join (`Error`)
  leaves no stale subscription. Tests: `test(/after_confirmation/)`, `test(/rejected_join/)`.
- **#21 (surface link state):** rather than a proactive "not connected" gate on tool dispatch (which
  raced the bridge's `LinkEvent::Up` at startup and broke the message-relay e2e — a tool call can
  legitimately arrive microseconds before the first `Up`), link state surfaces as a session notice:
  `link_down` announces a disconnect once per drop and `link_up` announces the reconnect
  (`link_down_notified` de-dupes). The "false success" half of #21 was already closed by T-001
  (sends now confirm) and connected-then-dropped hangs by T-002. Test: `test(/link_state/)`.
- Deferred: rolling back `joined` on a server-side kick/ban needs a server→client notification frame
  (the server doesn't currently tell a kicked client); harmless today — a stale entry only causes a
  resubscribe the server rejects (consumed by the `Resubscribe` sentinel). Left for a follow-up.
  Full gate green (148 tests).
