Merge CB-566: the charter config surface (PR #37)

fleet.charters is a validated Map<String,String>, not a record: Fleet is
@JsonIgnoreProperties(ignoreUnknown = true), so a record field named
architetc would be dropped in silence and the operator would never learn
of the typo. A map lets validateCharters see the bad key and refuse it.

The key sits under fleet: because ConfigRef already treats that block as
hot and changedDeferredKeys does not list it. A new top-level key would
inherit nothing, and forgetting to classify it means a reload prints
'config reloaded' and does nothing.

A blank value is refused while an absent one is fine: an absent key means
the operator configured no charter, a blank one means they tried and
failed. Refusing at both startup and reload is the point — wiring only
one of the two paths is the whole bug.
This commit is contained in:
Dai Ha
2026-08-15 04:52:48 +02:00
6 changed files with 148 additions and 5 deletions
+20 -2
View File
@@ -231,8 +231,8 @@ placement: weighted
#
# Not every key can move under a running daemon, and the difference is about what already exists
# when the reload happens — not about how important the key is:
# HOT → takes effect on the next spawn: the whole `fleet:` block (every role pool and
# `tabLabel`), `placement:`, and an existing profile's weight / maxLoad. Those are
# HOT → takes effect on the next spawn: the whole `fleet:` block (every role pool,
# `charters`, and `tabLabel`), `placement:`, and an existing profile's weight / maxLoad. Those are
# hot because the placement policy reads them through a supplier — being config is
# not by itself enough to make a key hot.
# DEFERRED → accepted into the new config, but the wiring built at startup keeps the old value
@@ -274,6 +274,24 @@ placement: weighted
# the candidates, in definition order. A dev and a reviewer staying anonymous is exactly compatible
# with being listed here; the entry key just names the entry.
fleet:
# Optional launch-charter text, keyed only by the singular role wire names: architect, dev,
# reviewer. Changes are HOT and reach the next spawn without a daemon restart. Do not put secrets
# here: a later launch step writes this text to a world-readable temp file, and ${ENV} interpolation
# is deliberately not supported.
charters:
architect: |-
You are an architect in this fleet. You refine work before anyone builds it:
scope, acceptance criteria, risks, and a unit split. You read the repo and
write analysis. You never commit production code and never open a PR.
A design task is worked by two architects. Design alone first, then exchange
and say plainly where you disagree. Do not concede just to agree.
dev: |-
You implement the one unit you were given, and nothing else. You test it,
commit it, and open your own pull request. You never merge.
reviewer: |-
You review the diff you were given. You report bugs, risks and missing tests.
You do not change code.
# Optional. Template for a member tab's label; {role}, {profile}, {model} and {n} are substituted.
# {n} counts per role+profile, so `dev: sonnet #2` really is the second sonnet dev. Because {role}
# comes from a closed enum, a generated label can never begin with a lead's tabPrefix.
@@ -96,6 +96,7 @@ public final class Bridged {
// 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();
@@ -532,6 +532,7 @@ public record BridgedConfig(
* @param architects profiles the {@code architect} role may run on
* @param developers profiles the {@code dev} role may run on
* @param reviewers profiles the {@code reviewer} role may run on
* @param charters optional launch-charter text keyed by singular role wire name
* @param tabLabel template for a member tab's label; {@code {role}}, {@code {profile}},
* {@code {model}} and {@code {n}} (a per role+profile counter) are
* substituted. Default {@link #DEFAULT_TAB_LABEL}
@@ -541,6 +542,7 @@ public record BridgedConfig(
Map<String, Slot> architects,
Map<String, Slot> developers,
Map<String, Slot> reviewers,
Map<String, String> charters,
String tabLabel) {
/**
@@ -556,9 +558,16 @@ public record BridgedConfig(
architects = unmodifiableOrEmpty(architects);
developers = unmodifiableOrEmpty(developers);
reviewers = unmodifiableOrEmpty(reviewers);
charters = unmodifiableOrEmpty(charters);
tabLabel = (tabLabel == null || tabLabel.isBlank()) ? DEFAULT_TAB_LABEL : tabLabel;
}
/** Convenience constructor for code that does not configure launch charters. */
public Fleet(Map<String, Leader> leaders, Map<String, Slot> architects,
Map<String, Slot> developers, Map<String, Slot> reviewers, String tabLabel) {
this(leaders, architects, developers, reviewers, null, tabLabel);
}
/**
* Deliberately not {@code Map.copyOf}: its iteration order is salted per JVM run, which
* would discard YAML definition order. The {@code fixed} placement policy answers with a
@@ -582,6 +591,11 @@ public record BridgedConfig(
};
}
/** The configured launch charter for {@code role}, or {@code null} when it is absent. */
public String charterFor(MemberRole role) {
return role == null ? null : charters.get(role.wireName());
}
/**
* The profile names {@code role} may run on, in definition order, without repeats.
*
@@ -1052,7 +1066,7 @@ public record BridgedConfig(
// fleet IS defaulted, unlike the leadScan: block it replaced, because an empty Fleet is not
// the same as an enabled one: every pool is empty, so no lead is scanned for or created and
// no role has a pool. Constructing it saves every reader a null check for no behaviour change.
Fleet f = (fleet != null) ? fleet : new Fleet(null, null, null, null, null);
Fleet f = (fleet != null) ? fleet : new Fleet(null, null, null, null, null, null);
// leadHeartbeat is left as-is (CB-551): null is "off", and LeadHeartbeat's own compact
// constructor defaults the fields of a block that IS present. Defaulting it here would
// switch the feature on for every config that never mentioned it.
@@ -1190,6 +1204,35 @@ public record BridgedConfig(
}
}
/**
* Reject configured charter entries that would remove a role's contract or never be read.
*
* <p>The map deliberately retains every key from {@code fleet.charters:}. A typed record would
* silently discard an unknown child because {@link Fleet} ignores unknown JSON properties, which
* would make a typo look like an accepted configuration.
*
* @throws IllegalStateException when a charter key is not a role wire name or its value is blank
*/
public void validateCharters() {
if (fleet == null || fleet.charters().isEmpty()) {
return;
}
List<String> valid = java.util.Arrays.stream(MemberRole.values())
.map(MemberRole::wireName)
.toList();
List<String> bad = new ArrayList<>();
fleet.charters().forEach((key, charter) -> {
if (!valid.contains(key)) {
bad.add("fleet.charters." + key + " is not a role wire name (valid: " + valid + ").");
} else if (charter == null || charter.isBlank()) {
bad.add("fleet.charters." + key + " is blank; a configured role needs charter text.");
}
});
if (!bad.isEmpty()) {
throw new IllegalStateException("refusing to start: " + String.join(" ", bad));
}
}
/**
* Reject a member slot whose {@code role} or {@code profile} does not resolve.
*
@@ -27,8 +27,8 @@ import java.util.function.Supplier;
*
* <ul>
* <li><strong>Hot</strong> — re-read per use, so a reload takes effect on the next spawn:
* {@code fleet:} (every role pool and {@code tabLabel}), {@code placement:}, and an existing
* profile's {@code weight} / {@code maxLoad}. Those three are read through a supplier on
* {@code fleet:} (every role pool, {@code charters}, and {@code tabLabel}),
* {@code placement:}, and an existing profile's {@code weight} / {@code maxLoad}. Those three are read through a supplier on
* {@code CompositePeerLauncher}, which is what makes them hot — not the fact that they are
* config.</li>
* <li><strong>Deferred</strong> — accepted into the new snapshot, but the wiring built at startup
@@ -150,6 +150,7 @@ public final class ConfigRef implements Supplier<BridgedConfig> {
fresh.validateAuthExposure();
fresh.validateLeadTabPrefixes();
fresh.validateSubscriptionProfiles();
fresh.validateCharters();
fresh.validateMembers();
} catch (RuntimeException e) {
String msg = e.getMessage() == null ? e.toString() : e.getMessage();
@@ -111,6 +111,28 @@ class BridgedConfigTest {
assertDoesNotThrow(() -> BridgedConfig.load(f));
}
@Test
void absentChartersRemainValidAndPresentChartersUseRoleWireNames(@TempDir Path dir) throws Exception {
Path absent = dir.resolve("absent.yaml");
Files.writeString(absent, "fleet: {}\n");
BridgedConfig withoutCharters = BridgedConfig.load(absent);
assertDoesNotThrow(withoutCharters::validateCharters);
assertNull(withoutCharters.fleet().charterFor(MemberRole.ARCHITECT));
Path blank = dir.resolve("blank.yaml");
Files.writeString(blank, "fleet:\n charters:\n architect: ' '\n");
IllegalStateException blankError = assertThrows(IllegalStateException.class,
() -> BridgedConfig.load(blank).validateCharters());
assertTrue(blankError.getMessage().contains("fleet.charters.architect is blank"));
Path unknown = dir.resolve("unknown.yaml");
Files.writeString(unknown, "fleet:\n charters:\n architetc: text\n");
IllegalStateException unknownError = assertThrows(IllegalStateException.class,
() -> BridgedConfig.load(unknown).validateCharters());
assertTrue(unknownError.getMessage().contains("architetc"));
assertTrue(unknownError.getMessage().contains("[architect, dev, reviewer]"));
}
/**
* CB-530. Unknown keys stay ignored — config must be allowed to run ahead of the code — but they
* must be NAMED at load. A whole block that parses, is dropped, and is never mentioned again is
@@ -66,6 +66,64 @@ class ConfigRefTest {
assertEquals("[{profile}] {role}", ref.get().fleet().tabLabel());
}
@Test
void aCharterChangeIsHotAndReachesTheLiveConfig(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, yaml("""
fleet:
charters:
architect: old charter
"""));
ConfigRef ref = refFor(f);
assertEquals("old charter", ref.get().fleet().charterFor(
dev.ltms.bridged.peer.MemberRole.ARCHITECT));
Files.writeString(f, yaml("""
fleet:
charters:
architect: new charter
"""));
ConfigRef.Outcome out = ref.reload();
assertTrue(out.applied());
assertTrue(out.deferred().isEmpty());
assertEquals("new charter", ref.get().fleet().charterFor(
dev.ltms.bridged.peer.MemberRole.ARCHITECT));
}
@Test
void invalidChartersRefuseReloadAndKeepTheRunningConfig(@TempDir Path dir) throws Exception {
Path f = dir.resolve("bridged.yaml");
Files.writeString(f, yaml("""
fleet:
charters:
architect: valid charter
"""));
ConfigRef ref = refFor(f);
BridgedConfig before = ref.get();
Files.writeString(f, yaml("""
fleet:
charters:
architect: " "
"""));
ConfigRef.Outcome blank = ref.reload();
assertFalse(blank.applied());
assertTrue(blank.error().contains("fleet.charters.architect is blank"));
assertSame(before, ref.get());
Files.writeString(f, yaml("""
fleet:
charters:
architetc: valid charter
"""));
ConfigRef.Outcome unknown = ref.reload();
assertFalse(unknown.applied());
assertTrue(unknown.error().contains("architetc"));
assertTrue(unknown.error().contains("architect"));
assertSame(before, ref.get());
}
/**
* The point of the whole class: a consumer holding the ref sees the new value without being
* rebuilt. A component that captured {@code get()} into a field would still show the old one.