CB-548: config-declared architect slots + Role.ARCHITECT authz
CI / build (pull_request) Successful in 55s
CI / contract (pull_request) Successful in 1m6s

This commit is contained in:
Dai Ha
2026-08-13 17:28:14 +02:00
parent 509530e235
commit 21cfc09f8e
14 changed files with 735 additions and 20 deletions
@@ -0,0 +1,67 @@
package dev.ltms.bridged.auth;
import dev.ltms.bridged.config.BridgedConfig;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* CB-548 — the architect-slot registry: the config snapshot of slot → profile, and the live
* terminal → slot binding the resolver reads. The role the binding produces is asserted in
* {@link CallerResolverTest}; this pins the registry object itself.
*/
class ArchitectRegistryTest {
private static final Map<String, BridgedConfig.Architect> SLOTS = Map.of(
"lead-designer", new BridgedConfig.Architect("term_design", "sonnet"),
"reviewer", new BridgedConfig.Architect(null, "gx10"));
private final ArchitectRegistry registry =
new ArchitectRegistry(SLOTS, () -> Map.of("term_design", "lead-designer"));
@Test
void exposesTheConfiguredSlots() {
assertEquals(SLOTS.keySet(), registry.slots().keySet());
assertTrue(registry.isSlot("reviewer"));
assertFalse(registry.isSlot("nope"));
}
@Test
void theSpawnLifecycleReadsTheProfileBackFromASlot() {
assertEquals("sonnet", registry.profileForSlot("lead-designer"));
assertEquals("gx10", registry.profileForSlot("reviewer"));
assertNull(registry.profileForSlot("unknown"), "an unknown slot has no profile");
}
@Test
void resolvesTheSlotOfALiveTerminal() {
assertEquals("lead-designer", registry.slotForTerminal("term_design"));
assertNull(registry.slotForTerminal("term_unbound"));
assertNull(registry.slotForTerminal(null), "no terminal ⇒ no slot");
}
@Test
void theBindingIsLiveReReadPerCall() {
Map<String, String> live = new HashMap<>();
ArchitectRegistry r = new ArchitectRegistry(SLOTS, () -> live);
assertNull(r.slotForTerminal("term_design"));
live.put("term_design", "lead-designer"); // injected after construction
assertEquals("lead-designer", r.slotForTerminal("term_design"));
}
@Test
void theSlotSnapshotIsFixedByConstruction() {
Map<String, BridgedConfig.Architect> mutable = new HashMap<>(SLOTS);
ArchitectRegistry r = new ArchitectRegistry(mutable, Map::of);
mutable.put("hijack", new BridgedConfig.Architect("t", "gx10"));
assertFalse(r.isSlot("hijack"), "a handed-over map is not offered as live state");
}
}
@@ -12,6 +12,8 @@ class AuthzTest {
private static final Principal WORKER_A = Principal.worker("term_a", 200);
private static final Principal WORKER_B = Principal.worker("term_b", 300);
private static final Principal ANON = Principal.anonymous();
private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400);
private static final Principal ARCH_OTHER = Principal.architect("reviewer", "term_review", 500);
@Test
void anonymousIsAuthorizedForNothing() {
@@ -61,6 +63,48 @@ class AuthzTest {
"an absent session id must not satisfy the own-session rule");
}
// ── CB-548: the architect matrix ───────────────────────────────────────────────────────────
@Test
void anArchitectMaySendButNotSpawnStopOrDrain() {
assertTrue(Authz.permits(ARCH_DESIGN, SEND, "term_worker"),
"delegating a turn to a worker IS the architect's job");
assertTrue(Authz.permits(ARCH_DESIGN, SEND, null));
for (Authz.Action a : new Authz.Action[]{SPAWN, STOP, DRAIN}) {
assertFalse(Authz.permits(ARCH_DESIGN, a, null),
"an architect must not " + a + " — fleet lifecycle is the primary's alone, so "
+ "a coordinator cannot also stand up or tear down the fleet");
}
}
@Test
void anArchitectMayReplyAndAskOnlyAsItsOwnPane() {
assertTrue(Authz.permits(ARCH_DESIGN, REPLY, "term_design"), "its own pane is its own");
assertTrue(Authz.permits(ARCH_DESIGN, ASK, "term_design"));
assertFalse(Authz.permits(ARCH_DESIGN, REPLY, "term_review"),
"architect 'lead-designer' must not reply on reviewer's pane");
assertFalse(Authz.permits(ARCH_OTHER, ASK, "term_design"),
"reviewer must not ask as lead-designer — no terminal is another's");
assertFalse(Authz.permits(ARCH_DESIGN, REPLY, null),
"an absent target must not pass the own-session rule");
}
@Test
void anArchitectMayReadAndScrapeMetrics() {
assertTrue(Authz.permits(ARCH_DESIGN, READ, null));
assertTrue(Authz.permits(ARCH_DESIGN, METRICS, null));
}
@Test
void anArchitectIsNotCountedAsPrimaryOrWorker() {
assertFalse(Authz.permits(ARCH_DESIGN, SPAWN, null), "not a primary — no lifecycle");
assertFalse(ARCH_DESIGN.isPrimary());
assertFalse(ARCH_DESIGN.isWorker(), "an architect is its own role, not a widened worker");
assertTrue(ARCH_DESIGN.isArchitect());
}
@Test
void observationIsOpenToBothAuthenticatedRoles() {
assertTrue(Authz.permits(PRIMARY, READ, null));
@@ -298,6 +298,97 @@ class CallerResolverTest {
assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role());
}
// ── CB-548: architect slots ─────────────────────────────────────────────────────────────────
@Test
void aBoundArchitectPaneResolvesToArchitectBeforeTheWorkerFallback() {
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
Map::of, () -> Map.of("term_a", "lead-designer"))
.resolve("127.0.0.1", 42, null);
assertEquals(Role.ARCHITECT, p.role(),
"a terminal bound to an architect slot is an architect, NOT the generic worker it "
+ "would otherwise resolve to");
assertEquals("lead-designer", p.name(), "whoami must say WHICH slot is asking");
assertEquals("term_a", p.terminal(), "the pane identity is carried so ownsSession works");
}
@Test
void anArchitectNeedsNoTokenEvenInTokenMode() {
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), true, "s3cret",
Map::of, () -> Map.of("term_a", "lead-designer"))
.resolve("127.0.0.1", 42, null);
assertEquals(Role.ARCHITECT, p.role(),
"the pane mapping is as unforgeable as a worker's — it outranks the token path");
}
@Test
void anUnboundPaneStillResolvesAsAWorker() {
Map<String, String> arch = Map.of("term_elsewhere", "reviewer");
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
Map::of, () -> arch).resolve("127.0.0.1", 42, null);
assertEquals(Role.WORKER, p.role());
assertNull(p.name());
}
/** CB-548 precedence: lead > architect > worker, so a pane named in BOTH is still a lead. */
@Test
void aLeadWinsOverAnArchitectBindingForTheSamePane() {
Principal p = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
() -> Map.of("term_a", "opus-5.0"), () -> Map.of("term_a", "lead-designer"))
.resolve("127.0.0.1", 42, null);
assertEquals(Role.PRIMARY, p.role(),
"a pane the config calls a lead must keep resolving as a lead — no behaviour change "
+ "when an architect binding is added to an existing fleet");
assertEquals("opus-5.0", p.name());
}
/** The registry is live, like leads: a binding injected after construction is honoured. */
@Test
void anArchitectBoundAfterConstructionIsHonouredWithoutRebuildingTheResolver() {
Map<String, String> live = new java.util.HashMap<>();
CallerResolver r = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
Map::of, () -> live);
assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role());
live.put("term_a", "lead-designer"); // the later lifecycle binds the slot
assertEquals(Role.ARCHITECT, r.resolve("127.0.0.1", 42, null).role());
assertEquals("lead-designer", r.architects().get("term_a"));
}
@Test
void theArchitectMapFormIsCopiedSoLaterMutationCannotGrantArchitect() {
Map<String, String> mutable = new java.util.LinkedHashMap<>();
CallerResolver r = new CallerResolver(workerIdentity(), false, null, Map.of(), mutable);
mutable.put("term_a", "sneaky");
assertEquals(Role.WORKER, r.resolve("127.0.0.1", 42, null).role());
}
@Test
void describeNamesTheArchitectSlot() {
assertEquals("architect:lead-designer",
Principal.architect("lead-designer", "term_a", 1).describe());
}
/** An architect acts only as its own pane — the same ownsSession rule as a worker or lead. */
@Test
void anArchitectOwnsItsOwnPaneAndNoOther() {
Principal arch = CallerResolver.withLeadsAndArchitects(workerIdentity(), false, null,
Map::of, () -> Map.of("term_a", "lead-designer")).resolve("127.0.0.1", 42, null);
assertTrue(arch.ownsSession("term_a"));
assertTrue(Authz.permits(arch, Authz.Action.REPLY, "term_a"));
assertFalse(arch.ownsSession("term_b"));
assertFalse(Authz.permits(arch, Authz.Action.REPLY, "term_b"));
}
@Test
void tokenModeRequiresANonEmptyConfiguredToken() {
ConnectionIdentity id = nonWorkerIdentity();
@@ -129,6 +129,7 @@ class BridgedConfigTest {
broker: {}
primary: {}
leaders: {}
architects: {}
leadScan: {}
placement: fixed
auth: {}
@@ -326,6 +327,165 @@ class BridgedConfigTest {
assertEquals(Map.of("term_real", "real"), BridgedConfig.load(f).leaderTerminals());
}
// ── CB-548: the architects registry ────────────────────────────────────────────────────────
@Test
void architectsBlockBindsSlotsByGatewayLocalName(@TempDir Path dir) throws Exception {
Path f = dir.resolve("architects.yaml");
Files.writeString(f, """
bind:
port: 8080
workers:
sonnet:
baseUrl: http://gx10.gw:8000
architects:
lead-designer:
terminal: term_design
profile: sonnet
reviewer:
profile: sonnet
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals(Set.of("lead-designer", "reviewer"), cfg.architects().keySet(),
"slot names are the keys — gateway-local unique by construction");
assertEquals("sonnet", cfg.architects().get("lead-designer").profile(),
"each slot carries its strong-model profile reference");
assertEquals("term_design", cfg.architects().get("lead-designer").terminal());
// A slot with no terminal binds nothing yet — the live binding may supply it later.
assertTrue(cfg.architects().get("reviewer").terminal() == null
|| cfg.architects().get("reviewer").terminal().isBlank());
}
@Test
void architectTerminalsMapsEachBoundSlotByItsPane(@TempDir Path dir) throws Exception {
Path f = dir.resolve("arch-terminals.yaml");
Files.writeString(f, """
bind:
port: 8080
workers:
sonnet:
baseUrl: http://gx10.gw:8000
architects:
lead-designer:
terminal: term_design
profile: sonnet
reviewer:
terminal: term_review
profile: sonnet
unbound:
profile: sonnet
""");
assertEquals(Map.of("term_design", "lead-designer", "term_review", "reviewer"),
BridgedConfig.load(f).architectTerminals(),
"a slot with no terminal registers no binding; the value is the slot name");
}
@Test
void noArchitectsBlockLeavesNothingBound(@TempDir Path dir) throws Exception {
Path f = dir.resolve("no-arch.yaml");
Files.writeString(f, "bind:\n port: 8080\n");
BridgedConfig cfg = BridgedConfig.load(f);
assertNull(cfg.architects());
assertTrue(cfg.architectTerminals().isEmpty(),
"no architects: block ⇒ no architect identity, exactly as before CB-548");
}
@Test
void anArchitectSlotMayResolveToTheSoleProfileWithoutPrivileging(@TempDir Path dir) throws Exception {
// Even a single unqualified worker profile can back an architect slot — the reference is
// by name, not by position, so an explicit name is required.
Path f = dir.resolve("arch-single.yaml");
Files.writeString(f, """
bind:
port: 8080
worker:
profile: ltms-local
baseUrl: http://gx10.gw:8000
architects:
lead-designer:
terminal: term_design
profile: ltms-local
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertDoesNotThrow(cfg::validateArchitects);
assertEquals("ltms-local", cfg.architects().get("lead-designer").profile());
}
@Test
void anArchitectProfileThatIsNotConfiguredRefusesToStart(@TempDir Path dir) throws Exception {
Path f = dir.resolve("arch-bad-profile.yaml");
Files.writeString(f, """
bind:
port: 8080
workers:
gx10:
baseUrl: http://gx10.gw:8000
architects:
lead-designer:
terminal: term_design
profile: sonnet
""");
BridgedConfig cfg = BridgedConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateArchitects);
assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the slot");
assertTrue(e.getMessage().contains("sonnet"), "the refusal names the offending profile");
}
@Test
void anArchitectSlotMissingAProfileRefusesToStart(@TempDir Path dir) throws Exception {
Path f = dir.resolve("arch-no-profile.yaml");
Files.writeString(f, """
bind:
port: 8080
workers:
gx10:
baseUrl: http://gx10.gw:8000
architects:
lead-designer:
terminal: term_design
profile: ""
""");
BridgedConfig cfg = BridgedConfig.load(f);
IllegalStateException e = assertThrows(IllegalStateException.class, cfg::validateArchitects);
assertTrue(e.getMessage().contains("lead-designer"), "the refusal names the slot");
}
@Test
void aValidArchitectRegistryPassesValidation(@TempDir Path dir) throws Exception {
Path f = dir.resolve("arch-ok.yaml");
Files.writeString(f, """
bind:
port: 8080
workers:
sonnet:
baseUrl: http://gx10.gw:8000
gx10:
baseUrl: http://gx10.gw:8000
architects:
lead-designer:
terminal: term_design
profile: sonnet
reviewer:
profile: gx10
""");
assertDoesNotThrow(() -> BridgedConfig.load(f).validateArchitects());
}
@Test
void absentArchitectsBlockPassesValidation(@TempDir Path dir) throws Exception {
Path f = dir.resolve("no-arch.yaml");
Files.writeString(f, "bind:\n port: 8080\n");
assertDoesNotThrow(() -> BridgedConfig.load(f).validateArchitects());
}
@Test
void absentBrokerBlockLeavesInboxSoftState(@TempDir Path dir) throws Exception {
Path f = dir.resolve("no-broker.yaml");
@@ -77,6 +77,7 @@ class BridgeMcpAuthzTest {
private static final Principal PRIMARY = Principal.primary(100);
private static final Principal WORKER_A = Principal.worker("term_a", 200);
private static final Principal ANON = Principal.anonymous();
private static final Principal ARCH_DESIGN = Principal.architect("lead-designer", "term_design", 400);
// --- the table, enforced on THIS path too ---------------------------------------------------
@@ -119,6 +120,33 @@ class BridgeMcpAuthzTest {
assertNotNull(m.denyFor(PRIMARY, Authz.Action.ASK, "term_a"));
}
// --- CB-548: the architect on this path ------------------------------------------------
@Test
void anArchitectMaySendAndReadButNotOrchestrateOverMcp() {
BridgeMcp m = mcp(true);
assertNull(m.denyFor(ARCH_DESIGN, Authz.Action.SEND, "term_a"),
"delegating a turn is the architect's job");
assertNull(m.denyFor(ARCH_DESIGN, Authz.Action.READ, null));
for (Authz.Action a : new Authz.Action[]{Authz.Action.SPAWN, Authz.Action.STOP,
Authz.Action.DRAIN}) {
McpSchema.CallToolResult denied = m.denyFor(ARCH_DESIGN, a, null);
assertNotNull(denied, a + " must be refused to an architect");
assertTrue(denied.isError(), "a refusal is returned as an MCP tool error");
}
}
@Test
void anArchitectMayReplyAndAskOnlyAsItsOwnPaneOverMcp() {
BridgeMcp m = mcp(true);
assertNull(m.denyFor(ARCH_DESIGN, Authz.Action.REPLY, "term_design"));
assertNull(m.denyFor(ARCH_DESIGN, Authz.Action.ASK, "term_design"));
assertNotNull(m.denyFor(ARCH_DESIGN, Authz.Action.REPLY, "term_a"),
"architect 'lead-designer' must not reply on worker term_a's session");
}
@Test
void anonymousIsRefusedEverythingAndCountedAsUnauthenticated() {
BridgeMcp m = mcp(true);
@@ -156,6 +184,11 @@ class BridgeMcpAuthzTest {
assertEquals("term_a", BridgeMcp.principalFrom("WORKER", "term_a", 7).terminal());
assertEquals(Role.PRIMARY, BridgeMcp.principalFrom("PRIMARY", null, 7).role());
assertEquals(Role.ANONYMOUS, BridgeMcp.principalFrom("ANONYMOUS", null, -1).role());
// CB-548: an architect round-trips through the same stash, carrying its slot name.
Principal arch = BridgeMcp.principalFrom("ARCHITECT", "term_design", 7, "lead-designer");
assertEquals(Role.ARCHITECT, arch.role());
assertEquals("lead-designer", arch.name());
assertEquals("term_design", arch.terminal());
}
@Test
@@ -466,4 +466,22 @@ class BridgeMcpTest {
assertTrue(out.contains("\"sessionId\":\"term_orphan\""), out);
assertFalse(out.contains("profile"), out); // nothing invented for a session we don't track
}
/**
* CB-548: an architect reports its role and which gateway-local slot its pane is bound to —
* the same shape as a lead, under the architect key, so it can tell a peer where to reach it.
*/
@Test
void whoamiReportsAnArchitectWithItsSlotAndPane() {
FakeHerdr h = new FakeHerdr();
McpSchema.CallToolResult res = BridgeMcp.whoami(
Principal.architect("lead-designer", "term_design", 400),
sessionManager(h, "http://gx00.gw:8000", Set.of("gx00.gw")));
assertNotEquals(Boolean.TRUE, res.isError());
String out = textOf(res);
assertTrue(out.contains("\"role\":\"architect\""), out);
assertTrue(out.contains("\"architect\":\"lead-designer\""), out);
assertTrue(out.contains("\"sessionId\":\"term_design\""), out);
}
}