Model gating units 2+3: enforce the allow-list at spawn, and turn one model off at runtime without editing profiles #422
Closed
opened 2026-09-10 06:39:58 +02:00 by ltms
·
2 comments
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#422
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?
Follow-up to the "central allow-list of usable models" ticket, which shipped config-load validation only.
FleetConfig.Models' javadoc says so and names what was left out:This ticket is the first two of those: enforce at spawn and runtime on/off.
Why these are one unit, not two
Split apart, each half ships inert. A gate with no flag to read always allows; a flag nothing reads always does nothing. Both would pass a full build with every test green. That is the silent-default-disables-features shape, and it has already cost this project a shipped-but-off feature.
So: one worker, one PR.
What the operator actually needs
Real case, today.
xfrunsopencode/nemotron-3-ultra-free, and the opencode subscription ran out of its weekly allowance.solandterrarunopenai/gpt-5.6-sol/openai/gpt-5.6-terraon a subscription that just came back. The operator wants to turn a model off while its allowance is gone and on when it lifts, without editing theprofiles:block — because a profile'smodel:,argv,envand the rest are deferred launch settings, so editing them needs a full daemon restart, and a restart drops every in-flight ticket.Turning a model off must therefore be a hot change.
The design trap, and the one thing that must not be got wrong
allowand a new on/off flag answer two different questions, and collapsing them makes the feature unusable:allow= which model ids may appear inprofiles:at all. Checked at config load byvalidateModels().If "off" were implemented by removing the entry from
allow, then turning a model off would makevalidateModels()refuse the whole reload — because the profile still names that model, andConfigRef.reload()runsvalidateAll()and refuses the config outright rather than applying it partially (ConfigRef.java:449-455). The operator's only way to turn a model off would then be to also editprofiles:, which is exactly what this ticket exists to avoid.So an entry that is off stays in
allow. It stays valid to name; it just stops being spawnable.Where the flag goes
Models.ModelEntryis already a record for exactly this reason — its javadoc says:So add the field there. Absent ⇒ on. Every config on every host was written before this field existed and must keep working unchanged — the same compatibility reasoning that makes an absent
models.allow:mean "off".Where the gate goes
CompositePeerLauncheralready holds this exact family of spawn refusals, and the model gate is a fourth sibling:Read the live config, not a snapshot.
profiles0()at:301is the pattern to copy — it callsprofileConfigs.get()on a supplier every time, which is whymaxLoad/weight/credentialIdare hot. A field captured at construction would give you #416 in reverse: an operator turns a model off, the reload reports success, and spawns keep landing on it forever.Both halves of the gate, or it is a one-way gate
There are two paths into a spawn, and each family of refusal above covers both:
fleet_spawn{profile: "xf"}— refused by theenforceXmethods at:332-334.quarantinedProfilesat:445andcoolingOffProfilesat:470, which feedPlacementContextat:352.Covering only path 1 means an unqualified
fleet_spawnstill lands on a disabled model. Covering only path 2 means an explicit spawn does. Both, or the unit is not done. This is the a-one-way-gate-is-not-a-gate lesson: ask which states open it, not only which close it.modelsmust be reclassified, or the reload report goes falsemodelsis inConfigRef.DEFERRED_KEYS(ConfigRef.java:227-229), and that is correct today — the class javadoc explains why at:46-51: a good edit "has nothing built at startup to rebuild", so it is reported deferred rather than silently swallowed.This ticket makes that false. Once the spawn gate reads
modelslive, amodels:edit takes effect on the very next spawn. A reload that still reports "these changes need a restart to take effect" would be telling the operator to restart for a change that already applied — and the operator would restart, dropping live members, for nothing.Work out the honest class and move it. Read the
hot/deferred/cold/splitdefinitions in theConfigRefclass javadoc and pick from them; do not invent a fourth. Two facts to weigh:validateModels()re-runs on reload and refuses a bad edit outright ⇒ already handled by the catch block, and not a reason to call the key cold.State your reasoning in the javadoc next to the classification, the way every other key there does. If the answer is
split, say which half is live and which needs a restart, in the same shape as the existinghealth:/coordinator:/fleet:bullets.ConfigRefTopLevelCoverageTestandConfigRefTopLevelReportingCoverageTestboth read those sets and will hold you to triaging the key. Do not add an exclusion to get them green — that is the escape hatch #323 was filed for.The receipt must read what the gate reads
fleet_profilesshould report which models are currently off, so an operator can see the gate's state without reading the config file. That report must read the same source the gate reads — the live config.This is #404 exactly:
exhaustionDetectionArmedread the live config while the detection it described read the startup snapshot, so the status field promised something the behaviour could not deliver. Do not repeat it in the other direction either. If the gate reads live and the report reads live, they agree by construction; anything else needs a test proving they agree.Acceptance criteria
fleet_spawn{profile: <that profile>}. The message names the model and says the operator turned it off — distinct wording from the quarantine and cool-off refusals, so a lead reading a spawn failure can tell the three apart.fleet_spawnnever places onto a profile whose model is off. When every candidate's model is off, the error names that as the cause rather than reporting a generic "no candidates".ConfigRef.reload(), and assertapplied().modelsis reclassified inConfigRefwith its reasoning written down, and both top-level coverage tests pass without a new exclusion.fleet_profilesreports the off models, read from the live config.validateModels()must not refuse it. This is criterion 3's mirror and the whole point of the ticket; test it directly.deepseek-v4-flashis named by bothlocalandlocal-direct, so turning it off takes out two profiles at once. That is the correct meaning — the model is what is rate-limited, not the profile — but it must be documented in the field's javadoc, because it will surprise someone.Prove your own tests
For each of criteria 1, 2 and 4, break the fix on purpose and confirm the matching test goes red, one mutation at a time, restored afterwards:
Report the exact failing test name and assertion message for each. A mutation that leaves the suite green means that half is not pinned, and the unit is not done. Mutation B and C exist because they are the only thing that proves the two halves are independently load-bearing — a single test that happens to exercise both would hide a missing one.
Out of scope — do not build these
BackendQuarantineorBackendOutagePolicy. The model gate is a fourth, independent reason to refuse a spawn — it must not reuse or extend the quarantine machinery, which is for backend-reported outages, not operator intent.fleetd.yaml. It is gitignored and you cannot see the real one. Reproduce the shape you need in a@TempDirfixture.Reference — the live shape
Model ids are a single flat opaque-string namespace on purpose — a bare Claude id and a provider-prefixed opencode id both fit unchanged, because the comparison is exact string equality and never parses a provider prefix or branches on a profile's
kind:. Keep that property.Measured on the second host: the gate ships green and inert there, and nothing says so
Checked fleet01's live config read-only over
ssh, because this feature is driven entirely by a gitignored file and no test can see one:The Mac has a
models:block with 7 entries. fleet01 has none.This is not a defect in PR #429.
models0()normalising a missing block toNO_MODELS_CONFIGUREDso the gate never fires is the correct back-compat behaviour, and it is deliberately documented that way. The problem is what the operator is told.On a host with no
models:block, every part of this feature is silently absent:enforceModelEnablednever refuses,modelOffProfilesis always empty, anddisabledModels()returns an empty set whichfleet_profileswill report as "nothing is off" — indistinguishable from "everything is on and the gate is working". An operator who flips a model off on the Mac, sees it work, and expects the same lever on fleet01 gets nothing, with no warning at any point.That is the "gitignored config ships inert" shape, and it is also the #415 shape one level up: "off" and "no fallback configured" are different facts and must read differently.
Follow-up unit (not for the worker currently on #429 — do not add scope mid-round)
A startup coverage line plus a status field, in the style already established by
CompletionResolver.coverage(#415) andFleetHealthMonitor.coverage:off (no models: block configured),armed (N models allowed, none turned off), orarmed (N allowed, M turned off: <ids>). The three must be distinguishable, and the "no block" case must not read as "nothing is off".fleet_profiles/GET /profiles: report the gate's state, not only the off-list. An emptydisabledModels()currently conflates "no block" with "block present, nothing off".models0()— the same accessorenforceModelEnabledandmodelOffProfilesread. PR #429 already got that right fordisabledModels(); the new field must not introduce a second source.coverage(...)-style helper with the right signature can still be called with the wrong arguments and leave the whole suite green. Assert whatFleetdpasses.This also needs a decision I am recording rather than leaving open: a missing
models:block stays permitted. Requiring one would break every existing config, including fleet01's, and the allow-list was shipped in #398 as opt-in on purpose. The fix is to make the inert state visible, never to make it fatal.The follow-up unit is shipped and live. Closing, with one deviation from what I asked for.
The follow-up in the comment above asked for three distinguishable gate states, in both the startup log and the tool surface, both reading
models0(). Checked against the running daemon and the code, not the PR.The live daemon's startup line:
The live tool surface (
fleet_profilesright now):All three states are distinguishable, which was the actual defect this ticket named — an empty
disabledModels()conflating "no block" with "block armed, nothing off":models:blocknot configured (no models: block — nothing is gated, and nothing can be)modelGateArmed: falsearmed (models: block present; 0 models currently turned off)modelGateArmed: true, nomodelsOffarmed (N model(s) turned off: [ids])modelGateArmed: true+modelsOffThe #404 rule is followed, and the code says why. One read, one source:
and that accessor is the one the gate enforces on:
PeerLauncher.java:337-338even carries the instruction that stops this drifting again: "do not override this method separately from that one." That is the right shape.The deviation, stated plainly
I asked for
armed (N models allowed, none turned off). The shipped line does not report N. It says "block present", not how many models the allow-list covers, andfleet_profilesdoes not carry that count either.I am not reopening for it. The defect this ticket was filed about was the three states reading identically, and that is fixed in both surfaces. The allowed-model count is a nice-to-have that conflates nothing — an operator can read the count from the config file, which is the same place they would go to change it. Recording it so nobody later reads my original wording as an unmet criterion.
Decision from the comment above, still standing
A missing
models:block stays permitted. fleet01 has nomodels:block and must keep working. The fix was to make the inert state visible, never to make it fatal — andmodelGateArmed: falseis exactly that. Closing.Units 1 and 2 of the operator's three-part ask are therefore live. Unit 3 (limit monitoring — turn a model off when a subscription limit is reached) is #446, which is a structural problem rather than missing code: the gate's key is hot and the detector's key is deferred, so a model can be turned off at runtime but detection cannot be armed at runtime. That is delegated.