diff --git a/fleetd/fleetd.example.yaml b/fleetd/fleetd.example.yaml index 5f6fc07..0a3d369 100644 --- a/fleetd/fleetd.example.yaml +++ b/fleetd/fleetd.example.yaml @@ -877,3 +877,32 @@ guard: # terminal: term_65619bd6174568 # pushReminders: 5 # pushBackoffMs: 15000 + +# Central allow-list of models any profiles: entry may name. Nothing checked a profile's model: +# value before this block existed — it was a free-form string handed straight to the backend +# adapter, and a withdrawn or misspelled name failed silently instead of at config load (opencode +# falls back to a default model rather than erroring on an unknown -m). +# +# Absent, or present with an empty allow:, is OFF: no profile's model: is checked, exactly like +# before this block existed. fleetd.yaml is gitignored on every host, so an upgrade must not force +# every operator to enumerate their models before the daemon will start. +# +# The list is the authority; profiles: is checked against it, never the reverse — adding or +# editing a profiles: entry cannot, by itself, widen what is permitted here. +# +# Enforcement is at CONFIG LOAD only (a bad model: fails the daemon at startup, naming both the +# model and the profile). There is no spawn-time enforcement, no runtime on/off switch, and no +# interaction with BackendQuarantine — those are separate, later units. +# +# allow → the permitted models. Each entry is its own block (not a bare string) so a later unit +# can add an on/off state or a load limit per model without changing this shape. +# model → the model id exactly as a profiles: entry's model: field would write it. One flat, +# opaque-string namespace: a bare Claude id (claude-sonnet-5) and an opencode +# provider-prefixed id (openai/gpt-5.6-terra) both fit here unchanged — the check is a +# plain string match, never a parse of the provider prefix or a branch on kind:. +# models: +# allow: +# - model: claude-sonnet-5 +# - model: claude-opus-5 +# - model: openai/gpt-5.6-terra +# - model: amazon.nova-pro-v1:0 diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index cc9191d..ed194a5 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -148,19 +148,16 @@ public final class Fleetd { SubscriptionGuard guard = new SubscriptionGuard(cfg.guard().hostSet()); guard.assertPrimaryClean(System.getenv()); - // CB-501: refuse to start if the bind is wider than the auth mode can defend. Under - // loopback-trust, "not a known worker" means "the primary" — sound only because the OS - // refuses remote connections to a loopback socket. This throws rather than warns so the - // dangerous configuration cannot be reached by ignoring a log line. - cfg.validateAuthExposure(); - cfg.validateLeadTabPrefixes(); - // CB-542: a subscription:true profile whose env: reseats ANTHROPIC_BASE_URL/AUTH_TOKEN would - // reach an unguarded endpoint (the launcher skips SubscriptionGuard for it). Refuse at load. - cfg.validateSubscriptionProfiles(); - cfg.validateCharters(); - // CB-548: every architect slot must name a configured workers: profile — the strong-model - // backend the future spawn lifecycle would read. A stale reference dies here, not later. - cfg.validateMembers(); + // Every FleetConfig.validateXxx() the operator's config can fail — CB-501's auth-exposure + // check, CB-531's lead-tab-prefix check, CB-542's subscription-profile check, the charter + // and member-slot checks, and the "central allow-list of usable models" check — must run + // here, at load, before anything below opens a socket or spawns a member. fleetd ticket + // "central allow-list of usable models" follow-up: mutation testing found six individual + // calls here with nothing proving any of them still ran (deleting one left the full suite + // green). validateAll() replaces them with the one call that FleetConfigValidateAllTest + // and the Fleetd-startup tests actually pin — see FleetConfig#validateAll's javadoc for + // why a name-by-name list here would have the same defect it replaces. + cfg.validateAll(); Path socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank() ? Path.of(cfg.herdrSocket()) diff --git a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java index aa5072a..5ee4dcd 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java +++ b/fleetd/src/main/java/dev/ltms/fleet/config/ConfigRef.java @@ -43,6 +43,12 @@ import java.util.function.Supplier; * running daemon keeps whatever this was at startup regardless of a later edit), * {@code spawnReadyTimeoutMs} / {@code spawnReadyPollMs}, {@code quarantineCooldownSeconds} * (CB-578 stage B — baked once into the {@code BackendQuarantine} built at startup), + * {@code models:} (fleetd ticket "central allow-list of usable models" — {@link + * FleetConfig#validateModels()} re-runs against the fresh config in {@link #reload()} + * (via {@link FleetConfig#validateAll()}), so a + * models.allow: edit that would refuse to boot still refuses the reload; a change that + * passes has nothing built at startup to rebuild, so it is reported deferred rather than + * silently accepted with no report at all), * {@code guard:}, {@code worktreeRoot:}, {@code worktreeGroup:} and {@code memberSkills:} * (all three of the latter baked once into the {@code GitWorktrees} built at * {@code Fleetd.java:251} and never rebuilt — fleetd #323 instance 2 found @@ -135,8 +141,9 @@ import java.util.function.Supplier; * * *
The denominator, measured on 2026-09-04 (fleetd #330; recounted for fleetd #333);
- * recounted again for fleetd #362, and again after {@code idleSleepGuard:} was added.
- * {@code FleetConfig} has 24 top-level record components: 5 cold, 13 deferred, 3 split, 3
+ * recounted again for fleetd #362, again after {@code idleSleepGuard:} was added, and again after
+ * {@code models:} was added.
+ * {@code FleetConfig} has 25 top-level record components: 5 cold, 14 deferred, 3 split, 3
* hot-excluded. Three of them are named nowhere in this file, and the reason is the same for all
* three: {@code placement}, {@code memberCredentials} and {@code memberLoginShell} are
* hot and correctly absent — all three are read live off {@code config.get()}
@@ -219,7 +226,7 @@ public final class ConfigRef implements Supplier Absent or empty {@code allow:} is "off", on purpose: this is a large deployment
+ * with gitignored {@code fleetd.yaml} on more than one host, and a change that forced every
+ * operator to enumerate their models before the daemon would start would break every one of
+ * them on upgrade. See {@link FleetConfig#validateModels()}, which is where the allow-list is
+ * actually enforced, at config load.
+ *
+ * The list is the authority; a {@code profiles:} entry is checked against it, never the
+ * other way around. Adding or editing a {@code profiles:} entry cannot, by itself, widen
+ * the set of permitted models — only editing {@code models.allow:} itself can. This is the
+ * invariant the ticket asked for: the two blocks are validated in one direction only.
+ *
+ * Out of scope here, deliberately: nothing in this block is read at spawn time —
+ * enforcing it against a live spawn, an on/off runtime switch, and any interaction with {@code
+ * BackendQuarantine} are separate units. This block is config-load validation only.
+ *
+ * @param allow the permitted models, each its own {@link ModelEntry} rather than a bare
+ * string — see that record's javadoc for why. {@code null}/empty ⇒ the block is
+ * treated as absent: {@link FleetConfig#validateModels()} checks nothing.
+ */
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record Models(List Absent or empty {@code models.allow:} validates nothing — see {@link Models}'s javadoc:
+ * an existing config with no such block must keep working exactly as it does today. Once the
+ * operator declares at least one entry, every profile's {@code model:} (when set — a profile
+ * may legitimately leave it {@code null}, e.g. a {@code subscription: true} profile relying on
+ * the account's own default) must equal one of {@link Models#ids()} exactly. The comparison is
+ * a flat string match: a bare Claude id and an opencode {@code provider/model} id are both
+ * just opaque strings here, so nothing here needs to know which {@code kind:} a profile runs.
+ *
+ * The check runs one direction only, by construction: it reads {@link #profiles} and
+ * {@link #models}, and only ever adds to {@code bad} when a profile's model is missing from
+ * the allow-list. Nothing here can be satisfied by widening a {@code profiles:} entry — only
+ * editing {@code models.allow:} itself changes what passes. That is the invariant the ticket
+ * asked for: the list is the authority, profiles are checked against it.
+ *
+ * @throws IllegalStateException when any profile names a model outside the configured
+ * allow-list, naming both the model and the profile that wanted it
+ */
+ public void validateModels() {
+ if (models == null || models.allow().isEmpty()) {
+ return;
+ }
+ Set fleetd ticket "central allow-list of usable models", follow-up: mutation testing found
+ * that although each of the six validators above was well pinned on its own, nothing proved
+ * either real caller ({@code Fleetd.main} and {@link ConfigRef#reload()}) still
+ * invoked it — deleting a call site left the full suite green. The fix is not a seventh test
+ * per caller; a hand-maintained list of six names here would have the exact same defect its
+ * own javadoc would warn against: the seventh validator someone adds next month has no reason
+ * to be added to it. So this method does not name any validator. It sweeps {@link
+ * #getClass()}'s own public, no-argument, {@code void} methods whose name starts with {@code
+ * "validate"} (excluding itself) and invokes every one it finds, via {@link
+ * #invokeAllValidators}. A new {@code validateXxx()} method is therefore wired into both
+ * callers the moment it is written — there is no second step to forget, and so no state in
+ * which it silently never runs.
+ *
+ * {@code Fleetd.main} and {@link ConfigRef#reload()} each call this one method instead of
+ * the six individually — see the comments at those two call sites for why
+ * each must run it.
+ *
+ * Methods run in a fixed (alphabetical) order, so a config with more than one violation
+ * always names the same one first, on every run.
+ *
+ * @throws IllegalStateException (or whatever unchecked exception a validator itself throws),
+ * propagated unchanged from the first validator, in that order,
+ * that finds a problem
+ */
+ public void validateAll() {
+ invokeAllValidators(this);
+ }
+
+ /**
+ * The reflective sweep behind {@link #validateAll()}, kept as its own method — taking any
+ * {@code target}, not just {@code this} — so a test can prove the MECHANISM is generic (it
+ * would sweep a seventh {@code validateXxx()} method added to any class, not just something
+ * special-cased to today's six on {@link FleetConfig}) without needing to add a real, unwanted
+ * seventh validator to this class just to exercise that claim. See {@code
+ * FleetConfigValidateAllTest} for that proof.
+ *
+ * @param target an object whose public, no-argument, {@code void} methods named {@code
+ * validateXxx} (any name starting with {@code "validate"}, excluding {@code
+ * validateAll} itself) should all run, in alphabetical-by-name order
+ */
+ static void invokeAllValidators(Object target) {
+ List This is deliberately the real {@code static void main(String[] args)} — package-private, so
+ * only a test in this package can call it, which is exactly what makes this proof strong: it is
+ * not a helper extracted for testability, it is the literal method {@code java -jar fleetd.jar}
+ * invokes. Every fixture below is otherwise-valid and fails exactly one validator, and — because
+ * {@link FleetConfig#validateAll()} runs immediately after {@code SubscriptionGuard.
+ * assertPrimaryClean}, before {@code main} opens the herdr socket, binds Javalin, or touches
+ * anything else with a real side effect — calling {@code Fleetd.main} with one of these configs is
+ * safe: it is guaranteed to throw before reaching any of that, precisely because the config is
+ * deliberately invalid.
+ */
+class FleetdStartupValidationTest {
+
+ private static void assertMainRefuses(Path dir, String fileName, String yaml,
+ String mustContain) throws Exception {
+ Path f = dir.resolve(fileName);
+ Files.writeString(f, yaml);
+ IllegalStateException e = assertThrows(IllegalStateException.class,
+ () -> Fleetd.main(new String[]{f.toString()}),
+ fileName + ": Fleetd.main must refuse this config before doing anything else");
+ assertTrue(e.getMessage().contains(mustContain),
+ fileName + ": expected message to contain \"" + mustContain + "\" but was: "
+ + e.getMessage());
+ }
+
+ @Test
+ void mainRefusesANonLoopbackBindWithoutTokenMode(@TempDir Path dir) throws Exception {
+ assertMainRefuses(dir, "auth-exposure.yaml", """
+ bind:
+ host: 0.0.0.0
+ port: 8765
+ """, "auth.mode: token");
+ }
+
+ @Test
+ void mainRefusesALeadTabPrefixCollision(@TempDir Path dir) throws Exception {
+ assertMainRefuses(dir, "lead-tab-prefixes.yaml", """
+ bind:
+ host: 127.0.0.1
+ port: 8765
+ fleet:
+ tabLabel: "lead: {role} {profile}"
+ leaders:
+ opus:
+ tab: "lead: opus"
+ """, "fleet.tabLabel");
+ }
+
+ @Test
+ void mainRefusesASubscriptionProfileThatReseatsAnthropicBaseUrl(@TempDir Path dir)
+ throws Exception {
+ assertMainRefuses(dir, "subscription-profiles.yaml", """
+ bind:
+ host: 127.0.0.1
+ port: 8765
+ profiles:
+ sonnet:
+ subscription: true
+ argv: ["ccs", "sonnet"]
+ env:
+ ANTHROPIC_BASE_URL: http://anything-not-on-the-allowlist
+ """, "ANTHROPIC_BASE_URL");
+ }
+
+ @Test
+ void mainRefusesAnUnknownCharterKey(@TempDir Path dir) throws Exception {
+ assertMainRefuses(dir, "charters.yaml", """
+ bind:
+ host: 127.0.0.1
+ port: 8765
+ fleet:
+ charters:
+ architetc: text
+ """, "architetc");
+ }
+
+ @Test
+ void mainRefusesAnArchitectSlotNamingAnUnconfiguredProfile(@TempDir Path dir) throws Exception {
+ assertMainRefuses(dir, "members.yaml", """
+ bind:
+ host: 127.0.0.1
+ port: 8765
+ profiles:
+ gx10:
+ baseUrl: http://gx10.gw:8000
+ fleet:
+ architects:
+ lead-designer:
+ profile: sonnet
+ """, "lead-designer");
+ }
+
+ @Test
+ void mainRefusesAProfileNamingAModelOutsideTheAllowList(@TempDir Path dir) throws Exception {
+ assertMainRefuses(dir, "models.yaml", """
+ bind:
+ host: 127.0.0.1
+ port: 8765
+ profiles:
+ sonnet:
+ baseUrl: http://gx10.gw:8000
+ model: claude-sonnet-5
+ rogue:
+ baseUrl: http://gx11.gw:8000
+ model: claude-opus-9000
+ models:
+ allow:
+ - model: claude-sonnet-5
+ """, "rogue");
+ }
+}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java
index f022306..2626027 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/ConfigRefTopLevelReportingCoverageTest.java
@@ -109,6 +109,8 @@ class ConfigRefTopLevelReportingCoverageTest {
v.put("memberLoginShell", null);
v.put("memberSkills", "/skills/a");
v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(true));
+ v.put("models", new FleetConfig.Models(
+ List.of(new FleetConfig.Models.ModelEntry("model-a"))));
assertNamesMatchComponents(v);
return v;
}
@@ -151,6 +153,8 @@ class ConfigRefTopLevelReportingCoverageTest {
v.put("memberLoginShell", null);
v.put("memberSkills", "/skills/b");
v.put("idleSleepGuard", new FleetConfig.IdleSleepGuard(false));
+ v.put("models", new FleetConfig.Models(
+ List.of(new FleetConfig.Models.ModelEntry("model-b"))));
assertNamesMatchComponents(v);
return v;
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
index d8e6fc8..19baf53 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
@@ -2683,4 +2683,222 @@ class FleetConfigTest {
assertTrue(cfg.idleSleepGuard().isEnabled(),
"unlike ConfigReload/Health, this block defaults to ON even when present but empty");
}
+
+ // --- models: central allow-list of usable models -----------------------------------------
+
+ /**
+ * An absent {@code models:} block is today's behaviour exactly: no profile's {@code model:} is
+ * checked against anything, whatever it says. This is the "existing config keeps working"
+ * invariant — an operator on a gitignored {@code fleetd.yaml} that predates this feature must
+ * not be broken by upgrading the daemon.
+ */
+ @Test
+ void absentModelsBlockValidatesNothing(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ gx10:
+ baseUrl: http://gx10.gw:8000
+ model: totally-unheard-of-model-xyz
+ """);
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertDoesNotThrow(cfg::validateModels);
+ }
+
+ /**
+ * {@code models:} present but with an empty (or absent) {@code allow:} must behave exactly like
+ * the block being absent — an operator adding the block for the first time with nothing in it
+ * yet must not be surprised by every profile suddenly refusing to start.
+ */
+ @Test
+ void modelsBlockPresentButEmptyValidatesNothing(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ gx10:
+ baseUrl: http://gx10.gw:8000
+ model: whatever-the-operator-typed
+ models:
+ allow: []
+ """);
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertDoesNotThrow(cfg::validateModels);
+ }
+
+ /**
+ * The core of the ticket: a profile naming a model outside the configured allow-list fails
+ * config load, naming both the model and the profile that wanted it.
+ */
+ @Test
+ void aProfileNamingAModelOutsideTheAllowListRefusesToStart(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ sonnet:
+ baseUrl: http://gx10.gw:8000
+ model: claude-sonnet-5
+ rogue:
+ baseUrl: http://gx11.gw:8000
+ model: claude-opus-9000
+ models:
+ allow:
+ - model: claude-sonnet-5
+ - model: claude-haiku-5
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateModels);
+ assertTrue(e.getMessage().contains("rogue"), "the refusal names the offending profile");
+ assertTrue(e.getMessage().contains("claude-opus-9000"), "the refusal names the offending model");
+ assertFalse(e.getMessage().contains("'sonnet'"),
+ "the profile whose model IS allowed must not be reported");
+ }
+
+ /** A profile whose {@code model:} is on the allow-list loads fine. */
+ @Test
+ void aProfileNamingAModelOnTheAllowListLoadsFine(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ sonnet:
+ baseUrl: http://gx10.gw:8000
+ model: claude-sonnet-5
+ models:
+ allow:
+ - model: claude-sonnet-5
+ """);
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertDoesNotThrow(cfg::validateModels);
+ }
+
+ /**
+ * A profile that never sets {@code model:} (a {@code subscription: true} profile relying on the
+ * account's own default is the live example) must not be refused just because an allow-list is
+ * active — there is nothing to check it against.
+ */
+ @Test
+ void aProfileWithNoModelPassesEvenWithAnActiveAllowList(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ opus:
+ subscription: true
+ models:
+ allow:
+ - model: claude-sonnet-5
+ """);
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertDoesNotThrow(cfg::validateModels);
+ }
+
+ /**
+ * Reproduces the live config shape this ticket measured: a mix of {@code amazon-bedrock},
+ * {@code opencode} and {@code openai}-backed profiles, some naming a bare Claude id and some a
+ * provider-prefixed opencode id, in ONE allow-list. Both forms are just opaque strings compared
+ * for exact equality — proves the shape decision holds against the shape actually seen live,
+ * not just against a synthetic single-provider example.
+ */
+ @Test
+ void aBareClaudeIdAndAnOpencodeProviderPrefixedIdBothFitOneAllowList(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ sonnet:
+ baseUrl: http://gx10.gw:8000
+ model: claude-sonnet-5
+ terra:
+ kind: opencode
+ model: openai/gpt-5.6-terra
+ nova:
+ kind: opencode
+ model: amazon-bedrock/amazon.nova-pro-v1:0
+ models:
+ allow:
+ - model: claude-sonnet-5
+ - model: openai/gpt-5.6-terra
+ - model: amazon-bedrock/amazon.nova-pro-v1:0
+ """);
+
+ FleetConfig cfg = FleetConfig.load(f);
+ assertDoesNotThrow(cfg::validateModels);
+ }
+
+ /** The same live shape, but one opencode profile's model is missing from the allow-list. */
+ @Test
+ void anUnlistedOpencodeProviderPrefixedModelRefusesToStart(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ sonnet:
+ baseUrl: http://gx10.gw:8000
+ model: claude-sonnet-5
+ terra:
+ kind: opencode
+ model: openai/gpt-5.6-terra-withdrawn
+ models:
+ allow:
+ - model: claude-sonnet-5
+ - model: openai/gpt-5.6-terra
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateModels);
+ assertTrue(e.getMessage().contains("terra"), "the refusal names the offending profile");
+ assertTrue(e.getMessage().contains("openai/gpt-5.6-terra-withdrawn"),
+ "the refusal names the offending model, with its provider prefix intact");
+ }
+
+ /**
+ * Editing a {@code profiles:} entry alone must never be able to widen what is permitted — only
+ * editing {@code models.allow:} itself can. This is the invariant the ticket states explicitly;
+ * this test pins it by giving a profile a plausible-looking model that was never added to the
+ * allow-list and confirming it is still refused.
+ */
+ @Test
+ void addingAProfileCannotWidenTheAllowListByItself(@TempDir Path dir) throws Exception {
+ Path f = dir.resolve("fleetd.yaml");
+ Files.writeString(f, """
+ bind:
+ port: 8080
+ profiles:
+ sonnet:
+ baseUrl: http://gx10.gw:8000
+ model: claude-sonnet-5
+ brand-new:
+ baseUrl: http://gx12.gw:8000
+ model: claude-sonnet-6-preview
+ models:
+ allow:
+ - model: claude-sonnet-5
+ """);
+ FleetConfig cfg = FleetConfig.load(f);
+
+ IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateModels);
+ assertTrue(e.getMessage().contains("brand-new"));
+ assertTrue(e.getMessage().contains("claude-sonnet-6-preview"));
+ }
+
+ /** {@code models} is a brand-new top-level key and must be recognized, not WARN-ed as unknown. */
+ @Test
+ void modelsIsAKnownTopLevelKey() {
+ assertTrue(FleetConfig.KNOWN_TOP_LEVEL_KEYS.contains("models"));
+ }
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigValidateAllTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigValidateAllTest.java
new file mode 100644
index 0000000..50930ab
--- /dev/null
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigValidateAllTest.java
@@ -0,0 +1,358 @@
+package dev.ltms.fleet.config;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.lang.reflect.Method;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The gap this class exists to close: mutation testing on the fleetd ticket "central allow-list
+ * of usable models" found that although {@link FleetConfig#validateModels()}'s own logic was well
+ * pinned, nothing proved either real caller ({@code Fleetd.main} and {@link ConfigRef#reload()})
+ * still invoked it — deleting the call site left the full suite green (1478/0/0/0). A follow-up
+ * measurement (same technique — remove one call site, run the suite, not read the code) found the
+ * SAME gap for all five of {@link FleetConfig}'s other validators at startup, and for four of the
+ * six inside {@link ConfigRef#reload()}. This is a class of gap, not one line's mistake: every one
+ * of those thirteen tests called the validator itself directly, never the real caller that was
+ * supposed to.
+ *
+ * The fix replaces the six individual {@code cfg.validateXxx()} calls at each of the two real
+ * call sites with one {@link FleetConfig#validateAll()}, which reaches every validator by
+ * reflection rather than by a hand-maintained list of names. A hand-maintained list of six names
+ * would have exactly the defect it replaces: the seventh validator someone adds next month has no
+ * reason to be added to it, and nothing would say so. This class proves TWO separate claims, and
+ * keeps them separate on purpose:
+ *
+ * Together with the direct-{@code Fleetd.main}-invocation tests in {@code
+ * FleetdStartupValidationTest} (which prove the real startup call site still calls {@code
+ * validateAll()}) and the {@code ConfigRefTest} reload tests (which prove the same for {@link
+ * ConfigRef#reload()}), removing {@code cfg.validateAll();} from either real call site now fails
+ * a test in this module.
+ *
+ * What is NOT pinned, measured rather than assumed. Reverting {@link
+ * FleetConfig#validateAll()} to a hardcoded list of today's six method calls leaves the whole
+ * suite green (measured at review: 1491 tests, 0 failures). Nothing ties {@code validateAll()} to
+ * the generic sweep — claim 1 proves {@link FleetConfig#invokeAllValidators} is generic, and claim
+ * 2 proves {@code validateAll()} reaches today's six, and a hardcoded list satisfies both. So the
+ * reflective sweep is a convenience, not the guarantee. The guarantee is {@link
+ * #fleetConfigDeclaresExactlyTheseSixValidatorsToday()}: it fails the moment a seventh validator
+ * is declared, which forces whoever adds it to look at this file.
+ */
+class FleetConfigValidateAllTest {
+
+ // ── Claim 1: the reflective sweep is a general mechanism, not six names in disguise ──────────
+
+ /**
+ * A throwaway fixture class, unrelated to {@link FleetConfig} in every way except shape: three
+ * public, no-arg, void methods named {@code validateXxx}. Proves the sweep works on ANY class
+ * with this shape, not on something special-cased to {@link FleetConfig}.
+ */
+ static class ThreeValidators {
+ final List
+ *
+ *
+ *