}
+ P->>B: "fleet_send — blocks or detaches"
+ B-->>W: "content injected"
+ W->>W: "works, never calls fleet_reply"
+ B->>B: "StatusPoller sees the turn end"
+ B->>B: "read the pane tail"
+ B->>B: "classify: exhausted? echoed brief? real report?"
+ B-->>P: "{ outcome: turn_done } or a named failure"
```
+The three ways it goes wrong, and what each looks like now:
+
+| What happened | What the lead used to get | What it gets today |
+|---|---|---|
+| The report is longer than the scrape window | The **end** silently cut off | Still clipped, but marked partial |
+| The member never started — spent credential | The lead's **own brief** echoed back as a report | A named failure: backend exhausted |
+| The member is simply slow | A tail of work in progress | Unchanged — read it as a hint, not a result |
+
+The echoed-brief case is the one to remember: it reads as a long, on-topic report with nothing in
+it from the member. It is suppressed now, but the general rule stands — **check the member's
+worktree with `git log` before believing a report you did not watch arrive.**
+
---
-## 7. Status gating
+## 2. Status gating
-Delivery only happens in a safe window. This is the state machine the `Injector` already
-enforces via `AgentStatus.injectable()`; MCP `fleet_send` is simply its producer.
+Delivery only happens in a safe window. `fleet_send` is a producer for the `Injector`, which
+already enforces this through `AgentStatus.injectable()`.
```mermaid
stateDiagram-v2
[*] --> IDLE
- IDLE --> WORKING: message delivered / picks up
- WORKING --> IDLE: turn done
- WORKING --> BLOCKED: awaits input
- BLOCKED --> WORKING: input delivered
- IDLE --> UNKNOWN: detection glitch
- BLOCKED --> UNKNOWN: detection glitch
- UNKNOWN --> IDLE: re-detected
+ IDLE --> WORKING: "message delivered, picked up"
+ WORKING --> IDLE: "turn done"
+ WORKING --> BLOCKED: "awaits input"
+ BLOCKED --> WORKING: "input delivered"
+ IDLE --> UNKNOWN: "detection glitch"
+ BLOCKED --> UNKNOWN: "detection glitch"
+ UNKNOWN --> IDLE: "re-detected"
note right of IDLE
injectable — deliver head of FIFO
@@ -321,69 +172,17 @@ stateDiagram-v2
end note
```
-At most one message is delivered per turn: after a send the `Injector` waits for a `WORKING`
-pickup before delivering the next, with a `PICKUP_GRACE_POLLS` fallback for turns faster than
-the poll interval. A herdr `events.subscribe` stream can later replace the sampling without
-touching this state machine.
+**At most one message per turn.** After a send, the `Injector` waits for a `WORKING` pickup before
+delivering the next, with a grace-poll fallback for turns that finish faster than the poll
+interval.
----
+Two consequences a lead feels directly:
-## 8. Error model
+- **A second send to a busy member never lands.** It reports as queued and times out. The member
+ is fine; the message simply waits, and then restarts the member when it next goes idle.
+- **A spawned member is not deliverable until it has mounted the MCP.** Until then a send waits on
+ that gate for about 60 seconds and then fails without ever reaching the pane.
-| Condition | `fleet_send` result | Notes |
-|---|---|---|
-| Worker replies | `{ outcome:"reply" }` | normal |
-| Worker asks | `{ outcome:"question", turn_id }` | answer with `fleet_send(turn_id)` |
-| Turn ends, no reply | `{ outcome:"turn_done" }` | terminal tail as text |
-| Deadline elapsed | `{ outcome:"timeout" }` | message may still be queued/delivered |
-| Worker vanished | error `worker_gone` | `Injector.drop` fails the queued future |
-| Guard breach on spawn | error `subscription_boundary` | REST `403` parity |
-| Delivery failed at herdr | error, message dropped | poisoned message not left blocking the FIFO |
-
-`fleet_reply` from a worker with no open waiter is **not** an error — it falls through to
-detached pane injection (§6.3).
-
----
-
-## 9. Mapping to existing code
-
-The MCP face is a thin adapter layer; nearly every capability already exists behind the REST
-seam. Only the **rendezvous registry** and the **caller-identity resolver** are new.
-
-| MCP tool | Existing collaborator | New work |
-|---|---|---|
-| `fleet_send` | `Injector.enqueue`, `AgentControl.send` | waiter registry, timeout, outcome mux (CB-104) |
-| `fleet_reply` / `fleet_ask` | `Injector` (pane injection) | reverse rendezvous, identity resolver |
-| `fleet_status` | `AgentControl.status`, `Injector.activeTargets` | pending-drain projection |
-| `fleet_spawn` / `list` / `stop` | `WorkerService.{spawn,list,stop}` | MCP adapter only |
-| `fleet_read` | `AgentControl.read` | MCP adapter only |
-
-Because the REST routes in `FleetApp` already exercise the collaborators, MCP tools are
-validated by **parity** against those routes, not by re-testing behavior.
-
----
-
-## 10. Open decisions
-
-1. **`fleet_ask` direction.** This page defines it as *worker-asks-primary* (a genuine reverse
- channel, matching the "inject the primary's pane" language). The alternative — a synonym for
- a blocking primary→worker send — is weaker and produces different plumbing. **Recommend
- worker-asks-primary.**
-2. **Detached delivery shape.** A `block:false` param on `fleet_send` (keeps the catalog
- small) vs. a separate `fleet_dispatch` tool. **Recommend the param.**
-3. **Auto-spawn on send.** `fleet_send` provisions a worker per profile when none exists
- (simplest primary UX) vs. requiring an explicit `fleet_spawn` first. **Recommend
- auto-spawn, defaulting on.**
-4. **Transport & SDK.** Streamable-HTTP/SSE co-located with the REST bind (recommended) vs.
- stdio. Requires choosing a Java MCP server SDK and adding it to the pom.
-
----
-
-## 11. Implementation staging
-
-- **CB-104** — blocking `fleet_send` + rendezvous registry + caller-identity resolver
- (the producer that finally drives the inert `StatusPoller`).
-- **CB-1xx** — `fleet_reply` / `fleet_ask` reverse rendezvous + detached pane injection.
-- **CB-1xx** — lifecycle + observability adapters (`fleet_spawn/list/stop/status/read`).
-- **CB-1xx** — transport wiring + `claude mcp add` docs; parity tests vs. REST.
-- **Later** — `fleet_cancel`; swap `StatusPoller` for herdr `events.subscribe`.
+`UNKNOWN` is deliberately neither injectable nor a pickup. A pane whose status cannot be read is
+not a pane that is safe to write to — see fleetd #176 for what happens when a gate treats an
+unreadable pane as a ready one.
diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/McpContractDocTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/McpContractDocTest.java
new file mode 100644
index 0000000..fbcf43e
--- /dev/null
+++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/McpContractDocTest.java
@@ -0,0 +1,121 @@
+package dev.ltms.fleet.mcp;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * fleetd #114 (CB-609): the guard that lets {@code docs/MCP-Contract.md} name a tool at all.
+ *
+ * That page was written in July 2026, before any MCP code existed, and then did not follow the
+ * code. By August it named two tools that had never been built, omitted five that shipped, had the
+ * wrong name for nearly every parameter, and still described a caller-identity rule that was a
+ * privilege bug by then. Nothing failed, because nothing checked it — and {@code CLAUDE.md} sends
+ * every session in the fleet to that page.
+ *
+ *
The fix was to delete the tool catalogue rather than correct it: a hand-maintained second copy
+ * of the tool surface is the defect, not the particular errors it had accumulated. What survives is
+ * the flows, which are shapes rather than names. But the flows still have to say {@code fleet_send}
+ * somewhere to be readable, and that is exactly the sentence that rots. This test is what makes it
+ * safe to write.
+ *
+ *
It checks source text, not behaviour. It reads the Markdown and reads {@link FleetMcp}'s
+ * source, and it only catches a name in the doc that the server does not register. It cannot catch a
+ * flow that describes the wrong order, or a parameter name in prose — those are not name-shaped. The
+ * doc's own header carries that caveat for its readers.
+ */
+class McpContractDocTest {
+
+ /** Tests run with the module directory as cwd, so the repo-root doc is one level up. */
+ private static final Path DOC = Path.of("../docs/MCP-Contract.md");
+ private static final Path MCP_SOURCE = Path.of("src/main/java/dev/ltms/fleet/mcp/FleetMcp.java");
+
+ private static Set matches(Path file, String regex) throws Exception {
+ Matcher m = Pattern.compile(regex).matcher(Files.readString(file));
+ Set found = new LinkedHashSet<>();
+ while (m.find()) {
+ found.add(m.group(1));
+ }
+ return found;
+ }
+
+ /** Every {@code fleet_*} the doc mentions, in prose or in a diagram. */
+ private static Set toolsNamedInTheDoc() throws Exception {
+ return matches(DOC, "(fleet_[a-z_]+)");
+ }
+
+ /** Every tool {@link FleetMcp} actually registers, read from its {@code tool("…")} calls. */
+ private static Set toolsTheServerRegisters() throws Exception {
+ return matches(MCP_SOURCE, "tool\\(\"(fleet_[a-z_]+)\"");
+ }
+
+ @Test
+ @DisplayName("[SOURCE TEXT] every fleet_* tool named in MCP-Contract.md is one the server registers")
+ void theDocNamesNoToolThatDoesNotExist() throws Exception {
+ Set registered = toolsTheServerRegisters();
+ Set named = toolsNamedInTheDoc();
+
+ Set unknown = new LinkedHashSet<>(named);
+ unknown.removeAll(registered);
+
+ assertTrue(unknown.isEmpty(),
+ "docs/MCP-Contract.md names " + unknown + ", which FleetMcp does not register. "
+ + "Checked " + named.size() + " name(s) in the doc against " + registered.size()
+ + " registered tool(s): " + registered + ". This is the fleetd #114 defect "
+ + "recurring — the doc named fleet_read and fleet_cancel for weeks after the "
+ + "code shipped without them. Either fix the name or drop it from the page; do "
+ + "NOT weaken this test.");
+ }
+
+ /**
+ * The denominator guard. The check above passes trivially if the doc stops naming any tool at
+ * all — an empty set is a subset of everything. A checker that can silently check nothing is the
+ * fleetd #113 shape, so this pins that the doc really is still describing the flows, and that
+ * the registration scrape really did find the server's tools.
+ */
+ @Test
+ @DisplayName("[SOURCE TEXT] the doc/server name check is not vacuous — both sides found names")
+ void theCheckActuallyHasSomethingToCheck() throws Exception {
+ Set registered = toolsTheServerRegisters();
+ Set named = toolsNamedInTheDoc();
+
+ assertTrue(registered.size() >= 10,
+ "scraped only " + registered.size() + " tool registrations from FleetMcp (" + registered
+ + "); the server registers eleven, so the tool(\"…\") scrape has stopped matching "
+ + "and the check above is now vacuous");
+ assertTrue(named.size() >= 4,
+ "docs/MCP-Contract.md names only " + named.size() + " fleet_* tool(s) (" + named + "). "
+ + "The flows describe delegation, clarification, detached delivery and the "
+ + "turn-done fallback, so it should name several. Too few means the page has been "
+ + "gutted and this test is guarding nothing.");
+ }
+
+ /**
+ * fleetd #114's actual lesson. The catalogue was deleted on purpose; a well-meaning "let me just
+ * document the tools here" restores the exact second copy that drifted for a month.
+ */
+ @Test
+ @DisplayName("[SOURCE TEXT] MCP-Contract.md still says it is not the tool reference")
+ void theDocStillDisclaimsBeingTheToolReference() throws Exception {
+ String doc = Files.readString(DOC);
+ assertTrue(doc.contains("**What this page is NOT: a tool reference.**"),
+ "docs/MCP-Contract.md must keep saying it is not the tool reference. That sentence is "
+ + "the fix for fleetd #114: the page carried a hand-maintained tool catalogue that "
+ + "drifted from the code for a month while CLAUDE.md pointed every session at it.");
+ assertEquals(0, countTables(doc.substring(0, doc.indexOf("## 1. Rendezvous flows"))),
+ "the header of docs/MCP-Contract.md must not grow a tool/parameter table — that is the "
+ + "second copy fleetd #114 deleted");
+ }
+
+ private static int countTables(String markdown) {
+ return (int) markdown.lines().filter(l -> l.strip().startsWith("|")).count();
+ }
+}