fleet_status has no owner check and returns another session's pending question text AND its turnId, so the #715 turnId never has to be guessed #721
Closed
opened 2026-10-04 08:13:39 +02:00 by ltms
·
4 comments
No Branch/Tag Specified
main
worker/702-4f5c7f-2
worker/715-5c43fc-1
worker/721-70f9ea-5
worker/718-99362b-2
worker/task-15-af0d10-12
worker/task-16-50a702-13
worker/task-12-4d0479-9
worker/task-13-823ce2-10
worker/705-ticket-owner-af9928-8
worker/703-list-collaborators-9c06c2-7
worker/669-example-truth-0b303d-6
worker/669-collab-deliverability-9ba859-3
worker/669-collab-reload-report-2a21bd-4
worker/669-7e80a6-1
worker/669-unit-d-efbbd7-1
worker/669-1b786a-1
worker/669-1d1d9f-1
worker/692-4afb9d-2
worker/689-02fced-13
worker/693-cf23fa-14
worker/677-fix-lead-collision-f69073-12
worker/638-fix-overmask-dbb1bf-11
worker/675-5b7478-4
worker/669-unit-a-70cc8f-3
worker/677-8cdaaf-5
worker/638-a7b391-1
worker/683-4536d6-2
worker/651-a75bbe-8
worker/680-20607d-7
worker/664-c12e95-3
worker/668-08534d-4
worker/672-0f2469-2
worker/670-7d1022-1
worker/661-ac7c28-2
worker/664-37fb9b-3
worker/663-remove-3arg-read-3f6783-1
worker/659-remove-dead-backcompat-ba5e6f-1
worker/637-revision-60a488-23
worker/656-redact-regression-tests-892903-19
worker/637-context-gauge-threshold-466eb5-16
worker/639-redact-line-numbers-de4ac4-17
worker/641-set-reformat-guard-6f96a4-18
worker/642-herdr-guard-scope-5de0e4-15
worker/650-javadoc-scope-95f3b3-14
worker/612-01e9f7-13
worker/612-a-r4-quarantine-outage-7ab0e8-5
worker/612-a-r9-r11-capacity-coverage-peers-cfcc79-7
worker/612-a-r10-loophealth-ccc872-8
worker/612-a-r12-turnregistrar-9e3bb7-9
worker/612-a-r5-leadconfigdir-9e70cf-6
lead/config-edit-redact-anchor-wording
worker/config-edit-seam-ca8dc1-1
worker/612-r67-630-lifecycle-290b8d-3
worker/629-625-ports-seams-da7d5d-4
worker/612-r12-exhaustion-f37cd7-1
worker/612-r38-amqp-24b083-2
worker/fleetd-612-unita-87807e-1
worker/612-b3-mcpwirings-da2b58-3
worker/612-b2-cb185-176d3a-2
worker/612-b1-completion-457459-1
worker/612-agaps-73a926-2
worker/608-sleeps-3a64ff-3
worker/621-b4520b-1
worker/618-b83894-2
worker/fleetd-615-e05481-5
worker/lead-autocompact-5f1ab2-3
worker/fleetd-613-f85deb-3
worker/fleetd-608-flaky-nudge-test-d0c2d1-3
worker/lead-context-gauge-ad404f-1
worker/gauge-wiring-9158c1-4
worker/redeploy-slowstart-ead0e5-5
worker/charter-bytes-13668c-6
worker/rollover-outcome-291483-2
worker/589-f64303-2
worker/593-1a8025-5
worker/589-fcd2aa-1
worker/568-9fdaa2-3
worker/571-attempted-outcome-5739f7-2
worker/581-completionresolver-cas-sites-0542b7-6
worker/562-loop-health-wiring-test-99611c-5
worker/562-surface-loop-health-7df5cc-4
worker/575-waiter-cleanup-sites-62ad80-1
worker/572-answer-lock-release-46a9ae-5
worker/567-probe-channel-leak-a38fc5-6
worker/551-record-before-send-7cbf56-1
worker/561-listener-fanout-survives-a-throw-61d538-2
worker/555-redeploy-main-flow-seam-65c2f5-2
worker/556-injector-owns-registration-e027a5-1
worker/552-post-restart-mktemp-abort-bc2672-4
worker/553-onstatus-completion-leak-0da881-2
worker/550-shasum-linux-196132-1
worker/538-loop-dies-on-error-4a5eeb-6
worker/426-health-coverage-ef1fd4-4
worker/504-failed-reported-clean-3cfd66-3
worker/537-capturedlog-close-e4c437-2
worker/459-broken-link-targets-cadc17-5
worker/535-appender-leak-fe74c1-1
worker/512-part2-shutdown-detection-434701-9
worker/529-logger-level-sweep-2a5533-8
worker/528-drain-gate-call-site-5de83d-7
charter/forge-mcp-vs-token
worker/521-swap-guard-unpinned-28e931-5
worker/519-probe-test-harness-d25ab8-4
worker/525-logger-level-leak-1b4eb0-6
worker/518-fleetmcp-resolver-wiring-8ef96c-1
worker/512-drain-complete-line-7edd71-3
worker/517-abort-branch-and-jar-id-41b641-2
worker/500-9e52c9-3
worker/509-4912f4-2
worker/511-9a4b23-1
worker/493-479f45-2
worker/505-03f8b2-1
worker/492-followup-detect-unclear
worker/501-a31fa0-7
worker/498-451d1c-5
worker/494-1015ce-2
worker/492-209647-1
worker/489-001902-2
worker/480-relative-handover-path-906323-1
worker/480-b-handover-skill-45bf1f-5
worker/474-followup-source-pin-f54a55-17
worker/474-charter-check-on-reload-f54a55-17
worker/466-quarantine-repeatcount-report
worker/393-opencode-skill-seeding-71854b-13
worker/469-canonical-tool-names-2a472a-16
worker/466-quarantine-escalation-5ae9c1-15
worker/446-hot-exhausted-pattern-0af580-6
worker/464-charter-tool-name-guard-a85635-12
worker/463-listfleet-default-fails-open-f1c76c-11
worker/458-invariant-5-by-purpose-862f9a-10
worker/439-coordinator-row-gate-bc032a-8
worker/449-herdr-protocol-576015-4
worker/450-abstract-spawn-599e1c-5
worker/437-ack-refuses-177d91-1
worker/444-placement-window-feb56a-2
worker/440-helddurable-derived-d462d7-13
worker/425-rework-placement-resolve-c58ba1-9
worker/421-lead-peek-held-msgs-cdbad2-10
worker/435-fixed-policy-cap-fe11de-12
worker/422-gate-state-observability-9e79d6-11
worker/431-memberregistry-live-readers-cdbad2-10
worker/424-architect-slot-hot-038b41-7
worker/422-model-gate-spawn-c29f48-6
worker/425-default-profile-live-f55534-8
worker/415-coverage-wording-2cbf9c-5
worker/416-3ad1da-1
worker/418-588283-3
worker/deterministic-stamp-race-409-3cb7b6-10
worker/armed-reads-live-config-404-ed931f-9
worker/reply-peer-refusal-391-5a34bd-7
worker/models-allowlist-aa9e9b-3
worker/ttl-stamp-race-399-f1122f-8
worker/scrub-receipt-400-316b3e-5
worker/exhaustion-detection-395-105105-6
worker/scrub-abort-394-316b3e-5
fix/scrub-uid-abort
worker/task-scrub-517574-2
worker/t386-clock-bd5b78-4
worker/t384-scrub-813790-5
worker/t381-cc-748314-2
worker/t373-336973-2
worker/t365-3920c5-3
worker/t358-6e989b-1
worker/t355-8b321c-1
worker/fleetd-369-hermetic-git-tests-e8b19a-3
worker/fleetd-368-stale-lead-binding-f5682e-2
worker/fleetd-360-deploy-units-0d3793-1
worker/359-dead-lead-tabs-f1253b-4
worker/362-worktree-skills-c03e51-3
worker/361-coord-visibility-655144-1
362-plugin-visibility-and-drift
worker/errscan-bed2ca-2
worker/amqp-log-identity-bed2ca-2
worker/withdefaults-guard-561704
worker/sleepguard-82076d-1
worker/fd334-9ee1b6-5
worker/fd348-f1ab27-4
worker/fd335-a71c35-1
worker/fd342-174a17-2
worker/fd345-490d0f-3
worker/fleetd-337-5ec7d4-21
worker/fleetd-341-af5a6b-24
worker/fleetd-339-5ca0a2-23
worker/fleetd-338-83a4a1-22
worker/fleetd-333-281f46-18
worker/fleetd-329-11bdbb-16
worker/fleetd-330-2770fb-17
worker/fix-326-50506e-15
worker/fix-324-3e9bbf-14
worker/fix-323-b8287d-13
worker/fix-316b-bd0860-11
worker/fix-318-76ca36-9
worker/fix-317-486aec-8
worker/fix-315-ce47c5-6
worker/fix-307-275890-6
worker/fix-308-b4f664-7
worker/fix-309-ec3939-8
worker/fix-310-7a3974-9
worker/fix-302-52ad0e-9
worker/fix-298-ce1acb-8
worker/fix-297-66bd11-7
worker/fix-296-104622-6
worker/fix-293-bare-closetab-eb22b5-3
worker/fix-280-gone-ask-lapse-bca98e-2
worker/fix-290-reapidle-guard-coverage-9b0dd1-1
worker/fix-285-trust-seed-8f3565-10
worker/fix-284-backend-error-seat-85912c-11
worker/fix-282-chained-ask-e6d0bb-8
worker/fix-283-teardown-leaks-f40dfa-9
worker/fix-281-pin-handler-actions-4921ac-7
worker/audit-rendezvous-lifecycle-d072ae-2
worker/audit-health-placement-1a2476-6
worker/audit-teardown-exits-e207a5-3
worker/audit-launcher-asymmetry-27e370-4
worker/audit-rest-authz-6ca53c-5
worker/investigate-275-abandon-asking-fdef52-8
worker/fix-274-worktree-leak-b0095d-7
worker/fix-273-exhausted-pattern-9665b5-6
worker/fleetd-267-model-check-bd8068-1
worker/fleetd-131-archunit-18b834-7
worker/fleetd-266-sshagent-rename-a014ff-6
worker/fleetd-184-uid-claim-8e1f31-4
worker/fleetd-184-warn-b381ee-10
worker/fleetd-184-docs-be1d12-9
worker/fleetd-257-9bf010-7
worker/fleetd-103-23a113-6
worker/fleetd-247-342356-5
worker/fleetd-116-04dea8-4
worker/fleetd-252-a830e0-3
worker/fleetd-111-7e8673-9
worker/fleetd-155c-f8ef4b-8
worker/fleetd-176-b928ca-3
worker/fleetd-249-7a7878-2
worker/cb248-composition-root-b-9acdf7-15
worker/cb148-envrc-default-fa6c82-12
worker/cb201-unit5-wiring-6c12e6-8
worker/cb241-fallback-echo-1175e9-11
worker/cb149-trust-dialog-2392a5-9
worker/cb134-148-overlay-visible-c9b986-10
worker/cb234-session-id-keyed-04e1fc-1
worker/cb201-unit3-nudge-abdf5c-6
worker/cb201-unit2-policy-c1102c-5
worker/cb201-unit4-outcome-a13bfa-7
worker/cb201-unit1-classifier-91b9b1-4
worker/cb201-227-refine-831980-3
worker/cb175-model-readback-0f085f-1
worker/cb222-charter-tmpdir-17f013-1
worker/cb226-architect-slot-race-cd3aa8-3
worker/cb224-worktree-root-group-024523-2
worker/cb-123-role-demotion-c600f7-2
worker/cb-219-opencode-roots-1f677e-1
worker/cb214-claude-session-id-b9eab4-4
worker/cb213-zdotdir-wrong-process-dd6de4-3
worker/cb211-exhaustion-classification-9546e0-2
worker/cb137-ambiguous-task-4df3d8-4
worker/cb209-agentsessionid-4dfdb6-2
worker/cb185-hostenvnames-2692b5-3
worker/cb206-opencode-sqlite-128718-2
worker/cb185-worktree-group-fc0c99-1
worker/cb-137-ask-ticket-e7760c-2
worker/cb-172-broker-uri-d36ae4-4
worker/cb-175-model-readback-76ead6-3
worker/cb-161-pane-ancestry-293510-1
worker/cb-164-rebase-885863-8
worker/cb-164-empty-scrape-false-success-1a80af-3
fix/cb-197-ticket-ttl-from-completion
worker/cb-189-remote-url-coverage-4692f3-1
worker/cb-185-blockers-027756-4
worker/cb-192-gap-log-11b631-2
worker/cb-633-fix-5f4396-3
worker/cb185-router-d6436d-3
worker/cb185-router-routing-gaps-9e9d33-3
worker/cb185-paneids-992586-2
worker/cb-633-allow-list-union-ed374b-1
worker/cb-157-credential-in-remote-url-496e44-2
worker/cb-641-health-herdr-evidence-8f1f54-6
worker/cb-640-health-msg-evidence-99c9cd-1
worker/cb-642-fleets-status-skill-bbbc40-5
cb-634-ide-mcp
worker/lead-comms-wiring-c014b9-7
worker/lead-mailbox-c19577-6
worker/autocompact-window-82bc2f-5
worker/cb-634-probe-18056f-4
worker/cb635-broker-urienv
worker/cb-632-config-retry-8e0efa-7
lead/cb-622e-claude-md
lead/cb-622-followup
worker/cb-622a-165dff-1
lead/cb-622d-opencode-mount
worker/cb-622b-717c67-2
worker/cb-622c-ab7759-3
worker/cb-617b2-20ca4b-3
worker/cb-617a-5c2f4a-1
worker/cb596-4e49ef-3
worker/cb586-10500c-1
worker/cb-606-b9343a-25
worker/cb604-1445f8-24
worker/cb582-477374-21
worker/cb584-8c2281-22
worker/cb600-e6b9a9-20
worker/cb602-ce257f-19
worker/cb601-b42837-18
worker/cb598-6c7ba7-17
worker/cb599-740fe4-16
worker/cb597-282224-15
worker/cb590fix-185e9a-10
worker/cb528-recovery-race
worker/cb594-96bead-8
worker/cb590-916766-2
worker/cb527-997d99-3
worker/cb592-env-leak-3cbf9c-1
worker/cb588-async-ticket-nudge-3218f7-5
worker/cb578b-9dcb13-6
worker/cb581-d24826-5
worker/m2-u5-ef8c42-15
worker/cb578a-516499-2
worker/cb576-01a04b-17
worker/cb579-lead-tab-acba06-20
worker/cb580-terminal-health-ed6058-21
worker/cb577-f36fdc-18
worker/cb573b-3db06f-16
worker/cb568c-f36fdc-18
worker/cb568-drop-cause-c3ac1c
worker/cb575-cancelled-notification-c3ac1c
worker/m4-sol-a2cbec-3
worker/cb574-async-ask-c3ac1c
worker/cb573-health-model-8ca857-14
worker/cb572-unknown-target-7f2e35-13
worker/u4-700706-9
worker/u3-b9fcb6-6
worker/u2-ef5b68-4
worker/u1-469dce-1-clean
worker/u1-469dce-1
worker/cb-564-health-events-70cf7e-2
worker/cb-565-recycle-drops-role-98e58f-3
worker/cb-563-missing-reply-df2866-1
worker/cb-562-readiness-gate-silent-6c23c9-3
worker/cb-560-architect-presence-da8155-1
worker/cb-561-architect-silent-off-a71cab-2
worker/cb-548-bind-architect-slot-fe1b8c-1
worker/parity-overlay-settings-5fb711-1
secrets-central-store
cb-559-hot-key-correction
cb-557-fleet-role-pools
worker/cb-553-maxload-explicit-spawn-305ee3-6
worker/cb-551-idle-lead-heartbeat-f1633c-1
worker/cb-544-drain-preserves-worktree-925fad-3
worker/cb-552-docs-sync-1cb9cf-4
worker/cb-548-rendezvous-guard-rebased
worker/cb-548-rendezvous-guard-116b53-10
worker/cb-548-authz-v2-586df6-8
worker/cb-548-authz-264363-5
salvage/cb-528b-codex-home
salvage/cb-528a-codex-launcher
CB-518-primary-flow
feature/peer-launcher-spi
cb-103-injector
v1.1.0
v1.0.0
Labels
Clear labels
blocked
needs-live-proof
ready-to-delegate
silent-default
Cannot start until something else lands. The body says what.
Merged and green, but never shown working on the running daemon. Not the same as done.
Scope, files and acceptance criteria are written. A worker can be briefed from the body alone.
A feature that compiles, passes tests, and ships turned off. Nine recurrences and counting.
No Label
Milestone
No items
No Milestone
Projects
Clear projects
No project
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: fleet/fleetd#721
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by an architect member reviewing #705 option 1. I verified every line below myself in the main
clone at
9a64d42.The leak
fleet_statusis authorized with no target, so the ownership check is skipped:statusthen takes the caller-suppliedsessionIdand tests nothing:And
pendingAskhas no caller filter either — it matches only on the session you name:The gate is
TASK_READ, held by three roles:Both surfaces are affected. REST has the same shape:
Note
GET /tasks/{ticket}on that same line was fixed — #716 threaded the caller's terminal intomessages.poll. The status route beside it was not. One line, two routes, only one of them gated.Why this is worse than it looks: it removes the guess from #715
#715 is filed as "a
turnIdis a guessable counter". I argued on that ticket that the guess isharder than it sounds, because
Rendezvous.java:148mintssession + "#" + askSeq.incrementAndGet(),so you must know a target's terminal id first. That mitigation is void.
fleet_statushands overthe
turnIddirectly, together with the question text and the ticket id.The full chain, with the grant at each step:
fleet_listREADsessionId—membersVisibleToadmits primary and architectfleet_status{sessionId}TASK_READturnId, itsticketfleet_send{turnId, content}ANSWER= primary or architectSo an architect can hijack any delegation's rendezvous with no guessing at all, and read the question
on the way. Step 1 is the reason this is reachable: #710 hid
membersandleadsfrom a worker,so a worker cannot enumerate session ids from the bridge, but an architect still can by design.
The content is the part that matters. A pending question is a member's own words about the work in
front of it — the
Authzcomment two lines aboveTASK_READalready states this exact principle asthe reason a collaborator is excluded from that row:
The narrower role got the stricter rule. The broader roles kept the open door, and the door is wider
than the ticket-id one that was just closed.
Suggested fix
Give
fleet_statusthe same treatment #716 gavefleet_poll{ticket}: pass the resolved caller andcheck it, rather than authorizing with a
nulltarget.Two things to decide deliberately, both of which have already bitten this repo:
Task, andTask.creatorTerminalalready exists — #705/#716 added it. Comparing against the sender of the brief is right, and it
keeps the architect's intended ability to answer a member it briefed. Do not compare against
MemberSession.ownerTerminal:SPAWNis primary-only, so only a lead can ever be the spawn owner,and gating on it would forbid the normal architect case while permitting nothing new. (Same
reasoning I wrote on #715.)
nullrecord case explicitly, and say which way it fails. AcallerTerminal == null || callerTerminal.equals(...)test fails open and buys nothing — that is exactly #718'sMessageService.poll(String). A strict equality against anullcreator fails closed and wouldbreak the base
fleet_statuscall for an unnamed primary, which is the break task-15 caught in#705 where gating the read alone would also have broken the documented REST fallback.
Also worth separating: the base status string and the pending-ask block are not equally
sensitive. Returning liveness for a session you do not own is close to what
READalready gives;returning the question text and
turnIdis not. A fix may legitimately keep the first and gate onlythe second, which is a smaller and safer change than refusing the whole call.
Relationship to the other open tickets
turnIdis handedout, not guessed. Both want the same
Task.creatorTerminalcomparison, so they may be one change.TASK_READholder reading another session's content), and the architectreviewing option 1 argued this should ship before the
OBSERVERfloor, because it closes the onereal content leak at a fraction of the cost and does not depend on that decision. I agree.
ask.ticket()is also returned here, and ticket ids reset per boot.Not measured
I have not driven the hijack from a live architect session: read
members, callfleet_statuson amember with an open ask, then answer it. Everything above is read from the five code sites quoted,
each of which I opened myself. The reachability of step 1 rests on
membersVisibleToadmitting anarchitect, which I read in
FleetMcp.java:786, not from a live architect'sfleet_listoutput.Lead, before delegating. I re-measured the fix surface in the main clone at
6e06058(after #718 merged). Five new facts, two of which change the suggested fix above. Where this comment and the ticket body disagree, this comment is newer and it wins.1. Exactly one
Taskis ever constructed, and both production callers record the creatorOne site, reached only through
sendAsync. Both production callers pass a real terminal:So every pending ask already has an owner recorded. Nothing new has to be stored. That settles decision 1 in the ticket: compare against
Task.creatorTerminal, and the field is already populated.2. Reuse
ownsTicket— do not write a new comparisonMessageService.java:1434already is the single place that knows task ownership:It is
private staticinMessageService, which is also where the check belongs — the pending-ask scan is in that class, so the comparison stays next to the data.I need to correct decision 2 in the ticket body, which blurs two different
nulls. I wrote that acallerTerminal == null || ...test "fails open and buys nothing — that is exactly #718'sMessageService.poll(String)". That is wrong as applied here. There are two nulls and they behave differently:ownsTicketresultcallerTerminal == nullMessageService.java:1428-1431, and it is what keeps the REST fallback workingtask.creatorTerminal == nullsendAsyncoverloadThe #718 defect was a convenience overload that silently supplied the first null for a caller who did have a terminal. It was not the unnamed-primary allowance. So reusing
ownsTicketunchanged is correct.What you must not do is repeat the #718 shape: do not add a one-argument
pendingAsk(String)that forwardsnull. That would hand the unnamed-primary bypass to every caller, which is the actual defect #718 was about.3. You can afford to change the signature in place —
poll's reason not to does not applypoll(String)kept its overload because deleting it meant editing 44 test call sites.pendingAskis nothing like that:8 call sites, all in one file. So change
pendingAskto take the caller terminal and fix all 8. No convenience overload, and therefore nothing for a future call site to reach for by mistake.4. The REST route looks gated and is not
FleetApp.java:870already passes the session id as the target:That has no effect. The
TASK_READrow ignorestargetSessionentirely — it iscaller.isPrimary() || caller.isWorker() || caller.isArchitect(). Do not read that argument as an existing check. The REST surface atFleetApp.java:881leaks the same three fields as the MCP one:Two surfaces, one fix. A change that only touches
FleetMcpleaves the REST route wide open.5. The invariant is already written down, which makes it a free test case
Authz.java, the comment on theREADrow:This is not a new policy. The codebase already claims a pending question is not readable without the stricter grant. The defect is that
TASK_READturned out to be role-only, with no ownership test, so moving it there enforced nothing. Treat that sentence as the specification.What stays unchanged from the ticket body
Gate only the pending-ask block, not the whole call. The base status string (
idle/working/done, and REST'sready) stays readable as it is today. That is the smaller change, it breaks no existing caller, and liveness is close to whatREADalready gives. Returning the question text andturnIdis the part that is not.Honest limit on what this fixes
This does not close the hijack on its own, and nobody should read the merge as if it did. #715 measured that a
turnIdissession + "#" + askSeq.incrementAndGet()— a counter. After this change an architect can no longer read another member'sturnId, but it can still guess one, andanswer()still takes no caller identity. Step 3 of the chain in the ticket body is untouched.So the correct description of this unit is: it closes the read half. #715 is the write half, and the chain stays open until both land. I am sending #715 to an architect in parallel, because
Rendezvous.AskWaiterrecords only the worker's session —— so there is nowhere to compare a delegator today, and where that identity should come from differs between a blocking and an async send. That is a design decision, not an implementation, so #715 is not ready to delegate yet.
Lead, a pointer rather than a correction — nothing above changes.
I checked that the acceptance criteria I set are actually satisfiable before you spend time on a harness. They are, and #716 already left you a near-exact template for every case on both surfaces. Mirror these rather than inventing anything:
REST —
fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java:182covers P1, P2 and P3 in one test for the poll route — a different worker refused, the creator allowed, the unnamed primary allowed. That is the same three-way split you need for the status route.:150is the "actually threads the terminal through" shape, which is what stops a fix that looks right but passes the wrong value.MCP —
fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java:512is the one my own mutation run killed when I revertedFleetMcppoll'scallerTerminaltonull, so it is proven to fail for the right reason. That makes it the right model for your criterion 3.Two notes on using them:
:223's shape matters for P4. It exists because recording and checking have to ship together: a fix that checks a creator the send never recorded refuses the ticket's own creator. You are not changing the recording side, so P4 should hold for free — but assert it rather than assuming it, because that is the half that silently inverts.restPollRefusesADifferentWorker...is the house style: it names what is protected.Nothing here changes the scope or the five properties. Re-read my earlier comment before you commit — that one does correct the ticket body.
Lead. Correcting a claim I made in my first comment. The fix and all five properties are unchanged — only my justification was wrong. An architect working #715 caught it and I verified every line myself.
What I got wrong
I wrote: "So every pending ask already has an owner recorded. Nothing new has to be stored."
The second sentence is true. The first is false.
Task.creatorTerminal == nullis a valid production state, not missing data:So a delegation created by the unnamed primary — the token or loopback-trust REST path, which has no herdr pane — records a
nullcreator. I asserted the opposite without checking it.Why the fix is still right
Reusing
ownsTickethandles this correctly without any change:So P4 is not a corner case you can assume holds; it is a real production path. Assert it, as I said in my second comment. The reason has changed: it is not "a task that should have had a creator and somehow didn't", it is "a task legitimately created by a caller that has no terminal".
And P2 is reachable on the MCP surface — I checked, because I nearly concluded it was not
The
callerTerminalhelper's own javadoc made me doubt this:Read literally, that says every primary gets
null, which would mean a lead's own MCP delegation records no creator and the gate would constrain only workers and architects. That reading is wrong, and the javadoc is what is imprecise. The context is populated from the resolvedPrincipalatFleetMcp.java:450(CALLER_TERMINAL, orEmpty(p.terminal())), and a named lead does carry a terminal:Only
Principal.primary(pid)(unnamed) andPrincipal.anonymous()have a null terminal. So a lead resolved by its tab label records its terminal, and P1/P2 bite between two named leads as well as between architects. My brief stands as written.Drive-by, in scope because you are in this file: that javadoc at
FleetMcp.java:790says "the primary" where it means "the unnamed primary". Fix the sentence while you are there — it conflates two states that need opposite handling, and it is what made me doubt a correct property. Keep it to the contract as it is now: no ticket number, no history.What I should have done
Run the command before writing the claim. I had the grep output for the call sites and read "passes a terminal" off the shape of the expression
caller == null ? null : caller.terminal()while skipping what its own ternary says. That is the fourth time this session a claim of mine needed correcting after it was already on a ticket, and all four were cause-or-count claims I did not re-measure at the moment of writing.Merged locally as
b12d707and pushed tomain. PR #723.What landed
Three production files, four test files, +286/-27. Both surfaces.
No convenience overload was added on either side, so the #718 shape is not reintroduced. The worker also dropped the
CB-582ticket references from the comments it touched, unprompted — correct per the project's comment rules.My own verification, not the worker's
Built in a throwaway worktree at
0f5985b.mvn clean install: BUILD SUCCESS, exit 0. Control: 175 surefire report files, so the suite really ran. The worker reports 2026 tests (2020 onmainplus 6); I did not re-extract that count from my own log and am quoting theirs for it.Then three mutations, each gated on
mvn -o compilebeing green first, so a red suite proves behaviour and not a compile error:pendingAsk:1767— drop the&& ownsTicket(task, callerTerminal)conjunctFleetMcp:517— pass a literalnullinstead ofcallerTerminal(exchange)FleetApp:883— drop the caller resolutionAll three restored, and
git diff --statafter restore was empty — byte-identical.M3 is the one the worker did not run, and it is the one that matters. The worker mutated the two call sites; I mutated the ownership check itself. It killed tests in all three test classes at once:
The REST failure prints the leak verbatim, which is the clearest statement of what this ticket was about:
So the check is pinned, not merely the wiring. Three independent mutations, three different kills.
The merge commit's tree is byte-identical to the tree I built (
adfa355), so the verified build covers the merge exactly and no post-merge rebuild was needed.A gap the worker found that I had not asked about
Its first mutation attempt — reverting only the handler lambda — did not kill its behavioural test, because that test calls
FleetMcp.status(...)statically and so bypasses the handler entirely. It noticed, and added the two handler-wiring scrape tests to close it.That is the "a test on the seam does not prove the caller" shape, caught without being told. Both scrape tests carry mandatory control assertions —
FleetAppAuthTest's asserts the scraped block containsmessages.pendingAsk(at all before asserting what it threads, so a drifted anchor fails loudly instead of passing on nothing. I read both.What this does and does not close
Closed: the read half.
fleet_statusno longer hands a non-creating caller another session's question text,turnIdor ticket id, on either surface. Step 2 of the hijack chain in the ticket body is gone.Still open: the write half, #715. A
turnIdissession + "#" + askSeq.incrementAndGet(), a counter, andanswer()still takes no caller identity. An architect can still guess a turn id and answer a turn it has no part in. Nobody should read this merge as closing the hijack. #715's design is now settled (see my comment there) and it is next after a rebase, since it edits the same file.Adjacent shape, noted not fixed
MessageService.sendAsynchas the same two short overloads thatpollhad —sendAsync(target, content)andsendAsync(target, content, onAccepted)both defaultcreatorTerminaltonull. #718's guard test coverspollonly. A production caller reaching for a shortsendAsyncwould record no creator, and then no terminal-bearing caller could read that task's question or poll its ticket — so it fails closed, which is the safe direction, and it is a usability failure rather than a leak. Not worth its own unit; worth folding into the nextMessageServicechange, which is #715.Closing.