fleetd #715: gate answer() on the caller that owns the turn #725

Closed
agent wants to merge 0 commits from worker/715-5c43fc-1 into main
Member

Fixes fleetd #715, the write half of the #715/#721 pair (#721, the read half, is already merged).

What changed

Rendezvous gets a three-state Owner record (no record / unnamed primary / named terminal), stamped on the forward waiter when a delegation opens it, and copied onto a fresh fleet_ask turn (a coalesced duplicate ask keeps the first owner). MessageService.answer() now checks the answering caller against that stored owner before taking the session lock or reopening the resumed waiter, and returns the new NOT_TURN_OWNER outcome (distinct from STALE_TURN) on a mismatch, with no rendezvous/task/question cleanup performed on a refused answer.

send() and answer() both drop their no-caller overloads — every call site in MessageService, FleetMcp and FleetApp now threads an explicit callerTerminal. No Authz policy change; AuthzTest gained a test pinning that the null/unnamed-primary allowance is safe only because ANONYMOUS never reaches ANSWER.

Follows the design settled in the issue's two newest comments (18750, 18775): the owner is the caller whose accepted delegation started the running turn, captured per-turn on the rendezvous itself — never looked up at answer time from Task, MemberSession.ownerTerminal, or PrimaryRegistry.

Tests

One vertical unit across Rendezvous, MessageService, FleetMcp, FleetApp and their test files. New coverage includes a live hijack-and-control test for all four combinations of capture input × adapter: blocking-send and async-send delegations, each answered through both the MCP (FleetMcp) and REST (FleetApp) adapters — a different caller's answer is refused as NOT_TURN_OWNER with no side effect, and the real owner's answer still succeeds.

Build

mvn clean install from the worktree: Tests run: 2039, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS.

Caveat for review

Design rule 6 (no shorter overload left behind) was followed literally here, diverging from the #718 precedent (MessageService.poll(String), kept as a 1-arg overload with a source-scrape test pinning no production caller uses it). I considered that precedent for send/answer and rejected it for this ticket, per the brief's explicit instruction to remove every shorter form.

Out of scope, not fixed: the same "identifier used as authorization token" shape still exists at MessageService.poll(String) — a bare ticket with no caller terminal bypasses the ownership check by design (#718), guarded only by a source-scrape test rather than a runtime check. Not touched here.

Fixes fleetd #715, the write half of the #715/#721 pair (#721, the read half, is already merged). ## What changed `Rendezvous` gets a three-state `Owner` record (no record / unnamed primary / named terminal), stamped on the forward waiter when a delegation opens it, and copied onto a *fresh* `fleet_ask` turn (a coalesced duplicate ask keeps the first owner). `MessageService.answer()` now checks the answering caller against that stored owner *before* taking the session lock or reopening the resumed waiter, and returns the new `NOT_TURN_OWNER` outcome (distinct from `STALE_TURN`) on a mismatch, with no rendezvous/task/question cleanup performed on a refused answer. `send()` and `answer()` both drop their no-caller overloads — every call site in `MessageService`, `FleetMcp` and `FleetApp` now threads an explicit `callerTerminal`. No `Authz` policy change; `AuthzTest` gained a test pinning that the null/unnamed-primary allowance is safe only because `ANONYMOUS` never reaches `ANSWER`. Follows the design settled in the issue's two newest comments (18750, 18775): the owner is the caller whose accepted delegation started the running turn, captured per-turn on the rendezvous itself — never looked up at answer time from `Task`, `MemberSession.ownerTerminal`, or `PrimaryRegistry`. ## Tests One vertical unit across `Rendezvous`, `MessageService`, `FleetMcp`, `FleetApp` and their test files. New coverage includes a live hijack-and-control test for all four combinations of capture input × adapter: blocking-send and async-send delegations, each answered through both the MCP (`FleetMcp`) and REST (`FleetApp`) adapters — a different caller's answer is refused as `NOT_TURN_OWNER` with no side effect, and the real owner's answer still succeeds. ## Build `mvn clean install` from the worktree: `Tests run: 2039, Failures: 0, Errors: 0, Skipped: 0` — `BUILD SUCCESS`. ## Caveat for review Design rule 6 (no shorter overload left behind) was followed literally here, diverging from the #718 precedent (`MessageService.poll(String)`, kept as a 1-arg overload with a source-scrape test pinning no production caller uses it). I considered that precedent for `send`/`answer` and rejected it for this ticket, per the brief's explicit instruction to remove every shorter form. Out of scope, not fixed: the same "identifier used as authorization token" shape still exists at `MessageService.poll(String)` — a bare ticket with no caller terminal bypasses the ownership check by design (#718), guarded only by a source-scrape test rather than a runtime check. Not touched here.
agent added 1 commit 2026-10-04 09:46:53 +02:00
fleetd #715: gate answer() on the caller that owns the turn
CI / shell-tests (pull_request) Failing after 8s
CI / contract (pull_request) Successful in 58s
CI / build (pull_request) Failing after 2m15s
dd18bd1f38
Record the turn's owner on the forward rendezvous waiter (Rendezvous.Owner,
a three-state record: no record / unnamed primary / named terminal). A
fresh fleet_ask copies that owner onto the ask turn; a coalesced duplicate
ask keeps the first owner. answer() compares the answering caller against
the stored owner before taking the session lock or reopening the resumed
waiter, and a mismatch returns the new NOT_TURN_OWNER outcome instead of
STALE_TURN, with no rendezvous/task/question cleanup.

send() and answer() both drop their no-caller overloads; every call site
in MessageService, FleetMcp and FleetApp now threads an explicit caller
terminal through. AuthzTest pins that the unnamed-primary null allowance
is safe only because ANONYMOUS never reaches ANSWER.

Covers all four combinations of capture input x adapter: blocking-send and
async-send delegations, answered through both FleetMcp and FleetApp, each
with a hijack attempt refused and the real owner's answer succeeding as a
control.
agent added 1 commit 2026-10-04 09:56:08 +02:00
fleetd #715: pin that no production caller uses Rendezvous.open(String)
CI / shell-tests (pull_request) Failing after 10s
CI / contract (pull_request) Successful in 47s
CI / build (pull_request) Failing after 2m4s
2374de28e4
Add a source-scrape test mirroring #718's MessageServicePollUsageTest
shape, for the same residual: a convenience overload that defaults the
turn's owner to null, left in place because deleting it would break 81
test-only call sites across 7 unrelated files.

The scanner is exercised against a file known to hold many real
one-argument rendezvous.open( calls before it is ever pointed at
production, using the identical matching logic for both. A pattern that
cannot find the known calls would also find none in production, and
that is exactly the failure mode a -based git grep regex hit earlier
on this ticket: git grep's -E engine does not treat \b as a word
boundary, so that pattern silently matched nothing anywhere, in clean
code and in the 81 real calls alike.
Owner

Merged locally into main as 70328ca and pushed (ed4f4b0..70328ca).

The merge commit's tree is ac0147a5bfe2f6d1a199768525783b1c616bc331, the same tree I built and
mutation-tested, so what landed is exactly what I verified. Final gate: mvn -o clean install,
BUILD SUCCESS, Tests run: 2040, Failures: 0, Errors: 0, Skipped: 0, Maven's own exit code 0.

Full verification record, including two of my own mutations that turned out to prove nothing and why,
is on #715 (comment 18807).

We merge locally, so Gitea does not close this PR by itself. Closing it by hand.

Merged locally into `main` as `70328ca` and pushed (`ed4f4b0..70328ca`). The merge commit's tree is `ac0147a5bfe2f6d1a199768525783b1c616bc331`, the same tree I built and mutation-tested, so what landed is exactly what I verified. Final gate: `mvn -o clean install`, `BUILD SUCCESS`, `Tests run: 2040, Failures: 0, Errors: 0, Skipped: 0`, Maven's own exit code 0. Full verification record, including two of my own mutations that turned out to prove nothing and why, is on #715 (comment 18807). We merge locally, so Gitea does not close this PR by itself. Closing it by hand.
ltms closed this pull request 2026-10-04 10:12:57 +02:00
Some checks are pending
CI / shell-tests (pull_request) Failing after 10s
CI / contract (pull_request) Successful in 47s
CI / build (pull_request) Failing after 2m4s

Pull request closed

Sign in to join this conversation.