A collaborator cannot read a ticket it created, so its own delegation's reply is unreachable #804

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

Reported by the #778 worker in PR #795's Part 3 survey as an open gap left out of scope. Filing it so it does not stay only in a closed PR body. I checked the claim in the merged code before filing.

The gap

Authz's full grant for TASK_READ after #778 is:

case TASK_READ -> caller.isPrimary() || caller.isWorker() || caller.isArchitect()
        || (caller.isObserver() && observerOwnsTicket.test(targetSession));

A collaborator appears nowhere in that line, so it is refused TASK_READ for every ticket — including one it created itself.

But a collaborator can create a ticket: SEND's knownLeadOrCollaborator classifier permits collaborator→collaborator, so fleet_send{sessionId, wait:false} to another collaborator returns a ticket. That ticket is then unreadable by the only caller that owns it. The reply has no route back.

Two consequences:

  1. The reply is simply lost to the sender. CLAUDE.md already tells a collaborator "you cannot read a ticket, so you cannot collect a delegation's reply" — but it frames that as a deliberate limit tied to not reaching a worker. For a collaborator→collaborator send, which is explicitly allowed, there is no such reason.
  2. When that ticket goes terminal, ReplyPushLoop.onTicketTerminal nudges the creator to run fleet_poll{ticket}, which Authz always refuses it. That is exactly the shape #778 Part 2 fixed for reply and question nudges, and ticket nudges were left ungated.

Why this should be granted, not just documented

The argument that justified #778 for an observer applies unchanged: the caller created the ticket, nobody else can read it, and the ownership classifier already confines the grant to that one ticket. Refusing the creator makes a permitted send useless.

I am deciding this rather than asking the operator, since it is a fleet design call and the precedent is already set by #778.

Suggested change

  1. Authz: add the collaborator to the conditional grant, confined by the same ownership classifier — a collaborator may TASK_READ only a ticket its own terminal created, nothing wider. MessageService.ownsTicket already compares collaborator:<name> owner keys correctly, so no new ownership logic is needed.
  2. Gate the ticket nudge the way #778 gated reply and question nudges, so no caller is ever told to run a fleet_poll{ticket} its role would refuse.
  3. Update the CLAUDE.md collaborator section: the "you cannot read a ticket" line becomes "you may read a ticket you created, and nothing wider", and the intent→tool table gains the row. Then sync wiki/7-Use-Cases.md and add the 11-Features.md entry.

Related

  • #778 / PR #795 — the observer half of the same grant, merged as 52cd047.
  • #803 — a null-ticket NullPointerException in the same new predicate, found while reviewing #795.

I asked Claude to check this against the merged code; the grant line quoted above is from main at 52cd047.

Reported by the #778 worker in PR #795's Part 3 survey as an open gap left out of scope. Filing it so it does not stay only in a closed PR body. I checked the claim in the merged code before filing. ## The gap `Authz`'s full grant for `TASK_READ` after #778 is: ```java case TASK_READ -> caller.isPrimary() || caller.isWorker() || caller.isArchitect() || (caller.isObserver() && observerOwnsTicket.test(targetSession)); ``` A **collaborator** appears nowhere in that line, so it is refused `TASK_READ` for every ticket — including one it created itself. But a collaborator *can* create a ticket: `SEND`'s `knownLeadOrCollaborator` classifier permits collaborator→collaborator, so `fleet_send{sessionId, wait:false}` to another collaborator returns a ticket. That ticket is then unreadable by the only caller that owns it. The reply has no route back. Two consequences: 1. The reply is simply lost to the sender. `CLAUDE.md` already tells a collaborator "you cannot read a ticket, so you cannot collect a delegation's reply" — but it frames that as a deliberate limit tied to not reaching a worker. For a collaborator→collaborator send, which is explicitly allowed, there is no such reason. 2. When that ticket goes terminal, `ReplyPushLoop.onTicketTerminal` nudges the creator to run `fleet_poll{ticket}`, which `Authz` always refuses it. That is exactly the shape #778 Part 2 fixed for reply and question nudges, and ticket nudges were left ungated. ## Why this should be granted, not just documented The argument that justified #778 for an observer applies unchanged: the caller created the ticket, nobody else can read it, and the ownership classifier already confines the grant to that one ticket. Refusing the creator makes a permitted send useless. I am deciding this rather than asking the operator, since it is a fleet design call and the precedent is already set by #778. ## Suggested change 1. `Authz`: add the collaborator to the conditional grant, confined by the same ownership classifier — a collaborator may `TASK_READ` only a ticket its own terminal created, nothing wider. `MessageService.ownsTicket` already compares `collaborator:<name>` owner keys correctly, so no new ownership logic is needed. 2. Gate the ticket nudge the way #778 gated reply and question nudges, so no caller is ever told to run a `fleet_poll{ticket}` its role would refuse. 3. Update the `CLAUDE.md` collaborator section: the "you cannot read a ticket" line becomes "you may read a ticket you created, and nothing wider", and the intent→tool table gains the row. Then sync `wiki/7-Use-Cases.md` and add the `11-Features.md` entry. ## Related - #778 / PR #795 — the observer half of the same grant, merged as `52cd047`. - #803 — a null-ticket `NullPointerException` in the same new predicate, found while reviewing #795. --- I asked Claude to check this against the merged code; the grant line quoted above is from `main` at `52cd047`.
Author
Owner

Fixed and merged as 2aacf07 on main (PR #806, now closed).

Authz's TASK_READ case now reads:

case TASK_READ -> caller.isPrimary() || caller.isWorker() || caller.isArchitect()
        || ((caller.isObserver() || caller.isCollaborator())
                && ticketOwnedByCaller.test(targetSession));

Ownership is still decided by the ticket's recorded creator, never by the role, so adding the collaborator to the role list cannot widen what any one caller reads. NO_OBSERVER_OWNED_TICKET → NO_OWNED_TICKET and observerOwnsTicket → ticketOwnedByCaller, since neither is observer-specific any more.

The grant is live, not dead code — checked rather than assumed. FleetMcp.sendAsync takes the caller as creator and records its owner key with no role filter (FleetMcp.java:1152-1162), and profileTargetError only rejects a profile name. So a collaborator's fleet_send{sessionId: <a lead's terminal>, wait:false} really does create a ticket owned by collaborator:<name>. FleetMcpAuthzTest.aCollaboratorMayTaskReadATicketItCreatedButNotOneAnotherCallerCreated builds the ticket that way, through the real sendAsync, so the test carries that check too.

Verified on the merge result: BUILD SUCCESS, Tests run: 2236, Failures: 0, Errors: 0, Skipped: 0, tallied again from 183 surefire XML files.

One change was rejected before merge, and it is the part worth recording

The first version also threaded a mayTaskReadNudge boolean through FleetMcp → PrimaryRegistry → ReplyPushLoop, so a ticket nudge would never tell a caller to run a fleet_poll{ticket} its role is refused — mirroring the mayDrainNudge/mayAnswerNudge pattern #778 added.

With the ownership classifier forced to t -> true at the call site, the TASK_READ expression reduces to isPrimary || isWorker || isArchitect || isObserver || isCollaborator. Role has six constants and ANONYMOUS is refused earlier, so the boolean was true for every role that could reach the line. One producer, one consumer, and the filter could never fire.

This issue's own grant is what killed it. The plumbing was only needed while a collaborator was refused TASK_READ. Granting the collaborator dissolves the problem it solved, so shipping both would have left a guard that reads as armed and is not. Removed in 820e6f2.

Docs

CLAUDE.md invariant 3 and the Collaborator section both said a collaborator is refused every ticket including its own — corrected in a49d4dc, with the wiki's canonical block re-synced byte-identical. wiki/11-Features.md gains Every ticket-owning role can read its own ticket (967e66a).

Not yet live. The running daemon holds its boot jar; this lands on the next scripts/redeploy-fleetd.sh, held so it can carry #796, #797, #778 and #803 together.

Fixed and merged as `2aacf07` on `main` (PR #806, now closed). `Authz`'s `TASK_READ` case now reads: ```java case TASK_READ -> caller.isPrimary() || caller.isWorker() || caller.isArchitect() || ((caller.isObserver() || caller.isCollaborator()) && ticketOwnedByCaller.test(targetSession)); ``` Ownership is still decided by the ticket's recorded creator, never by the role, so adding the collaborator to the role list cannot widen what any one caller reads. `NO_OBSERVER_OWNED_TICKET` → `NO_OWNED_TICKET` and `observerOwnsTicket` → `ticketOwnedByCaller`, since neither is observer-specific any more. **The grant is live, not dead code — checked rather than assumed.** `FleetMcp.sendAsync` takes the caller as `creator` and records its owner key with no role filter (`FleetMcp.java:1152-1162`), and `profileTargetError` only rejects a profile *name*. So a collaborator's `fleet_send{sessionId: <a lead's terminal>, wait:false}` really does create a ticket owned by `collaborator:<name>`. `FleetMcpAuthzTest.aCollaboratorMayTaskReadATicketItCreatedButNotOneAnotherCallerCreated` builds the ticket that way, through the real `sendAsync`, so the test carries that check too. Verified on the merge result: `BUILD SUCCESS`, `Tests run: 2236, Failures: 0, Errors: 0, Skipped: 0`, tallied again from 183 surefire XML files. ## One change was rejected before merge, and it is the part worth recording The first version also threaded a `mayTaskReadNudge` boolean through `FleetMcp` → `PrimaryRegistry` → `ReplyPushLoop`, so a ticket nudge would never tell a caller to run a `fleet_poll{ticket}` its role is refused — mirroring the `mayDrainNudge`/`mayAnswerNudge` pattern #778 added. With the ownership classifier forced to `t -> true` at the call site, the `TASK_READ` expression reduces to `isPrimary || isWorker || isArchitect || isObserver || isCollaborator`. `Role` has six constants and `ANONYMOUS` is refused earlier, so the boolean was **true for every role that could reach the line**. One producer, one consumer, and the filter could never fire. **This issue's own grant is what killed it.** The plumbing was only needed while a collaborator was refused `TASK_READ`. Granting the collaborator dissolves the problem it solved, so shipping both would have left a guard that reads as armed and is not. Removed in `820e6f2`. ## Docs `CLAUDE.md` invariant 3 and the Collaborator section both said a collaborator is refused every ticket including its own — corrected in `a49d4dc`, with the wiki's canonical block re-synced byte-identical. `wiki/11-Features.md` gains *Every ticket-owning role can read its own ticket* (`967e66a`). **Not yet live.** The running daemon holds its boot jar; this lands on the next `scripts/redeploy-fleetd.sh`, held so it can carry #796, #797, #778 and #803 together.
ltms closed this issue 2026-10-07 05:41:09 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#804