A turnId is a guessable counter and answer() takes no caller identity, so any architect can answer a turn opened for someone else — the same shape as #705, on a write path #715
Closed
opened 2026-10-04 07:03:14 +02:00 by ltms
·
8 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#715
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 the implementer on #705 as an out-of-scope observation, then checked by me in the main
clone.
The two facts
A
turnIdis predictable.Rendezvous.java:148:askSeqis oneAtomicLongperRendezvous(:77). So a turn id is the target session id plus asmall integer —
term_abc#1,term_abc#2, and so on. A caller that knows a session id, which anyREADholder does today, can enumerate turn ids.answer()takes no caller.MessageService.java:1150:It resolves the worker from the turn id and proceeds. There is no check that the caller is the
session whose blocked
fleet_sendowns that turn.And the action is granted broadly.
Authz.java:118:What that adds up to
Any architect can answer a
fleet_askthat was opened for a different architect, or for theprimary. A worker is not in the attacker set here, so this is narrower than #705.
It is also worse in kind. #705 is a read: you learn another session's reply. This is a
write: the answer is injected into the worker's resumed turn, so whoever answers steers what
that worker does next. A lead's worker can be redirected by an architect the lead never involved.
Same shape as #705
An identifier is being used as if it were an authorization token. #705's ticket ids are a plain
counter with no owner check; these turn ids are a plain counter with no owner check. PR #712 fixed
the ticket case by recording the creating caller's terminal on the
Taskand comparing it onpoll. The same shape of fix applies: record the asking caller's counterpart on the rendezvousentry and compare it in
answer.Decide one thing before implementing: a
fleet_askis answered by the lead that delegated theturn, which is recorded in
PrimaryRegistry, not by whoever happens to holdANSWER. So thecomparison is probably against the recorded delegator, not against the architect that called.
Not measured
Nobody has driven this from a second live architect session. All three facts above are read from
the source at the lines quoted. In particular I have not confirmed that a second architect can
reach
fleet_send{turnId}for another architect's turn end to end — only that no code on the pathwould stop it.
Design call — lead, 2026-10-04
I read the code in the main clone at
c468953and decided this. All line numbers below are fromthat commit.
Decision 1 — do NOT compare against
PrimaryRegistry. Reject that route.The issue text above suggests comparing the caller against the recorded delegator in
PrimaryRegistry. I checked that class and I am rejecting it, for two reasons.It is keyed by the wrong thing.
PrimaryRegistryholdsConcurrentHashMap<String, String> leadByTarget(:36), keyed by the target session, not bythe turn.
recordDelegation(target, leadTerminal)(:79) overwrites the entry for that target, andforgetDelegation(target)(:87) clears it. So its lifetime is the lifetime of the newestdelegation to that worker, not the lifetime of the turn we want to protect.
It is a nudge-routing map, not an authorization record. Its only production reader is
nudgeTargetFor(target)(:103). If the gate reads that map, the gate reads a different sourcethan the delivery did. We already have a defect of exactly that shape on file — a receipt that reads
a different source than the behaviour reads. I do not want to add a second one on a write path.
Decision 2 — use #712's shape: record the owner on the turn, compare it in
answer.PR #712 fixed the ticket case by recording the creating caller's terminal on the
Taskandcomparing it on
poll. The same shape fits here, and it is exact rather than approximate.The reason it is exact: the question reaches the lead through the waiter.
ask()doesrendezvous.currentWaiter(workerSession)(MessageService.java:1057), thenresolveQuestion(...)(:1059) completes that waiter. The waiter was registered byrendezvous.open(target)on the sender's behalf. So the session that opened the waiter is theone and only session that ever learns the
turnId. That is the owner. One writer, one reader, andthe turn's own lifetime.
rendezvous.open(has exactly two call sites — I enumerated them withgrep -rn "rendezvous\.open(" .:MessageService.java:970— the forwardsendMessageService.java:1161— the resumed-turn waiter insideansweritselfWhat the unit has to do
Give the blocking
sendchain acreatorTerminal. It has none today. I checked thesignatures:
sendat:929,:945and:950take no terminal, whilesendAsyncat:1302already takes one (that was #712). This is the missing half.
Stamp the owner on the ask when the turn is minted — the
fresh()branch ofRendezvous.openAskonly. A coalesced duplicate ask keeps the first owner; it does notre-stamp.
Compare in
answer, with the same null rule asownsTicket.ownsTicket(
MessageService.java:1434) iscallerTerminal == null || callerTerminal.equals(...). The nullallowance is for the unnamed primary, which carries no herdr pane. That rule carries over here
unchanged, because an architect always has a real terminal.
Return a refusal that is distinct from
STALE_TURN. A caller must be able to tell "this turnis not yours" from "this turn lapsed". Today
answerreturnsSTALE_TURNfor an unknownturnId(:1152), and reusing it would hide the refusal.Fix both gates.
messages.answer(turnId, content, timeout)is called from two places, andneither passes a caller:
FleetMcp.java:905FleetApp.java:691Point 5 is the part most likely to be missed. #705 had this exact shape: the MCP handler was
fixed and the REST door stayed open, which is what task-15 is closing now. A check at one gate of
two is not a check.
The coupling this inherits, written down so it is not rediscovered
The null allowance in point 3 is safe only because
Authz.java:118iscase ANSWER -> caller.isPrimary() || caller.isArchitect(), so anANONYMOUScaller never reachesanswerat all. Nothing in the code pins that. GrantingANONYMOUStheANSWERaction wouldsilently open this gate. This is the same unpinned coupling that #705's fix depends on, now on a
second path.
Still not measured
I did not drive this from a second live architect session, and nor did the issue author. Every
claim above is read from the source at the lines quoted. So "any architect can answer another
architect's turn" remains a reading of the code, not an observed event. The unit should include a
behavioural test that makes it observed, with a control half that passes for the real owner.
Design call, measured in the main clone at
9a64d42The previous lead left this as "needs a design call first: compare against the recorded delegator in
PrimaryRegistry, not against whoever holdsANSWER." I worked that through. The direction isright but the named field is wrong, and the obvious version of the fix would break the architect
role.
How guessable a
turnIdactually isSo a
turnIdisterm_65cfd7f2d6b4270#3— the target's terminal id plus a small counter. It isnot a bare sequential id like a ticket, which weakens the original framing: you must know a session
id before you can guess a turn.
That changes who the realistic attacker is. #710 now hides the
membersandleadsarrays from aworker — I confirmed that live today, a worker's
fleet_listreturns onlyhealthCoverage,loopHealthandcapacity. So a worker can no longer enumerate session ids from the bridge at all.But
membersVisibleToadmits an architect, andANSWERiscaller.isPrimary() || caller.isArchitect(). So the reachable case is an architect answering a turn belonging to adelegation it has nothing to do with.
Why
ownerTerminalis the wrong field — this is the part that mattersThe recorded spawn owner does exist, and it is on the session, not in
PrimaryRegistry:But
case SPAWN -> caller.isPrimary(). Only a lead can ever spawn, soownerTerminalis alwaysa lead's terminal or
null. Gatingansweron it would mean an architect can never answer any ask —which deletes a capability the role table grants on purpose. The
Authzcomment states that intent:An architect cannot spawn but can
SEND, so an architect answering an ask from a member a leadspawned is intended, not an abuse. A spawn-ownership check would forbid the normal case and allow
nothing new.
The field to compare against instead
Compare against who sent the brief that is currently running, not who spawned the pane. That
record already exists, because #705 added it:
Task.creatorTerminal, the fieldownsTicketreads.Whoever delegated the turn is the party whose question it is, and that is true whether they are the
lead or an architect.
Two traps this fix must not walk into, both already paid for here
null. That is exactly #718:MessageService.poll(String)forwards withnull, andownsTickettreatsnullas "skip thecheck", so the short form fails open. If
answergains a caller parameter, change thesignature — do not leave a shorter one behind for a future call site to find.
fleet_sendand the REST path do not necessarily leave aTask. If the check iscaller == null || caller.equals(recorded)it fails open and buys nothing. If it is a strictequality against a
nullrecord, every such rendezvous becomes unanswerable — which is thesame break task-15 found in #705, where gating the ticket read alone would have closed a hole and
broken the documented REST fallback in one commit. Enumerate the paths that open an ask with no
recorded sender before writing the comparison, and state the chosen behaviour for each.
Still not measured
I have not driven the hijack end to end: two leads, one member, an architect answering the other
delegation's turn. Everything above is read from
Rendezvous.java:148, theAuthztable, theSPAWNrow andMemberSession's javadoc. The reachability argument rests on an architect being ableto read
members, which I confirmed frommembersVisibleTo, not from a live architect session.Not delegating yet: trap 2 needs the path enumeration done first, and it touches
MessageServicewhere #718's scrape test is in flight.
Correcting my own comment above: the
turnIdis handed out, not guessed — see #721In the comment above I wrote that a
turnIdissession + "#" + askSeq.incrementAndGet(), andconcluded:
That mitigation does not hold. An architect reviewing #705 found that
fleet_statushas no ownercheck, and I verified it on both the MCP and REST surfaces.
FleetMcp.statusreturns the pendingask's question text, its
turnIdand itsticketfor anysessionIdthe caller names, gatedonly on
TASK_READ. Details and the quoted code are on #721.So there is no guessing step at all:
fleet_list→ every member'ssessionIdREAD(membersVisibleToadmits an architect)fleet_status{sessionId}→ that member'sturnId+ question textTASK_READfleet_send{turnId, content}ANSWER= primary or architectThis makes #715 more serious than its title says, not less, and it changes the scoping:
TASK_READholder.Task.creatorTerminal, on the read sideand the write side. They are probably one change, and #721 is the half that leaks content, so it
leads.
MemberSession.ownerTerminalis thewrong field —
SPAWNis primary-only, so gating on it would forbid the normal architect case andpermit nothing new — and that the "no recorded sender" case must be decided explicitly, because one
direction fails open (#718's shape) and the other breaks the REST path (the break task-15 caught in
#705).
Still not measured, unchanged from above: nobody has driven the hijack from a live architect session.
Decision — settled by an architect (profile
sol) at6e06058, and I accept it. This supersedes the "decide one thing before implementing" paragraph in the ticket body, which guessed wrong.The decision
The owner of a turn is the caller whose accepted delegation started the running turn. Record that owner on the forward rendezvous; when a fresh
fleet_askopens, copy it onto the ask turn;answer()then compares the answering caller against that stored snapshot.Do not look ownership up at answer time from
Task,MemberSession, orPrimaryRegistry.One authorization rule covers both paths. They differ only in the capture input:
send()Task.creatorTerminalMy own ticket body was wrong about the mechanism
The body says "the comparison is probably against the recorded delegator" in
PrimaryRegistry. The architect rejected that with reasons I checked:PrimaryRegistryis keyed by worker session, not turn, and a later accepted send overwrites the value (PrimaryRegistry.java:69-84). A blocking send returnsQUESTIONand releases its session lock (MessageService.java:989-1036) while its ask stays open — so a second send can change a session-scoped routing value while the older turn is still answerable.:79-82), which loses the unnamed-primary case entirely.nudgeTargetFor(),:93-105), not authorization.So the owner has to be snapshotted per turn, not looked up per session.
Three corrections to premises, all of which I verified myself
1. The async callers do not always pass a real terminal. This is the one that matters most, and I had asserted the opposite on #721:
So
Task.creatorTerminal == nullis a valid production state.2. The blocking waiter does not expose the sender.
Rendezvous.waitersholds only aCompletableFuture<Resolution>(Rendezvous.java:73). The entry represents the blocked send but carries no sender identity. So there is nothing to read today, and this is why the fix needs a new field rather than a lookup.3. Neither adapter passes a caller into blocking send or into answer. I confirmed all four sites:
That makes this a four-file unit, not the one- or two-file change the body implies.
And one addition to my own reasoning about
MemberSession.ownerTerminal. I had ruled it out on #721 on the grounds thatSPAWNis primary-only, so gating on it "would forbid the normal architect case while permitting nothing new". The architect agrees but shows the second half is too weak: a spawn-owner lead would keep the right to answer every later turn on that member, including one an architect delegated. So it does not permit nothing new — it permits a stale right. Rejecting it is still correct, for a stronger reason.The null rule — three states, not two
This is the part I would have got wrong, and it is worth stating plainly because it is the opposite of what #721 does.
So the implementation needs record presence kept separate from the nullable terminal value.
ownsTicket's two-statecallerTerminal == null || equals(...)is adequate for #721's read path and is not adequate here: a missing record must not inherit the unnamed-primary exception. Collapsing "unknown" into "unnamed primary" is the trap.Also: keep ownership refusal distinct from
STALE_TURN, and run the ownership check before taking the session lock or opening the resumed waiter (MessageService.java:1155-1161), so a refused caller cannot consume the ask, clear question state, or delay the real owner.Unit shape
One vertical unit, not split — splitting the core ownership record from either adapter leaves a working bypass. Files:
Rendezvous.java,MessageService.java,FleetMcp.java,FleetApp.java. NoAuthzpolicy change; theANSWERrole gate stays as the outer check and gets a test pinning that coupling.Scheduling: this rebases after #721. Both edit
MessageService, and #721 is in flight now. The architect flagged the same thing independently.Full acceptance properties are in the architect's reply and I will put them in the brief when I delegate. The two I most want pinned: a refused answer performs no rendezvous, task, or question cleanup, and no production answer overload can silently substitute a null caller — the second is the #718 shape, one file over.
Limits
The architect read the code and changed nothing (
git status --shortempty at6e06058). It ran no build and no live hijack, and did not read #721's unmerged branch. The end-to-end hijack is still not demonstrated from a live second architect session — that was unverified when I filed this and it still is.Do not build this on
PrimaryRegistry— measured 2026-10-04 at28a1f3dThere is already a map from a member to the lead that delegated to it, and it looks like exactly what
this ticket needs. It is not. Writing this down before an implementer finds it and reuses it.
mcp/PrimaryRegistry.java:36holdsleadByTarget, aConcurrentHashMap<String, String>keyed by themember's target, written by
:79 recordDelegation(target, leadTerminal)and cleared by:87 forgetDelegation(target). It is wired throughMessageService.send'sonAcceptedhook (CB-548).Three reasons it cannot serve as the authorization gate:
recordDelegationismcp/FleetMcp.java:490, which covers both the blocking send (:494) and the async one (:493).The REST blocking send at
rest/FleetApp.java:704callsmessages.send(id, content, timeout)andpasses no
onAccepted, so it records no delegator at all. A gate reading this map would havenothing to compare for a REST-delegated ask. This is the same one-line-two-routes shape as #721.
forgetDelegationhas two production callers:Fleetd.java:700andmsg/ReplyPushLoop.java:450. The second clears it when the delegating leadlooks dead, which has nothing to do with whether an ask is owned. A gate built on it would change
its answer for a reason unrelated to ownership.
two successive delegations to the same member — which is the case the gate exists to separate.
So the record this ticket needs is its own, and
msg/Rendezvous.java:63'sAskWaiteris still theright home — it is already per-
turnId, which is the key the gate needs. The record is constructed atexactly one site,
:150, so adding a field has one place to fill it.One observation I am deliberately not filing as a defect
FleetApp.java:704recording no delegator may well be correct: a blocking caller is holding theconnection, so there is no reply to nudge anywhere. I did not find a path where its absence causes a
wrong outcome, so I am not calling it a bug. Noting it only because an implementer reading
recordDelegation's single call site should know the asymmetry is already there and is not theirs tofix in this ticket.
Lead review of
dd18bd1— the fix is right. One deletion before I merge.I read the production diff at
dd18bd1. The design is implemented correctly and the two traps I most cared about are both avoided.The three-state null rule is right.
Rendezvous.Owner.permitsisso a
nullowner — no record at all — matches nobody, including a caller whose own terminal isnull.Owner.UNNAMED_PRIMARYis a recorded owner with a null terminal and does match the unnamed primary. "No record" and "recorded as the unnamed primary" stay distinct, which is the part that would have been easy to collapse.No null-defaulting convenience overload survived on the gated methods. The 3-arg
answer(...)and the 3-argsend(...)are gone rather than kept as forwarders. That was the #718 shape and it is not here.The check runs early enough.
answerresolves the session, returnsSTALE_TURNfor an unknown turn, then checks ownership — all beforesessionLocks.computeIfAbsent, beforetryLock, and beforerendezvous.open. So a refused answer performs no rendezvous, lock, task or question work.NOT_TURN_OWNERis a separate outcome and is excluded from the completed-delegation mapping alongsideSTALE_TURNandQUESTION.The resumed waiter inherits the same owner (
MessageService.java:1182passesowner, not the answering caller), so answering a turn does not transfer ownership of it.The one change I want: delete
Rendezvous.open(String)Rendezvouskept a 1-arg overload:That is a convenience overload which defaults the owner to
null, which is what the brief asked you not to leave behind. Two things make it a smaller problem than #718, and I want to be accurate about that:nullowner makespermitsrefuse everyone, so a misuse produces a loud unanswerable turn rather than a silent bypass;git grep -nE '\b(rendezvous|rv|r)\.open\([^,)]*\)'acrossfleetd/srcand got nothing, with a positive control confirming the pattern does find the 2-arg form.So it is dead code whose only behaviour is to produce the fail-closed state. #718 handled the equivalent case one file over by pinning that no production caller uses the short form, but deleting is strictly better here: a method that does not exist needs no pin and cannot be found by a future caller.
Please delete the 1-arg
open(String)overload and re-runmvn -o clean install. If the build then fails because something does call it, stop and tell me what — that would mean my grep was wrong and I would rather know.Nothing else. Both production files and the test additions otherwise look right to me, and I will do my own build and mutation pass on the final commit.
Correcting comment 18794: my grep was broken, so "delete the overload" was wrong advice
The implementer refused that instruction and checked my measurement instead of following it. It was right to. Do not act on comment 18794's deletion request.
What was wrong
I wrote that
Rendezvous.open(String)has no caller, measured with:\bis a GNU-grep escape.git grep -Euses a POSIX ERE engine where it is not a word boundary, so the whole pattern silently matches nothing. I verified this myself on one file that definitely contains the text:Same file, same text, one escape apart. My zero was the pattern failing, not the search succeeding.
And my "positive control" did not control anything. It checked that a different pattern —
\.open\([^,)]*,, with no\b— still found the 2-arg form. So it exercised a pattern that could not fail in the way the real one did. A control has to run the same mechanism that might be broken, or it only proves that some other pattern works. That is the part I got wrong twice: once in the measurement, once in believing I had guarded it.The real count
81 call sites of the 1-arg form, across 7 files, all test-only:
inject/CompletionResolverTest.javamsg/RendezvousTest.javaFleetdTurnListenerCompositionTest.javainject/InjectorTest.javaFleetdTurnRegistrarWiringTest.javainject/BackendOutageFlowTest.javamsg/MessageServiceTest.javaTwo small corrections to the implementer's own figures, which I checked: it reported 82 across 8 files and included
health/FleetHealthMonitorTest.java, which has no call. And the single hit my grep found undersrc/mainis a javadoc reference,{@code rendezvous.open()}athealth/FleetHealthMonitor.java:95, not a call site. So its central claim stands: no production caller, and deleting the overload would break compilation across six files outside #715's scope.Decision: keep the overload, and pin it the way #718 pinned
poll(String)Deleting is off the table — it is an 81-site migration across unrelated files, which is not this ticket.
The overload stays, and it is materially safer than #718's residual: a
nullowner makespermitsrefuse everyone, so a future production misuse produces a loud unanswerable turn, not a silent bypass. What it still lacks is anything stopping that misuse from being introduced, which is exactly what #718 added forpoll(String).So: a source-scrape test asserting no production caller uses the 1-arg form — with a mandatory positive control, for the reason this comment exists. A scrape test whose pattern is broken reads as a clean pass, which is the same failure I just made, promoted into the suite where it would sit unnoticed. The control must prove the pattern finds a real call, by matching one of the 81 known test call sites.
Lead verification of
2374de2— my own build and mutation passI said in comment 18794 that I would do this myself. Here is what I ran and what it showed.
Merge identity. I merged
origin/worker/715-5c43fc-1ontoorigin/main(ed4f4b0) in a throwawayworktree, no conflicts. The merge tree and the branch tree are the same object
(
ac0147a5bfe2f6d1a199768525783b1c616bc331both ways), so the worker's green build does cover thismerge. I built it again anyway.
Baseline:
Tests run: 2040, Failures: 0, Errors: 0, Skipped: 0— the same count the workerreported.
Mutations
Each one was line-anchored, gated on a compile before the suite, then restored and checked
byte-identical against
HEAD(git diff --statempty every time).Owner.permits→return trueRendezvousTest,MessageServiceTest,FleetMcpTest,FleetAppAuthTestSTALE_TURN, notNOT_TURN_OWNEROwner.of(callerTerminal)instead ofownernullownersecondFleetAskInTheSameResumedTurnDoesNotKillTheAsyncTicketrendezvous.openNOPE(openAskstampsOwner.UNNAMED_PRIMARYinstead ofownerOf(session)M3 is my own bad mutation, not a gap in the tests. By the time line 1182 runs,
permitshasalready proved
owner.terminal()equalscallerTerminal, soOwner.of(callerTerminal)isowner.The mutation cannot change behaviour, so its survival is not evidence of anything. M3b is the
mutation I should have written first, and it is killed.
M4b is the one I most wanted. It fails with:
That is exactly the failure mode of my wrong
git grepin comment 18794: a pattern that matchesnothing reads as a clean production result. The control fires first and names the real cause, so
that mistake can no longer pass as evidence. This test now pins the lesson in CI.
Two corrections to my own method, recorded because they both produced vacuous numbers:
mvn compiledoes not compile test sources —so its
exit=0proved nothing and themvn testfailure that followed was a compile error, not afailing control. A mutation in a test file must be gated on
mvn test-compile.baseline exit=$?aftermvn … | tail -25readstail's status, not Maven's. The load-bearingevidence for the baseline is the surefire aggregate, and I re-ran
mvn -o clean installwith theexit code captured directly.
One limitation of the new test, not a blocker
scanFileForOpenCallsanchors on the literal receiverrendezvous.open(. A future production callthrough a differently-named reference (
rv.open() or a bareopen(session)insideRendezvousitself would not be seen. That matches the current convention everywhere in
src/main/java, and thearity classification is the part that mattered, so I am not holding the merge for it. Noting it so
nobody later reads a green result as broader than it is.
Verdict: merging. The fail-open overload is gone from production, the three-state rule
(
no record/unnamed primary/named terminal) is pinned by direct assertions, and all fourentry points — unit, service, MCP and REST — fail when the gate is removed.