An observer can send to another observer but cannot discover one — fleet_list's panes array is withheld from it #758

Closed
opened 2026-10-05 10:04:24 +02:00 by ltms · 1 comment
Owner

The last gap in the goal #743 was opened for: any agent pane with the fleet MCP mounted can message any other such pane, with no fleetd.yaml edit and no daemon restart.

#743 shipped the send half: Authz.SEND now admits caller.isObserver() && knownObserverTarget.test(target), and CallerResolver.sendableObserverTarget() restricts that to a target which is not a spawned member, not a lead, not bound to an architect slot, and not a collaborator. Live and working.

The discovery half is missing. FleetMcp.panesVisibleTo is:

static boolean panesVisibleTo(Principal caller) {
    return caller.isPrimary() || caller.isArchitect() || caller.isCollaborator();
}

So an observer gets no panes key at all — absent, not empty. It therefore cannot learn another observer's sessionId, which is the one argument fleet_send needs. The capability exists and is unreachable without a lead handing over an id out of band.

Its javadoc is also now false, and says so in as many words:

Visible to exactly the roles that may SEND to a named peer; a plain worker or an observer holds READ but never SEND, so it does not see this array.

An observer does hold SEND as of #743. The comment describes the rule it was written under, not the rule in force.

What the row must not leak

The row an observer receives cannot be the lead's row. Today paneRow projects sessionId, paneId, workspaceId, tabId, label, agentType, status, role, deliverable, and cwd when a member session has one. For an observer:

  • cwd must not appear. A spawned member's working directory is its worktree path; that is the lead's business.
  • paneId must not appear. It is the fleet_stop handle. An observer's stop is refused at the gate, so this is defence in depth rather than the only guard — but there is no reason to hand it over.
  • Rows must be filtered to what the observer may actually send to, using sendableObserverTarget — the same predicate the gate runs. An observer should not be enumerating leads, workers or collaborators at all.

What it needs is exactly: sessionId, label, status, role, deliverable.

Depends on #756

paneRole does not consult the architect-slot source, so a pane bound to a configured architect slot with no live member reports role: "observer" while the SEND gate refuses it as an architect. Filtering this array by role is unsafe until the row's role and the gate's decision read one source. #756 fixes that; this ticket should land on top of it.

Not reopening #705

#705's hazard was an observer reaching ticket state. Authz.TASK_READ remains isPrimary() || isWorker() || isArchitect() and COORD_SEND remains isPrimary() || isArchitect(). This change touches fleet_list's panes key only and must leave both alone.

The last gap in the goal #743 was opened for: *any agent pane with the fleet MCP mounted can message any other such pane, with no `fleetd.yaml` edit and no daemon restart.* #743 shipped the **send** half: `Authz.SEND` now admits `caller.isObserver() && knownObserverTarget.test(target)`, and `CallerResolver.sendableObserverTarget()` restricts that to a target which is not a spawned member, not a lead, not bound to an architect slot, and not a collaborator. Live and working. The **discovery** half is missing. `FleetMcp.panesVisibleTo` is: ```java static boolean panesVisibleTo(Principal caller) { return caller.isPrimary() || caller.isArchitect() || caller.isCollaborator(); } ``` So an observer gets **no `panes` key at all** — absent, not empty. It therefore cannot learn another observer's `sessionId`, which is the one argument `fleet_send` needs. The capability exists and is unreachable without a lead handing over an id out of band. Its javadoc is also now false, and says so in as many words: > *Visible to exactly the roles that may `SEND` to a named peer; a plain worker or an observer holds `READ` but never `SEND`, so it does not see this array.* An observer does hold `SEND` as of #743. The comment describes the rule it was written under, not the rule in force. ## What the row must not leak The row an observer receives cannot be the lead's row. Today `paneRow` projects `sessionId`, `paneId`, `workspaceId`, `tabId`, `label`, `agentType`, `status`, `role`, `deliverable`, and `cwd` when a member session has one. For an observer: - **`cwd` must not appear.** A spawned member's working directory is its worktree path; that is the lead's business. - **`paneId` must not appear.** It is the `fleet_stop` handle. An observer's stop is refused at the gate, so this is defence in depth rather than the only guard — but there is no reason to hand it over. - **Rows must be filtered to what the observer may actually send to**, using `sendableObserverTarget` — the same predicate the gate runs. An observer should not be enumerating leads, workers or collaborators at all. What it needs is exactly: `sessionId`, `label`, `status`, `role`, `deliverable`. ## Depends on #756 `paneRole` does not consult the architect-slot source, so a pane bound to a configured architect slot with no live member reports `role: "observer"` while the SEND gate refuses it as an architect. Filtering this array by role is unsafe until the row's role and the gate's decision read one source. #756 fixes that; this ticket should land on top of it. ## Not reopening #705 #705's hazard was an observer reaching ticket state. `Authz.TASK_READ` remains `isPrimary() || isWorker() || isArchitect()` and `COORD_SEND` remains `isPrimary() || isArchitect()`. This change touches `fleet_list`'s `panes` key only and must leave both alone.
Author
Owner

Fixed and merged in PR #762, on main at 5f7f388.

An observer now gets the panes key. FleetMcp.panesVisibleTo adds caller.isObserver(), and the array an observer sees is a different array, not the same one:

  • Filtered — paneRows drops every row the observer may not send to, using CallerResolver.sendableObserverTarget, the same predicate the SEND gate runs. So an observer sees no lead, no spawned member, no architect-slot pane and no collaborator.
  • Reduced — paneRow suppresses paneId (the fleet_stop handle), workspaceId, tabId, agentType and cwd. What is left is sessionId, label, status, role, deliverable.

A worker is unchanged: it still gets no panes key at all. The pre-existing authz test was renamed and flipped for the observer, and I checked it kept its controls — assertFalse for WORKER_A and for ANON both survive.

This closes the last missing half of observer-to-observer messaging. #743 gave an observer the SEND; it had no way to learn a target's terminal id, so the capability was reachable only by being told the id out of band.

Verified by me: tree-hash parity on the merge, the +3 test count reconciled against the diff, and 5 mutations run. Four killed. The fifth deletes the cwd suppression and survives the whole suite — honestly reported rather than papered over. The cause is structural, not a missing test: cwd is written only for a pane with a live MemberSession, and the filter above already removes every spawned member, so no observer row can reach that line. The guard is correct defence in depth if the filter is ever widened, but no assertion can cover it today. Recorded as a gotcha in the wiki Features entry so nobody later reads it as coverage it does not have.

Instruction surface updated: wiki/11-Features.md at wiki 4872227, and CLAUDE.md + wiki/7-Use-Cases.md at 92adfcf — the observer definition now says where an observer finds a target, and the table row no longer claims fleet_list cannot list these panes. in sync: True.

Not yet live — needs scripts/redeploy-fleetd.sh.

Fixed and merged in PR #762, on `main` at `5f7f388`. An observer now gets the `panes` key. `FleetMcp.panesVisibleTo` adds `caller.isObserver()`, and the array an observer sees is a **different** array, not the same one: - **Filtered** — `paneRows` drops every row the observer may not send to, using `CallerResolver.sendableObserverTarget`, the same predicate the `SEND` gate runs. So an observer sees no lead, no spawned member, no architect-slot pane and no collaborator. - **Reduced** — `paneRow` suppresses `paneId` (the `fleet_stop` handle), `workspaceId`, `tabId`, `agentType` and `cwd`. What is left is `sessionId`, `label`, `status`, `role`, `deliverable`. A worker is unchanged: it still gets no `panes` key at all. The pre-existing authz test was renamed and flipped for the observer, and I checked it kept its controls — `assertFalse` for `WORKER_A` and for `ANON` both survive. This closes the last missing half of observer-to-observer messaging. #743 gave an observer the `SEND`; it had no way to learn a target's terminal id, so the capability was reachable only by being told the id out of band. Verified by me: tree-hash parity on the merge, the +3 test count reconciled against the diff, and 5 mutations run. Four killed. The fifth deletes the `cwd` suppression and **survives the whole suite** — honestly reported rather than papered over. The cause is structural, not a missing test: `cwd` is written only for a pane with a live `MemberSession`, and the filter above already removes every spawned member, so no observer row can reach that line. The guard is correct defence in depth if the filter is ever widened, but no assertion can cover it today. Recorded as a gotcha in the wiki Features entry so nobody later reads it as coverage it does not have. Instruction surface updated: `wiki/11-Features.md` at wiki `4872227`, and `CLAUDE.md` + `wiki/7-Use-Cases.md` at `92adfcf` — the observer definition now says where an observer finds a target, and the table row no longer claims `fleet_list` cannot list these panes. `in sync: True`. **Not yet live** — needs `scripts/redeploy-fleetd.sh`.
ltms closed this issue 2026-10-05 10:40:17 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#758