fleetd #669 Unit A: split SEND and READ into their real call shapes #687

Closed
agent wants to merge 0 commits from worker/669-unit-a-70cc8f-3 into main
Member

Closes #678.

fleet_send was one Authz.Action behind three call shapes (local sessionId delivery, the coordId cross-host broker route, and the turnId answer-a-blocked-worker form), and fleet_poll/fleet_status folded ticket-polling and session-status into the same roster-only READ action. This splits each into its own action — SEND/COORD_SEND/ANSWER and READ/TASK_READ — on both the MCP (FleetMcp#sendAction/#pollAction/#authzAction) and REST (FleetApp#routeAction) entry paths, with every new action granted to exactly who held the combined action before. No caller gains or loses a capability.

Also fixes #678's Authz.java:87-89 comment, which claimed "the roster carries no secrets" — true now of the narrowed READ, false of the old combined action (ticket replies and pending questions are guessable/walkable, see #678).

No capability changes, and how I checked

  • PRIMARY: unchanged — holds every action it held before (SPAWN/STOP/DRAIN/HANDOVER, all three send shapes, both read shapes, COORD_READ).
  • WORKER: unchanged — still excluded from all three send shapes (as it was from the one SEND), still holds both read shapes (as it held the one READ).
  • ARCHITECT: unchanged — still holds all three send shapes (as it held SEND), still holds both read shapes, still excluded from lifecycle actions and COORD_READ.
  • ANONYMOUS: unchanged — excluded from everything (Authz.permits returns false before the switch).

Checked by:

  1. Permanent tests pinning the per-role grant for every new action (AuthzTest.theThreeSendShapesCarryTheSameGrantAsTheOldUndividedAction, .taskReadCarriesTheSameGrantReadDidBeforeTheSplit; FleetMcpAuthzTest.anArchitectMayUseAllThreeSendShapesOverMcp, .aWorkerMayNotUseAnySendShapeOverMcp, .aWorkerKeepsBothReadActionsAfterTheSplit).
  2. A manual mutation control, run and reverted during implementation (not committed): temporarily narrowed each of SEND, COORD_SEND, ANSWER to primary-only (removing || caller.isArchitect()) and reran the targeted suite — each mutation failed only the tests for that one action (2-4 failures each, all naming that action), while the other two send-shape tests and every unrelated test stayed green. Did the same for TASK_READ (narrowed to primary-only): only the TASK_READ-specific tests failed; every READ-specific test (observationIsOpenToBothAuthenticatedRoles, anArchitectMayReadAndScrapeMetrics, etc.) stayed green. This is what proves the split is a real divergence point in the switch, not a rename.
  3. Both entry paths: FleetMcpAuthzTest covers MCP; FleetAppAuthTest adds theMessageRouteIsASendWithNoTurnIdAndAnAnswerWithOne (unit-level routeAction mapping) and aWorkerMayNotAnswerAnotherSessionsBlockedQuestionOverRest (live HTTP: a worker POSTing POST /sessions/{id}/message with a turnId body is still refused 403, exactly as the plain-SEND shape already was). REST has no coordId route — that shape is MCP-only (sendToLead), so only two of the three shapes apply there.

Build

cd fleetd && rm -rf target/surefire-reports && mvn -o clean install

Tests run: 1940, Failures: 0, Errors: 0, Skipped: 0 -- BUILD SUCCESS. target/surefire-reports/*.xml count: 172 (matches a fresh run; the directory was removed before the build, and mvn clean removes target/ anyway).

Scope

Unit A only, per fleetd #669's "six units" plan (comment 2026-10-03 20:58). Units B-F (the COLLABORATOR role, its config block, the resolver, deliverability, the instruction surface) are explicitly out of scope and untouched -- no COLLABORATOR role, no fleet.collaborators config, no new validator.

CLAUDE.md's invariant 3 ("send is lead or architect") stays true and is untouched, since no caller's capability changed.

Closes #678. `fleet_send` was one `Authz.Action` behind three call shapes (local `sessionId` delivery, the `coordId` cross-host broker route, and the `turnId` answer-a-blocked-worker form), and `fleet_poll`/`fleet_status` folded ticket-polling and session-status into the same roster-only `READ` action. This splits each into its own action — `SEND`/`COORD_SEND`/`ANSWER` and `READ`/`TASK_READ` — on both the MCP (`FleetMcp#sendAction`/`#pollAction`/`#authzAction`) and REST (`FleetApp#routeAction`) entry paths, with every new action granted to exactly who held the combined action before. No caller gains or loses a capability. Also fixes #678's `Authz.java:87-89` comment, which claimed "the roster carries no secrets" — true now of the narrowed `READ`, false of the old combined action (ticket replies and pending questions are guessable/walkable, see #678). ## No capability changes, and how I checked - `PRIMARY`: unchanged — holds every action it held before (`SPAWN/STOP/DRAIN/HANDOVER`, all three send shapes, both read shapes, `COORD_READ`). - `WORKER`: unchanged — still excluded from all three send shapes (as it was from the one `SEND`), still holds both read shapes (as it held the one `READ`). - `ARCHITECT`: unchanged — still holds all three send shapes (as it held `SEND`), still holds both read shapes, still excluded from lifecycle actions and `COORD_READ`. - `ANONYMOUS`: unchanged — excluded from everything (`Authz.permits` returns `false` before the switch). Checked by: 1. Permanent tests pinning the per-role grant for every new action (`AuthzTest.theThreeSendShapesCarryTheSameGrantAsTheOldUndividedAction`, `.taskReadCarriesTheSameGrantReadDidBeforeTheSplit`; `FleetMcpAuthzTest.anArchitectMayUseAllThreeSendShapesOverMcp`, `.aWorkerMayNotUseAnySendShapeOverMcp`, `.aWorkerKeepsBothReadActionsAfterTheSplit`). 2. A manual mutation control, run and reverted during implementation (not committed): temporarily narrowed each of `SEND`, `COORD_SEND`, `ANSWER` to primary-only (removing `|| caller.isArchitect()`) and reran the targeted suite — each mutation failed *only* the tests for that one action (2-4 failures each, all naming that action), while the other two send-shape tests and every unrelated test stayed green. Did the same for `TASK_READ` (narrowed to primary-only): only the `TASK_READ`-specific tests failed; every `READ`-specific test (`observationIsOpenToBothAuthenticatedRoles`, `anArchitectMayReadAndScrapeMetrics`, etc.) stayed green. This is what proves the split is a real divergence point in the switch, not a rename. 3. Both entry paths: `FleetMcpAuthzTest` covers MCP; `FleetAppAuthTest` adds `theMessageRouteIsASendWithNoTurnIdAndAnAnswerWithOne` (unit-level `routeAction` mapping) and `aWorkerMayNotAnswerAnotherSessionsBlockedQuestionOverRest` (live HTTP: a worker POSTing `POST /sessions/{id}/message` with a `turnId` body is still refused 403, exactly as the plain-SEND shape already was). REST has no `coordId` route — that shape is MCP-only (`sendToLead`), so only two of the three shapes apply there. ## Build ``` cd fleetd && rm -rf target/surefire-reports && mvn -o clean install ``` `Tests run: 1940, Failures: 0, Errors: 0, Skipped: 0` -- `BUILD SUCCESS`. `target/surefire-reports/*.xml` count: 172 (matches a fresh run; the directory was removed before the build, and `mvn clean` removes `target/` anyway). ## Scope Unit A only, per fleetd #669's "six units" plan (comment 2026-10-03 20:58). Units B-F (the `COLLABORATOR` role, its config block, the resolver, deliverability, the instruction surface) are explicitly out of scope and untouched -- no `COLLABORATOR` role, no `fleet.collaborators` config, no new validator. `CLAUDE.md`'s invariant 3 ("send is lead or architect") stays true and is untouched, since no caller's capability changed.
agent added 1 commit 2026-10-03 22:13:18 +02:00
fleetd #669 Unit A: split SEND and READ into their real call shapes
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 1m6s
CI / build (pull_request) Failing after 2m7s
9dea289975
SEND covered three different call shapes under one action (local
sessionId delivery, the coordId cross-host broker route, and the
turnId answer-a-blocked-worker form). READ covered both roster/
profile/identity observation and ticket-polling/session-status.
Split each into its own Authz.Action — SEND/COORD_SEND/ANSWER and
READ/TASK_READ — with every new action granted to exactly who held
the combined action before, on both the MCP and REST entry paths.

Also fixes fleetd #678's Authz.java comment: READ no longer claims
"the roster carries no secrets" for ticket replies and pending
questions, because those now live under TASK_READ.
Owner

Merged locally as 7c458e8 on main (pushed; main is now 2eb2d61). Closing by hand because we
merge locally.

What I checked myself:

  • The authorization table really is unchanged in effect. A reviewer compared every (role, action,
    target) pair against origin/main — 156 pairs, 0 mismatches — and I confirmed the load-bearing
    fact by reading Authz directly: SEND, ANSWER and COORD_SEND all carry
    caller.isPrimary() || caller.isArchitect(). TASK_READ carries what READ carried.
  • The mutation controls in the report are the right shape: each arm denied in turn, each failing
    only its own tests.
  • Combined build with #684 and #688: 1942 tests, 0 failures, BUILD SUCCESS, 173 report files.
    The merged main tree hash matches the tree I built (35b81eb).

One thing I did not merge silently. This PR moves the JSON body parse in
FleetApp.sendMessage to before the allow(...) gate, so an unauthorized caller's body is parsed
before it is refused. On origin/main the gate ran first. Since all three send grants are
identical today, turnId changes nothing about the decision and the reordering buys nothing yet.

I judged the practical risk low — bind.host is 127.0.0.1, so the caller is already a local
process — and merged on that basis rather than holding #669 Unit A. It is filed as #689 with the
suggested fix (check the coarse grant first, then parse, then check the finer action).

Note on the review: the reviewer I put on the REST route read the code but ran nothing, because it
understood the reviewer contract to forbid running tests. That was a defect in my brief. So #689's
DoS reading is unmeasured; the ordering change itself I read in both versions of the file.

#669 stays open — Units B to F are still not delegated, and Unit F (the instruction surface) is mine.
#678 is closed by this.

Merged locally as `7c458e8` on `main` (pushed; `main` is now `2eb2d61`). Closing by hand because we merge locally. What I checked myself: - The authorization table really is unchanged in effect. A reviewer compared every (role, action, target) pair against `origin/main` — **156 pairs, 0 mismatches** — and I confirmed the load-bearing fact by reading `Authz` directly: `SEND`, `ANSWER` and `COORD_SEND` all carry `caller.isPrimary() || caller.isArchitect()`. `TASK_READ` carries what `READ` carried. - The mutation controls in the report are the right shape: each arm denied in turn, each failing only its own tests. - Combined build with #684 and #688: **1942 tests, 0 failures, BUILD SUCCESS**, 173 report files. The merged `main` tree hash matches the tree I built (`35b81eb`). One thing I did **not** merge silently. This PR moves the JSON body parse in `FleetApp.sendMessage` to before the `allow(...)` gate, so an unauthorized caller's body is parsed before it is refused. On `origin/main` the gate ran first. Since all three send grants are identical today, `turnId` changes nothing about the decision and the reordering buys nothing yet. I judged the practical risk low — `bind.host` is `127.0.0.1`, so the caller is already a local process — and merged on that basis rather than holding #669 Unit A. It is filed as **#689** with the suggested fix (check the coarse grant first, then parse, then check the finer action). Note on the review: the reviewer I put on the REST route read the code but ran nothing, because it understood the reviewer contract to forbid running tests. That was a defect in my brief. So #689's DoS reading is unmeasured; the ordering change itself I read in both versions of the file. #669 stays open — Units B to F are still not delegated, and Unit F (the instruction surface) is mine. #678 is closed by this.
ltms closed this pull request 2026-10-03 22:29:46 +02:00
Some checks are pending
CI / shell-tests (pull_request) Failing after 7s
CI / contract (pull_request) Successful in 1m6s
CI / build (pull_request) Failing after 2m7s

Pull request closed

Sign in to join this conversation.