fleetd #790: let an observer pane fleet_send to a lead #792

Closed
agent wants to merge 0 commits from worker/790-observer-to-lead-send-523d45-1 into main
Member

Closes part of fleet/fleetd#790 — "allow observer to lead send".

What changed

An observer caller may now fleet_send to a lead terminal. It still may not reach a collaborator, an architect slot, or a spawned member.

  • auth/CallerResolver — sendableObserverTarget() renamed observerSendTarget() and widened. It reads the same maps resolve() reads, in the same order: a live spawned member is refused first; a configured lead is then accepted, even when the same pane is also bound to an architect slot (because that is the role resolve gives it); an architect-slot pane and a collaborator tab are refused; anything else is the observer floor and is accepted. The rename is because a predicate that accepts a lead can no longer be called a "sendable observer target".
  • auth/Authz — the SEND case is structurally unchanged; only the classifier it is handed widened. Param renamed knownObserverTarget → observerSendTarget, constant NO_KNOWN_OBSERVER_TARGET → NO_OBSERVER_SEND_TARGET, javadoc and the inline rule comment corrected.
  • auth/Role — the OBSERVER contract no longer says "only to a target that would itself resolve as OBSERVER".
  • mcp/FleetMcp — PaneSource.sendableToObserver renamed observerSendTarget; leadsVisibleTo/panesVisibleTo/paneRows/paneRow docs corrected; the fleet_list tool description now says where an observer reads a lead's sessionId.
  • rest/FleetApp — follows the rename; the gate already passed the real production classifier, so the REST route widens from the same predicate.

fleet_list visibility: panes, not leads

I added the lead rows to the observer's filtered panes array rather than giving an observer the leads array. Two reasons:

  1. paneRows already filters an observer's rows with the same predicate as the SEND gate, so "what I can see" and "what I can reach" cannot drift apart — which is exactly what CallerResolver's own javadoc promises. Adding leads would have been a second, separately-derived answer.
  2. A leads row carries the lead's name, its live context gauge and its configured CLAUDE_CONFIG_DIR — fleet shape, not an address. The observer's reduced pane row carries only sessionId, label, status, role, deliverable, so the observer learns the address and nothing more. role reads "lead", so it can tell a lead apart from a peer pane.

Gates traced (a grant at one gate of two is dead)

Gate Result
FleetMcp.denyFor → Authz.permits widened; the one change
FleetMcp.toolAction("fleet_send") maps to SEND for a sessionId call — unchanged
FleetMcp.sendAsync → profileTargetError rejects a configured profile name only; a lead terminal passes
MessageService.sendAsync records the caller's owner key; no role gate — nothing to widen
Injector readiness gate Fleetd.deliverableTo accepts a lead through the leads map, never a presence entry. Pinned by the new delivery test, which wires the production predicate and asserts the lead has no MemberPresence entry
Injector status gate unchanged; delivery still waits for IDLE
FleetApp.allow (REST) same predicate, same answer — covered by a route-level test

Two notes for review, neither changed here.

  1. The lead prompt-box gate does not sit on this path, and did not before. PromptBox.clearToSubmit is read only by ReplyPushLoop, LeadCoordLoop and LeadHeartbeatLoop — the ticket-nudge, coord-mail and heartbeat pushes. A direct fleet_send{sessionId: <lead terminal>} goes through MessageService → Injector, which has no PromptBox. So an observer's send to a lead gets exactly the gates a collaborator's send to a lead already got; this ticket opens no new hole. If the box gate should also cover the Injector's lead deliveries, that is a separate change for every sending role at once.
  2. An "unknown" terminal is not refused, and could not be without changing #743. The classifier has no pane-existence check: a terminal in none of the maps is the observer floor, which #743 deliberately made reachable. A genuinely nonexistent pane fails later on the readiness grace instead. The ticket body asks only that a spawned member, an architect and a collaborator stay refused, so I left this as-is rather than widen the scope.

Tests

  • CallerResolverTest — observerSendTargetIsTrueForALeadTerminal, observerSendTargetIsTrueForALeadTerminalThatIsAlsoABoundArchitectSlot (asserts the resolve premise too), observerSendTargetIsFalseForASpawnedMemberOnATerminalTheLeadMapAlsoNames, plus the existing refusals, each now carrying an in-test positive control.
  • FleetMcpAuthzTest — anObserverMaySendToALeadOrAnotherObserverButNeverToACollaboratorOrAMember, anObserverMayNotSendToABoundArchitectSlotEither, and anObserverReachingALeadStillHoldsNoTicketReadAndNoLifecycle (the widening is SEND only: no TASK_READ, SPAWN, STOP or COORD_SEND).
  • FleetMcpObserverSendToLeadDeliveryTest (new) — a real MCP client over a real HTTP transport, the real CallerResolver, the real MessageService and the real Injector with the production deliverableTo gate, asserting the lead's pane receives [fleet_send from observer term_shell]\ncan we split the review?, plus a same-server control that the collaborator terminal is still refused and opens no waiter.
  • FleetAppAuthTest — anObserverMaySendToAKnownLeadButNotToAKnownCollaboratorOverRest (202 vs 403).
  • FleetMcpTest — the observer's panes array now asserts the lead row is present with role: "lead", while the member, collaborator and architect panes stay absent and the row stays reduced.

No new source-text tests (CLAUDE.md code-quality rule 4).

Build

mvn clean install in fleetd/, unpiped:

[INFO] Tests run: 2208, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Not vacuous: reverting only the widening in CallerResolver.observerSendTarget() and re-running the five touched classes gives Tests run: 243, Failures: 7 — at least one failure in every one of them, on both the MCP and the REST surface.

Not done here

Docs are the lead's, per the ticket: CLAUDE.md invariant 3, the observer paragraph in "Which role am I", the unconfigured-pane row in the intent table, the wiki template sync and a Features entry. I touched neither CLAUDE.md nor wiki/.

Closes part of fleet/fleetd#790 — "allow observer to lead send". ## What changed An `observer` caller may now `fleet_send` to a **lead** terminal. It still may not reach a collaborator, an architect slot, or a spawned member. - `auth/CallerResolver` — `sendableObserverTarget()` renamed `observerSendTarget()` and widened. It reads the same maps `resolve()` reads, **in the same order**: a live spawned member is refused first; a configured lead is then accepted, even when the same pane is also bound to an architect slot (because that is the role `resolve` gives it); an architect-slot pane and a collaborator tab are refused; anything else is the observer floor and is accepted. The rename is because a predicate that accepts a lead can no longer be called a "sendable observer target". - `auth/Authz` — the `SEND` case is structurally unchanged; only the classifier it is handed widened. Param renamed `knownObserverTarget` → `observerSendTarget`, constant `NO_KNOWN_OBSERVER_TARGET` → `NO_OBSERVER_SEND_TARGET`, javadoc and the inline rule comment corrected. - `auth/Role` — the `OBSERVER` contract no longer says "only to a target that would itself resolve as OBSERVER". - `mcp/FleetMcp` — `PaneSource.sendableToObserver` renamed `observerSendTarget`; `leadsVisibleTo`/`panesVisibleTo`/`paneRows`/`paneRow` docs corrected; the `fleet_list` tool description now says where an observer reads a lead's `sessionId`. - `rest/FleetApp` — follows the rename; the gate already passed the real production classifier, so the REST route widens from the same predicate. ## fleet_list visibility: `panes`, not `leads` I added the lead rows to the observer's filtered **`panes`** array rather than giving an observer the `leads` array. Two reasons: 1. `paneRows` already filters an observer's rows with the *same* predicate as the `SEND` gate, so "what I can see" and "what I can reach" cannot drift apart — which is exactly what `CallerResolver`'s own javadoc promises. Adding `leads` would have been a second, separately-derived answer. 2. A `leads` row carries the lead's name, its live context gauge and its configured `CLAUDE_CONFIG_DIR` — fleet shape, not an address. The observer's reduced pane row carries only `sessionId`, `label`, `status`, `role`, `deliverable`, so the observer learns the address and nothing more. `role` reads `"lead"`, so it can tell a lead apart from a peer pane. ## Gates traced (a grant at one gate of two is dead) | Gate | Result | |---|---| | `FleetMcp.denyFor` → `Authz.permits` | widened; the one change | | `FleetMcp.toolAction("fleet_send")` | maps to `SEND` for a `sessionId` call — unchanged | | `FleetMcp.sendAsync` → `profileTargetError` | rejects a configured **profile name** only; a lead terminal passes | | `MessageService.sendAsync` | records the caller's owner key; **no role gate** — nothing to widen | | `Injector` readiness gate | `Fleetd.deliverableTo` accepts a lead through the `leads` map, never a presence entry. Pinned by the new delivery test, which wires the production predicate and asserts the lead has **no** `MemberPresence` entry | | `Injector` status gate | unchanged; delivery still waits for `IDLE` | | `FleetApp.allow` (REST) | same predicate, same answer — covered by a route-level test | **Two notes for review, neither changed here.** 1. **The lead prompt-box gate does not sit on this path, and did not before.** `PromptBox.clearToSubmit` is read only by `ReplyPushLoop`, `LeadCoordLoop` and `LeadHeartbeatLoop` — the ticket-nudge, coord-mail and heartbeat pushes. A direct `fleet_send{sessionId: <lead terminal>}` goes through `MessageService` → `Injector`, which has no `PromptBox`. So an observer's send to a lead gets exactly the gates a collaborator's send to a lead already got; this ticket opens no new hole. If the box gate should also cover the `Injector`'s lead deliveries, that is a separate change for every sending role at once. 2. **An "unknown" terminal is not refused, and could not be without changing #743.** The classifier has no pane-existence check: a terminal in none of the maps *is* the observer floor, which #743 deliberately made reachable. A genuinely nonexistent pane fails later on the readiness grace instead. The ticket body asks only that a spawned member, an architect and a collaborator stay refused, so I left this as-is rather than widen the scope. ## Tests - `CallerResolverTest` — `observerSendTargetIsTrueForALeadTerminal`, `observerSendTargetIsTrueForALeadTerminalThatIsAlsoABoundArchitectSlot` (asserts the `resolve` premise too), `observerSendTargetIsFalseForASpawnedMemberOnATerminalTheLeadMapAlsoNames`, plus the existing refusals, each now carrying an in-test positive control. - `FleetMcpAuthzTest` — `anObserverMaySendToALeadOrAnotherObserverButNeverToACollaboratorOrAMember`, `anObserverMayNotSendToABoundArchitectSlotEither`, and `anObserverReachingALeadStillHoldsNoTicketReadAndNoLifecycle` (the widening is `SEND` only: no `TASK_READ`, `SPAWN`, `STOP` or `COORD_SEND`). - `FleetMcpObserverSendToLeadDeliveryTest` (new) — a real MCP client over a real HTTP transport, the real `CallerResolver`, the real `MessageService` and the real `Injector` with the production `deliverableTo` gate, asserting the lead's pane receives `[fleet_send from observer term_shell]\ncan we split the review?`, plus a same-server control that the collaborator terminal is still refused and opens no waiter. - `FleetAppAuthTest` — `anObserverMaySendToAKnownLeadButNotToAKnownCollaboratorOverRest` (202 vs 403). - `FleetMcpTest` — the observer's `panes` array now asserts the lead row is present with `role: "lead"`, while the member, collaborator and architect panes stay absent and the row stays reduced. No new source-text tests (CLAUDE.md code-quality rule 4). ## Build `mvn clean install` in `fleetd/`, unpiped: ``` [INFO] Tests run: 2208, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS ``` **Not vacuous:** reverting only the widening in `CallerResolver.observerSendTarget()` and re-running the five touched classes gives `Tests run: 243, Failures: 7` — at least one failure in every one of them, on both the MCP and the REST surface. ## Not done here Docs are the lead's, per the ticket: `CLAUDE.md` invariant 3, the observer paragraph in "Which role am I", the unconfigured-pane row in the intent table, the wiki template sync and a Features entry. I touched neither `CLAUDE.md` nor `wiki/`.
agent added 1 commit 2026-10-06 06:21:37 +02:00
fleetd #790: let an observer pane fleet_send to a lead
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 54s
CI / build (pull_request) Failing after 2m7s
72ea7def0a
An observer could only reach another observer pane, so a hand-opened tab
could answer a lead with fleet_reply but never start a conversation with
one.

CallerResolver.sendableObserverTarget() is renamed observerSendTarget()
and now accepts a configured lead terminal as well as a pane that falls
to the observer floor. It reads the same maps resolve() reads, in the
same order: a live spawned member is refused first, a lead is then
accepted even when the same pane is also bound to an architect slot,
and a collaborator or an architect-slot pane is refused. A collaborator,
an architect and a spawned member stay unreachable.

Authz keeps its one SEND case; only the classifier it is handed widened,
so the MCP gate (FleetMcp.denyFor) and the REST gate (FleetApp.allow)
give the same answer from the same predicate.

fleet_list shows the lead's address in the observer's filtered `panes`
rows rather than by adding the `leads` array: the panes filter already
reads the same classifier as the SEND gate, so reachability and
visibility cannot drift, and the reduced row carries no lead name,
context window or config dir.

Gates traced end to end for an observer -> lead send: denyFor, the
sendTool argument validation (profileTargetError rejects profile names
only), MessageService (records the caller's owner key, no role gate),
the injector's readiness gate (Fleetd.deliverableTo accepts a lead
through the leads map, never a presence entry) and its status gate.
Unchanged: an observer still holds no TASK_READ, so it cannot poll the
ticket a wait:false send returns.

Tests: observer -> lead allowed and observer -> collaborator / architect
slot / spawned member refused over denyFor, each with a positive control
in the same test; the same matrix over the REST route; a real MCP client
through the real MessageService and Injector to the real herdr call,
proving the [fleet_send from observer term_...] prefix reaches a lead's
pane and that the lead passes the production readiness gate; and the
observer's fleet_list panes rows now carrying the lead.

mvn clean install in fleetd/: Tests run: 2208, Failures: 0, Errors: 0,
Skipped: 0 — BUILD SUCCESS. Reverting the widening in CallerResolver
alone turns 7 of the new assertions red across all five touched test
classes, so none of them passes vacuously.
ltms closed this pull request 2026-10-06 06:24:54 +02:00
Owner

Merged locally into main as eaa1f0d (merge of 72ea7de; merged tree equals the tested tree). Lead build of 72ea7de: mvn clean install exit 0, 2208 tests, 0 failures, 0 errors. Closing because Gitea did not mark it merged. Follow-up: #793.

Merged locally into main as eaa1f0d (merge of 72ea7de; merged tree equals the tested tree). Lead build of 72ea7de: mvn clean install exit 0, 2208 tests, 0 failures, 0 errors. Closing because Gitea did not mark it merged. Follow-up: #793.
Some checks are pending
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 54s
CI / build (pull_request) Failing after 2m7s

Pull request closed

Sign in to join this conversation.