Revoking an architect slot does not revoke it: MemberRegistry freezes fleet.architects, and the reload says it applied #424
Closed
opened 2026-09-10 06:45:35 +02:00 by ltms
·
1 comment
No Branch/Tag Specified
main
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#424
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?
This is the reverse of #404 / #416 / #400, which is what #417 went looking for: a site frozen at the boot snapshot for a key
ConfigRefclassifies as hot. Found by a #417 sweep and verified independently here, line by line, before filing.The defect
MemberRegistryis built once from the boot snapshot and never re-reads the config:Its constructor flattens every role pool into a map at construction time (
MemberRegistry.java:64-76), and its own class javadoc callsslots"A read-only snapshot taken at construction." I checked for any live read:grep -c 'config\.get()\|Supplier' MemberRegistry.javareturns 0. There is no supplier and no re-read anywhere in the class.But
ConfigRef's class javadoc says role pools are hot (ConfigRef.java:29-37):That sentence is true of
CompositePeerLauncher, which really does read the pools live for placement. It is false ofMemberRegistry, which readsfleet.architectsfrozen for identity. Two consumers, one key, opposite behaviour — sofleet.architectsis itself a split key sitting inside the already-splitfleet:key, and nothing anywhere records that.Why this is the dangerous direction
Only the ARCHITECT role reads the snapshot again at runtime.
requireSlotFor(MemberRegistry.java:258-278) andreserve(:281-295) both return immediately for DEV and REVIEWER, then match a spawn'sprofileagainstslotsFor(MemberRole.ARCHITECT)— the frozen map.So the operator removes an architect slot, or repoints it at a different profile, intending to revoke it. The config reloads. And a later
fleet_spawn{role: "architect", profile: "<the removed one>"}is still granted, becauserequireSlotForis checking the pre-edit list. An architect is materially more privileged than a worker — it may send turns and delegate — so a live privilege the operator believes is closed stays open, with no warning anywhere. That is the same overstate-a-capability direction as #404 and #416, one hop further fromConfigRef.The other direction happens too and is merely annoying, but it misdiagnoses itself. Add a slot, and the spawn throws
no architect slot for profile '<X>' — ... fleet.architects carries profiles: <stale set>. That message reads as an operator typo, so it sends someone to re-check YAML they already got right, instead of to a restart.What the operator is told, which is the part that makes this a filing
There are two false statements, and the second is worse because the daemon asserts it rather than merely omitting it.
1. Edit only
fleet.architects. No branch inConfigRefnamesarchitectsoutside its javadoc, sochangedColdKeys/changedDeferredKeys/changedSplitKeysall add nothing.Outcome.summary()reports a bare "config reloaded" andapplied()is true. Nothing deferred, nothing split, nothing to read. The operator has every reason to think it took effect.2. Edit
fleet.leadersas well. Now the split branch fires, and its message says this, verbatim (ConfigRef.java:543-551):For
fleet.architectsthat is not true. The daemon is telling the operator the change applied, at the exact moment they are most likely to be reading reload output carefully.Note the sentence is also accurate about the placement path. That is what makes it hard to spot, and it is the #404 lesson again: a claim that is correct about one consumer of a key, copied onto a key with two consumers.
Reachability
Fully reachable, no unusual configuration needed:
configReload:enabled,fleet.architectsdeclaring at least one slot.fleet.architects— removes a slot, or changes a slot'sprofile.fleet_spawn{role: "architect", profile: <the old one>}is still granted.Why nothing catches it
ConfigRefTopLevelCoverageTest,ConfigRefTopLevelReportingCoverageTestandConfigRefProfileCoverageTestall proveConfigRef's own key sets are internally consistent withFleetConfig's record shape. None of them touchesMemberRegistry— a second, independent consumer in a different package thatConfigRefhas no way to learn about. The checkers verify the classification against the config record, never against the consumers, so a consumer that disagrees with the classification is invisible to all three.That is the gap worth fixing beyond this one instance: a key's class is a claim about every site that reads it, and nothing tests it that way.
What is wanted
State the goal, not the mechanism — pick the implementation yourself.
The architect slot check must see an edit to
fleet.architectswithout a restart, or the reload must stop claiming it applied. Those are the only two honest end states. Either is acceptable, and the choice is a real design call:MemberRegistryalso owns terminal bindings, which are live mutable state it created, not config. A rebuild must not drop or invalidate an existing architect's binding, and a slot that is currently bound and then removed from config needs a defined answer. Say what you chose.fleet.architectsmoves into the frozen half of thefleet:split key, the "already applied" sentence is corrected, and a branch is added so that editing only the architect pool is reported at all rather than silently.Do not do half of the first option. A rebuild that fixes
requireSlotForwhile leavingreservereading a stale map would be a one-way gate, and both are on the spawn path.Acceptance
ConfigRef.reload()removing an architect slot, then asserts the spawn-side check refuses that profile. Assertapplied()on the reload, so the test proves the reload really happened rather than passing because nothing changed.fleet.architectsmust produce a non-empty report. Today it produces nothing at all.Out of scope
fleet.developers/fleet.reviewersin this registry are dead data, and this ticket does not change them.requireSlotForandreserveboth return early for those roles — see the comment atMemberRegistry.java:247-250: those pools are placement candidates only, never a live identity binding. Do not "fix" them; do not delete them either without a separate ticket.ConfigRefexclusion to make a coverage test green.Found by the #417 reverse-mismatch sweep. Verified here:
Fleetd.java:121/355,MemberRegistry.java:20-28/64-76/247-250/258-295,ConfigRef.java:29-37/119/532/543-551, and the zero-hit live-read grep above.Closing: this was already fixed and merged, and the ticket was simply never closed.
Why it stayed open
The fix landed as PR #428, merged in
7667727. The ticket was not closed with it. I only found out by putting a worker on it, which then correctly reported there was no work left. That wasted turn is my fault, not the worker's — I built the unit list from Gitea's open tickets without first checking whether main already contained the fix.What I verified myself, in my own clone, on
origin/main=1fb6176Commits are ancestors of
origin/main:7f672f0fleetd #424: revoke the ARCHITECT privilege on reload, not just future spawnsce74e16fleetd #424: make architect-slot identity checks read fleet.architects live7667727Merge #428: revoke the ARCHITECT privilege on reload, not just future spawnsControl for that ancestry check: of 348 remote branches, 43 are not ancestors of
origin/main, sogit merge-base --is-ancestordoes discriminate here rather than answering yes to everything.The chosen end state was "make it hot", the better of the two the ticket offered.
MemberRegistrynow has 0 frozenFleetConfig.Fleetfields and 4Supplier<FleetConfig.Fleet>references, withslots()readingflatten(fleet.get())on every call.Acceptance criteria, checked against the test
MemberRegistryLiveTest(360 lines, 11@Test, 12 realConfigRef.reload()calls, 13applied()assertions). Every criterion has a named test, both directions:requireSlotForRefusesAProfileWhoseSlotWasRemovedByReload,reserveRefusesAProfileWhoseSlotWasRemovedByReloadrequireSlotForAllowsAProfileWhoseSlotWasAddedByReload,reserveAllowsAProfileWhoseSlotWasAddedByReloadrequireSlotForandreserveare covered, so the pair cannot drift apartanArchitectAlreadyBoundToASlotIsDemotedByReload,theOriginalBindingStillOccupiesTheRemovedSlotSoASecondTerminalCannotClaimIt,unbindStillSucceedsForTheOriginalTerminalAfterItsSlotIsRemovedMy own mutation, because named tests are not proof the behaviour is pinned
Run on
1fb6176in a throwaway worktree. I re-frozeslots()— the supplier stays, but the firstflattenis cached forever, which reproduces the exact defect this ticket describes:Harness-proof cell first, selector alone, unmutated:
rc=0,Tests run: 11, Failures: 0, andNo tests matching patterncount 0 — so the cell below is not void.Mutant:
rc=1,Tests run: 11, Failures: 8, Errors: 1. Nine of eleven tests fail. The fix is genuinely pinned, not just committed.(My failed-method name extraction printed empty in that run — a regex fault on my side — so I am quoting the summary lines, which are unambiguous, rather than method names I did not actually capture.)
Residue, not reopened here
The ticket's own closing point stands and is untouched: nothing tests a key's hot/cold classification against its consumers.
ConfigRefTopLevelCoverageTestand friends check the classification against the config record only, so a second consumer in another package that disagrees stays invisible. The worker's grep-level survey found no new instance of the frozen-field shape — onlyMemberRegistry(now live) andCompositePeerLauncher(already live) take a bareFleetConfig.Fleet, and the other hot keys are read throughconfig.get()or a per-call resolver. That was a grep, not a reflection-based coverage check, so it is a weak negative rather than a proof.Verified on
origin/main=1fb6176.