fleet_list's panes[].role says "observer" for a bound architect slot that the SEND gate refuses as an architect #756

Closed
opened 2026-10-05 09:45:20 +02:00 by ltms · 1 comment
Owner

Found while adjudicating #743's two PRs after merge (02eff4c). Not a security hole — the gate is the strict side and fails closed. It is a status field that disagrees with the behaviour it describes.

The disagreement

FleetMcp.paneRole (fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java:2508) decides a pane's reported role from three sources and nothing else:

private static String paneRole(String terminal, MemberSession session, Map<String, String> leads,
        Map<String, String> collaborators) {
    if (session != null) {
        return session.role().wireName();
    }
    if (leads.containsKey(terminal)) {
        return "lead";
    }
    if (collaborators.containsKey(terminal)) {
        return "collaborator";
    }
    return "observer";
}

It never consults architectTerminals. So a pane bound to a configured architect slot with no live member session is reported as "observer".

The SEND gate from the other PR disagrees. CallerResolver.sendableObserverTarget() excludes exactly that pane:

public Predicate<String> sendableObserverTarget() {
    return target -> target != null
            && spawnedMemberRole.apply(target) == null
            && !leadTerminals.get().containsKey(target)
            && !boundToArchitectSlot(target)        // <-- paneRole has no equivalent
            && !collaboratorTerminals.get().containsKey(target);
}

So for one specific pane, fleet_list says role: "observer" while an observer's fleet_send to it is refused. A lead reading the array to decide who can talk to whom gets the wrong answer.

Both halves shipped in the same ticket and neither author could see the other's: #753's PR body self-reported the architect-slot case as a known limitation of its first cut, and #754 added the gate afterwards. The limitation only became a contradiction when the gate landed.

Why it is the interesting kind of defect

The same PR got the neighbouring field right. deliverable does not re-derive reachability — it calls Fleetd.deliverableTo, the exact predicate the injector enforces, and the javadoc says so. One field reads the source the behaviour reads; the field beside it re-implements a narrower version from scratch. That is the pattern worth naming, not the missing branch.

Fix

Give paneRole the architect case, from the same source the gate uses rather than a fourth copy of the rule. CallerResolver already holds boundToArchitectSlot, which is private; the cheapest honest fix is to expose the classification once and have both callers read it, so the two cannot drift again.

Not just adding if (architects.containsKey(terminal)) return "architect"; — that would be a third definition of "is an architect", and boundToArchitectSlot is deliberately stricter than a bare map hit: it also requires memberSlotRoles.apply(slot) == MemberRole.ARCHITECT. A copy that drops that condition would report architect for a slot configured as something else.

Test it by behaviour, not by text

Rule 4 of the code-quality policy bans a new source-text test, and this is a case where the behavioural route is easy: assert that for one terminal, fleet_list's row and Authz.permits(observer, SEND, thatTerminal, …) agree. That is the assertion that actually matters, and it fails today.

Not measured

I did not run a live probe with a configured-but-unoccupied architect slot. I read paneRole, sendableObserverTarget and boundToArchitectSlot and compared their conditions. CallerResolverTest already covers the gate's side ("false for a terminal bound to a configured architect slot with no live session"), so that half is pinned; it is paneRole's side I have not exercised.

Found while adjudicating #743's two PRs after merge (`02eff4c`). Not a security hole — the gate is the strict side and fails closed. It is a status field that disagrees with the behaviour it describes. ## The disagreement `FleetMcp.paneRole` (`fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java:2508`) decides a pane's reported role from three sources and nothing else: ```java private static String paneRole(String terminal, MemberSession session, Map<String, String> leads, Map<String, String> collaborators) { if (session != null) { return session.role().wireName(); } if (leads.containsKey(terminal)) { return "lead"; } if (collaborators.containsKey(terminal)) { return "collaborator"; } return "observer"; } ``` It never consults `architectTerminals`. So a pane bound to a configured architect slot with **no live member session** is reported as `"observer"`. The SEND gate from the other PR disagrees. `CallerResolver.sendableObserverTarget()` excludes exactly that pane: ```java public Predicate<String> sendableObserverTarget() { return target -> target != null && spawnedMemberRole.apply(target) == null && !leadTerminals.get().containsKey(target) && !boundToArchitectSlot(target) // <-- paneRole has no equivalent && !collaboratorTerminals.get().containsKey(target); } ``` So for one specific pane, `fleet_list` says `role: "observer"` while an observer's `fleet_send` to it is refused. A lead reading the array to decide who can talk to whom gets the wrong answer. Both halves shipped in the same ticket and neither author could see the other's: #753's PR body self-reported the architect-slot case as a known limitation of its first cut, and #754 added the gate afterwards. The limitation only became a *contradiction* when the gate landed. ## Why it is the interesting kind of defect The same PR got the neighbouring field right. `deliverable` does not re-derive reachability — it calls `Fleetd.deliverableTo`, the exact predicate the injector enforces, and the javadoc says so. One field reads the source the behaviour reads; the field beside it re-implements a narrower version from scratch. That is the pattern worth naming, not the missing branch. ## Fix Give `paneRole` the architect case, from the same source the gate uses rather than a fourth copy of the rule. `CallerResolver` already holds `boundToArchitectSlot`, which is private; the cheapest honest fix is to expose the classification once and have both callers read it, so the two cannot drift again. Not just adding `if (architects.containsKey(terminal)) return "architect";` — that would be a third definition of "is an architect", and `boundToArchitectSlot` is deliberately stricter than a bare map hit: it also requires `memberSlotRoles.apply(slot) == MemberRole.ARCHITECT`. A copy that drops that condition would report `architect` for a slot configured as something else. ## Test it by behaviour, not by text Rule 4 of the code-quality policy bans a new source-text test, and this is a case where the behavioural route is easy: assert that for one terminal, `fleet_list`'s row and `Authz.permits(observer, SEND, thatTerminal, …)` agree. That is the assertion that actually matters, and it fails today. ## Not measured I did not run a live probe with a configured-but-unoccupied architect slot. I read `paneRole`, `sendableObserverTarget` and `boundToArchitectSlot` and compared their conditions. `CallerResolverTest` already covers the gate's side ("false for a terminal bound to a configured architect slot with no live session"), so that half is pinned; it is `paneRole`'s side I have not exercised.
Author
Owner

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

FleetMcp.paneRole now calls CallerResolver.boundToArchitectSlot, the same method the SEND gate runs, and tests the roles in the same order as CallerResolver.resolve: spawned member, lead, architect slot, collaborator, observer. The method was widened private → public for that, so the row and the gate read one source and cannot drift again.

Verified by me, not taken from the worker's report:

  • Merged in a throwaway worktree and compared git rev-parse 'HEAD^{tree}' against the tested tree — equal, so the merged bytes are the bytes that passed.
  • Test count: main 2137 → 2140. The diff adds 4 void test methods and removes 1 by rename, so +3 is the arithmetic, not a dropped test.
  • Mutation-tested the fix with a working baseline. I ran 5 mutations; 4 were killed. listReportsArchitectForASlotBoundPaneWithNoLiveMember is the test that kills the one that matters.

The one survivor is not this ticket's fix: it was the cwd suppression from #758, and it survives because cwd is written only for a pane with a live MemberSession while the observer filter already drops every spawned member. The state is unconstructable, so the guard is unreachable rather than untested. Recorded as a known limit in the wiki Features entry.

Instruction surface updated, as the project's "the prompt is part of the product" rule requires: wiki/11-Features.md (wiki 4872227) now describes the role source, and CLAUDE.md + wiki/7-Use-Cases.md (92adfcf) no longer claim fleet_list cannot list an unconfigured pane. The sync check prints in sync: True.

Not yet live. The running daemon holds the jar it started with, so this needs scripts/redeploy-fleetd.sh. Closing the ticket as fixed on main, with the redeploy still outstanding.

Fixed and merged in PR #762, on `main` at `5f7f388`. `FleetMcp.paneRole` now calls `CallerResolver.boundToArchitectSlot`, the same method the `SEND` gate runs, and tests the roles in the same order as `CallerResolver.resolve`: spawned member, lead, architect slot, collaborator, observer. The method was widened `private` → `public` for that, so the row and the gate read one source and cannot drift again. Verified by me, not taken from the worker's report: - Merged in a throwaway worktree and compared `git rev-parse 'HEAD^{tree}'` against the tested tree — equal, so the merged bytes are the bytes that passed. - Test count: main 2137 → 2140. The diff adds 4 `void` test methods and removes 1 by rename, so +3 is the arithmetic, not a dropped test. - Mutation-tested the fix with a working baseline. I ran 5 mutations; 4 were killed. `listReportsArchitectForASlotBoundPaneWithNoLiveMember` is the test that kills the one that matters. The one survivor is **not** this ticket's fix: it was the `cwd` suppression from #758, and it survives because `cwd` is written only for a pane with a live `MemberSession` while the observer filter already drops every spawned member. The state is unconstructable, so the guard is unreachable rather than untested. Recorded as a known limit in the wiki Features entry. Instruction surface updated, as the project's "the prompt is part of the product" rule requires: `wiki/11-Features.md` (wiki `4872227`) now describes the role source, and `CLAUDE.md` + `wiki/7-Use-Cases.md` (`92adfcf`) no longer claim `fleet_list` cannot list an unconfigured pane. The sync check prints `in sync: True`. **Not yet live.** The running daemon holds the jar it started with, so this needs `scripts/redeploy-fleetd.sh`. Closing the ticket as fixed on `main`, with the redeploy still outstanding.
ltms closed this issue 2026-10-05 10:40:14 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#756