A lead cannot discover a collaborator: fleet_list reports only leads and members #703

Closed
opened 2026-10-04 01:58:38 +02:00 by ltms · 2 comments
Owner

What

After #669 Unit D a collaborator can find and message a lead, but a lead has no way to find a collaborator. The channel works in one direction until the collaborator speaks first.

Measured on main at c3e3554:

$ grep -n '"leads"\|"members"\|"collaborators"' fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java
1884:            result.put("leads", leadRows); result.put("members", out);

There is no collaborators key. The leads rows are built from the leads map argument, and the call site passes the lead-only map:

// FleetMcp.java:561
leadSeats, leadContextGauge, leadConfigDirs, callers.leads(),

CallerResolver.collaborators() exists at CallerResolver.java:269 and has no caller outside the resolver itself:

$ grep -rn 'collaborators()' fleetd/src/main/java --include='*.java'
fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java:260:        var collaboratorsConfig = cfg.fleet().collaborators();
fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java:2756   (validation)
fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java:2804   (validation)
...
fleetd/src/main/java/dev/ltms/fleet/auth/CallerResolver.java:269:    public Map<String, String> collaborators() {
fleetd/src/main/java/dev/ltms/fleet/herdr/LeadTabScanner.java:199:    public synchronized Map<String, String> collaborators() {

The grep's positive control fires — it finds the validation and definition sites — so the absence of a consumer is a real zero, not a broken pattern. knownLeadOrCollaborator() reads the collaboratorTerminals field directly rather than going through collaborators(), which is why the accessor ended up with no caller.

Why it matters

A fleet_send needs the target's sessionId. A collaborator gets its own from fleet_whoami and can read every lead from fleet_list, so it can open an exchange. A lead has nowhere to read a collaborator's sessionId from. So the only way a lead reaches a collaborator is if the collaborator messaged it first, or a person copies the id across by hand.

This is the first thing #669's own requested walk-through (item 6) would hit, and it is the feature's stated purpose — "let named sessions talk to each other" — working one way only.

I have written the gap into CLAUDE.md's intent table and the Features entry rather than leaving it to be discovered, so nothing currently promises that discovery works. That is documentation of a limitation, not a fix.

Suggested shape

Add a collaborators array to fleet_list, built from callers.collaborators(), with the same liveness behaviour the lead rows already get — a tab nobody has open should not be listed. Two decisions worth settling before implementing:

  1. Who may see it. fleet_list is READ, which a worker also holds. Listing collaborator panes to workers widens what a worker learns about the host. The leads rows are already visible to a worker, so there may be no new exposure, but it should be a decision rather than a side effect. Note coordinatorVisibleTo(principal) already exists as the precedent for narrowing one part of this payload by role.
  2. What each row carries. A lead row carries context and seat information that has no meaning for a collaborator. The minimum useful row is the registry name plus the sessionId.

Not urgent: nothing is broken for the fleet as it runs today, because no fleet.collaborators entry is enabled on this host. It blocks the feature being usable as described.

Found while writing the #669 Unit F instruction surface.

## What After #669 Unit D a collaborator can find and message a lead, but a lead has no way to find a collaborator. The channel works in one direction until the collaborator speaks first. Measured on `main` at `c3e3554`: ``` $ grep -n '"leads"\|"members"\|"collaborators"' fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java 1884: result.put("leads", leadRows); result.put("members", out); ``` There is no `collaborators` key. The `leads` rows are built from the `leads` map argument, and the call site passes the lead-only map: ```java // FleetMcp.java:561 leadSeats, leadContextGauge, leadConfigDirs, callers.leads(), ``` `CallerResolver.collaborators()` exists at `CallerResolver.java:269` and has no caller outside the resolver itself: ``` $ grep -rn 'collaborators()' fleetd/src/main/java --include='*.java' fleetd/src/main/java/dev/ltms/fleet/FleetdAssembly.java:260: var collaboratorsConfig = cfg.fleet().collaborators(); fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java:2756 (validation) fleetd/src/main/java/dev/ltms/fleet/config/FleetConfig.java:2804 (validation) ... fleetd/src/main/java/dev/ltms/fleet/auth/CallerResolver.java:269: public Map<String, String> collaborators() { fleetd/src/main/java/dev/ltms/fleet/herdr/LeadTabScanner.java:199: public synchronized Map<String, String> collaborators() { ``` The grep's positive control fires — it finds the validation and definition sites — so the absence of a consumer is a real zero, not a broken pattern. `knownLeadOrCollaborator()` reads the `collaboratorTerminals` field directly rather than going through `collaborators()`, which is why the accessor ended up with no caller. ## Why it matters A `fleet_send` needs the target's `sessionId`. A collaborator gets its own from `fleet_whoami` and can read every lead from `fleet_list`, so it can open an exchange. A lead has nowhere to read a collaborator's `sessionId` from. So the only way a lead reaches a collaborator is if the collaborator messaged it first, or a person copies the id across by hand. This is the first thing #669's own requested walk-through (item 6) would hit, and it is the feature's stated purpose — "let named sessions talk to each other" — working one way only. I have written the gap into `CLAUDE.md`'s intent table and the Features entry rather than leaving it to be discovered, so nothing currently promises that discovery works. That is documentation of a limitation, not a fix. ## Suggested shape Add a `collaborators` array to `fleet_list`, built from `callers.collaborators()`, with the same liveness behaviour the lead rows already get — a tab nobody has open should not be listed. Two decisions worth settling before implementing: 1. **Who may see it.** `fleet_list` is `READ`, which a worker also holds. Listing collaborator panes to workers widens what a worker learns about the host. The `leads` rows are already visible to a worker, so there may be no new exposure, but it should be a decision rather than a side effect. Note `coordinatorVisibleTo(principal)` already exists as the precedent for narrowing one part of this payload by role. 2. **What each row carries.** A lead row carries context and seat information that has no meaning for a collaborator. The minimum useful row is the registry name plus the `sessionId`. Not urgent: nothing is broken for the fleet as it runs today, because no `fleet.collaborators` entry is enabled on this host. It blocks the feature being usable as described. Found while writing the #669 Unit F instruction surface.
Author
Owner

The two open decisions are settled, and this is delegated

The ticket asked for two things to be settled before implementing. Both are decided, so nobody
has to re-litigate them.

1. Who may see the collaborators array: primary, architect, collaborator. Not a worker.

fleet_list is gated on READ, which a worker also holds, so this has to be narrowed inside the
payload rather than by the action. coordinatorVisibleTo(principal) is the precedent and the
shape to copy.

The rule is: the roles that may see a collaborator row are exactly the roles that can act on it.
Authz.java:112-113 gives SEND to the primary, an architect, and a collaborator whose target is
itself a named peer. A worker cannot send to a collaborator at all, so a row would tell a worker
which human tabs exist on this host and give it nothing it can use. The ticket was right that the
leads rows are already visible to a worker, so this is not a new kind of exposure — but
"already leaking something similar" is not a reason to add more, and the decision should be made
on purpose rather than inherited.

2. Each row carries the registry name and the sessionId, nothing else.

No context gauge, no seat information, no profile. A collaborator is never spawned and has no
profile, so those fields would be empty or meaningless.

On liveness, the ticket asked for "the same liveness behaviour the lead rows already get". No
extra work is needed: the scan only reports tabs it actually found in herdr, so a tab nobody has
open never appears. I have asked the implementer to confirm that by reading the scanner rather
than taking it from me.

Delegated

Running now as ticket task-10, on a dev member with the implementer skill. Acceptance
criteria are written as properties under a change, each with a control half — in particular the
visibility test must assert both that a worker sees nothing and that an architect does see it,
because a test that only checks the worker is hidden passes just as well if the feature was never
built for anyone.

One correction to the ticket's framing, for the record: the implementer cannot read this ticket.
A member's forge MCP server holds a deliberately blocked credential, so every call fails. The
brief therefore carries these decisions in full rather than pointing here.

## The two open decisions are settled, and this is delegated The ticket asked for two things to be settled before implementing. Both are decided, so nobody has to re-litigate them. ### 1. Who may see the `collaborators` array: primary, architect, collaborator. Not a worker. `fleet_list` is gated on `READ`, which a worker also holds, so this has to be narrowed inside the payload rather than by the action. `coordinatorVisibleTo(principal)` is the precedent and the shape to copy. The rule is: the roles that may see a collaborator row are exactly the roles that can act on it. `Authz.java:112-113` gives `SEND` to the primary, an architect, and a collaborator whose target is itself a named peer. A worker cannot send to a collaborator at all, so a row would tell a worker which human tabs exist on this host and give it nothing it can use. The ticket was right that the `leads` rows are already visible to a worker, so this is not a new *kind* of exposure — but "already leaking something similar" is not a reason to add more, and the decision should be made on purpose rather than inherited. ### 2. Each row carries the registry name and the `sessionId`, nothing else. No context gauge, no seat information, no profile. A collaborator is never spawned and has no profile, so those fields would be empty or meaningless. On liveness, the ticket asked for "the same liveness behaviour the lead rows already get". No extra work is needed: the scan only reports tabs it actually found in herdr, so a tab nobody has open never appears. I have asked the implementer to confirm that by reading the scanner rather than taking it from me. ### Delegated Running now as ticket `task-10`, on a `dev` member with the `implementer` skill. Acceptance criteria are written as properties under a change, each with a control half — in particular the visibility test must assert **both** that a worker sees nothing and that an architect does see it, because a test that only checks the worker is hidden passes just as well if the feature was never built for anyone. One correction to the ticket's framing, for the record: the implementer cannot read this ticket. A member's forge MCP server holds a deliberately blocked credential, so every call fails. The brief therefore carries these decisions in full rather than pointing here.
Author
Owner

Done and verified in main — closing

Landed as PR #709 (task-10). I checked origin/main myself rather than taking it from the PR:

  • FleetMcp.java:759 — static boolean collaboratorsVisibleTo(Principal caller), the named
    predicate this ticket asked for, in the coordinatorVisibleTo shape.
  • FleetMcp.java:563 — the real handler passes
    callers.collaborators(), collaboratorsVisibleTo(principal(exchange)). This is the production
    call site, not a test wrapper.
  • FleetMcpAuthzTest.theFleetListHandlerActuallyConsultsCollaboratorsVisibleTo exists, with its
    own control assertion, so the wiring cannot be quietly removed.

Both decisions in the comment above shipped as decided: the array is visible to a primary, an
architect and a collaborator but never to a worker, and each row carries only the registry name
and the sessionId.

Two follow-ups, so they are not lost here:

  • The instruction surface was wrong about this and is now fixed. CLAUDE.md used to say
    "fleet_list does not report collaborators, so you cannot discover one". That became false
    the moment this merged. Corrected in 95311c6, propagated to the wiki's copy of the canonical
    block, and the sync check prints True.
  • wiki/11-Features.md had a matching stale gotcha; also corrected.

Separately, and after this ticket: #710 finding 1 narrowed the other two arrays. A worker now
gets neither leads nor members — keys absent, not empty. Measured live on the running daemon
after today's redeploy: a sonnet worker's fleet_list returned exactly
healthCoverage, loopHealth, capacity and nothing else. So the visibility rule this ticket
established — you may list what you could address — is now applied consistently across all three
arrays.

## Done and verified in `main` — closing Landed as PR #709 (task-10). I checked `origin/main` myself rather than taking it from the PR: - `FleetMcp.java:759` — `static boolean collaboratorsVisibleTo(Principal caller)`, the named predicate this ticket asked for, in the `coordinatorVisibleTo` shape. - `FleetMcp.java:563` — the real handler passes `callers.collaborators(), collaboratorsVisibleTo(principal(exchange))`. This is the production call site, not a test wrapper. - `FleetMcpAuthzTest.theFleetListHandlerActuallyConsultsCollaboratorsVisibleTo` exists, with its own control assertion, so the wiring cannot be quietly removed. Both decisions in the comment above shipped as decided: the array is visible to a primary, an architect and a collaborator but never to a worker, and each row carries only the registry name and the `sessionId`. Two follow-ups, so they are not lost here: - The instruction surface was wrong about this and is now fixed. `CLAUDE.md` used to say "**`fleet_list` does not report collaborators**, so you cannot discover one". That became false the moment this merged. Corrected in `95311c6`, propagated to the wiki's copy of the canonical block, and the sync check prints `True`. - `wiki/11-Features.md` had a matching stale gotcha; also corrected. Separately, and after this ticket: #710 finding 1 narrowed the **other** two arrays. A worker now gets neither `leads` nor `members` — keys absent, not empty. Measured live on the running daemon after today's redeploy: a `sonnet` worker's `fleet_list` returned exactly `healthCoverage`, `loopHealth`, `capacity` and nothing else. So the visibility rule this ticket established — you may list what you could address — is now applied consistently across all three arrays.
ltms closed this issue 2026-10-04 07:58:00 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#703