CB-548: config-declared architect slots + Role.ARCHITECT authz (corrected premise) #20

Merged
ltms merged 3 commits from worker/cb-548-authz-v2-586df6-8 into main 2026-08-13 18:33:11 +02:00
14 changed files with 1141 additions and 20 deletions
@@ -14,6 +14,7 @@ import dev.ltms.bridged.inject.Injector;
import dev.ltms.bridged.inject.StatusPoller;
import dev.ltms.bridged.inject.TurnListener;
import dev.ltms.bridged.inject.WorkerPresence;
import dev.ltms.bridged.auth.ArchitectRegistry;
import dev.ltms.bridged.auth.CallerResolver;
import dev.ltms.bridged.mcp.BridgeMcp;
import dev.ltms.bridged.mcp.ConnectionIdentity;
@@ -90,6 +91,9 @@ 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();
// 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.validateArchitects();
Path socket = cfg.herdrSocket() != null && !cfg.herdrSocket().isBlank()
? Path.of(cfg.herdrSocket())
@@ -199,6 +203,19 @@ public final class Bridged {
leads = () -> leadTerminals;
}
// CB-548: config-declared architect slots. Config supplies only the stable name → profile
// map; the terminal → slot binding is owned by the registry and is empty at startup, so no
// pane resolves to an architect until the later spawn lifecycle binds one. The registry is
// what CallerResolver resolves against and what that lifecycle will read profiles from;
// nothing here spawns a slot.
ArchitectRegistry architects = new ArchitectRegistry(
cfg.architects() == null ? Map.of() : cfg.architects());
if (!architects.slots().isEmpty()) {
log.info("architect slots: {} configured {} — none bound yet (a slot is idle until the "
+ "spawn lifecycle binds a live terminal to it)",
architects.slots().size(), architects.slots().keySet());
}
// Status-gated injector (CB-103): the single writer into workers, fed by a poller.
// The blocking message endpoint (CB-104) is the producer; the poller is inert until then.
// CB-106: a confirmed turn completion resolves a blocked send whose worker never replied.
@@ -308,11 +325,13 @@ public final class Bridged {
throw new IllegalStateException("auth.mode=token but env var " + cfg.auth().tokenEnv()
+ " is unset or empty — export it before starting bridged");
}
callers = CallerResolver.withLeads(identity, true, token, leads);
callers = CallerResolver.withLeadsAndArchitects(identity, true, token, leads,
architects::snapshot);
log.info("auth: token mode (bearer required for non-worker callers, env {})",
cfg.auth().tokenEnv());
} else {
callers = CallerResolver.withLeads(identity, false, null, leads);
callers = CallerResolver.withLeadsAndArchitects(identity, false, null, leads,
architects::snapshot);
log.info("auth: loopback-trust (any loopback non-worker caller is the primary)");
}
@@ -0,0 +1,142 @@
package dev.ltms.bridged.auth;
import dev.ltms.bridged.config.BridgedConfig;
import java.util.HashMap;
import java.util.Map;
/**
* The architect-slot registry (CB-548): every gateway-local architect name and the strong-model
* profile it points at, plus the <em>live</em> bindings from a live architect's herdr terminal to
* its slot.
*
* <p>Two halves, split by who owns each:
* <ul>
* <li><b>slots</b> — configured once, keyed by the gateway-local unique name; each carries the
* {@code profile} reference the spawn lifecycle reads when it stands the slot up. A read-only
* snapshot taken at construction.</li>
* <li><b>terminal bindings</b> — owned by this registry and initially <em>empty</em>. Config
* declares no architect terminal, so at startup every slot is idle and nothing resolves to an
* architect; a session only becomes one when the spawn lifecycle {@linkplain #bind(String,
* String) binds} its terminal to a slot. {@link CallerResolver} reads this through
* {@link #snapshot()} to turn a pane into an {@link Role#ARCHITECT}.</li>
* </ul>
*
* <p>Spawning/lifecycle is deliberately a separate unit: this class only owns the bindings and
* exposes the map the resolver resolves against plus the profile lookup lifecycle will call.
* Nothing here creates or manages an architect session.
*/
public final class ArchitectRegistry {
private final Map<String, BridgedConfig.Architect> slots;
/** Live {@code terminal_id → slot name}; guarded by {@code this}. */
private final Map<String, String> terminalToSlot = new HashMap<>();
public ArchitectRegistry(Map<String, BridgedConfig.Architect> slots) {
this.slots = slots == null ? Map.of() : Map.copyOf(slots);
}
/** The configured slots, keyed by gateway-local unique name. Unmodifiable snapshot. */
public Map<String, BridgedConfig.Architect> slots() {
return slots;
}
/**
* An immutable copy of the live {@code terminal_id → slot name} bindings.
*
* <p>Passed to {@link CallerResolver} as the source of architect identity, and what
* {@code bridge_whoami}/the roster will read to say which slot a pane hosts. Empty until the
* spawn lifecycle binds a slot.
*/
public Map<String, String> snapshot() {
synchronized (terminalToSlot) {
return Map.copyOf(terminalToSlot);
}
}
/** The slot a live terminal is bound to, or {@code null} if it is not an architect slot. */
public String slotForTerminal(String terminal) {
if (terminal == null) {
return null;
}
synchronized (terminalToSlot) {
return terminalToSlot.get(terminal);
}
}
/**
* The strong-model profile a slot runs under — what the spawn lifecycle reads.
*
* @return the slot's configured {@code profile}, or {@code null} if the slot is unknown or
* declares none
*/
public String profileForSlot(String slotName) {
BridgedConfig.Architect a = slots.get(slotName);
return (a == null || a.profile() == null) ? null : a.profile();
}
/** True when {@code slotName} is a configured architect slot. */
public boolean isSlot(String slotName) {
return slots.containsKey(slotName);
}
/**
* Bind {@code terminal} to {@code slot} (CB-548).
*
* <p>The spawn lifecycle calls this when it stands a slot up. The bind is atomic and preserves
* the two cardinality invariants: a terminal may occupy at most one slot, and a slot may host at
* most one terminal. Binding the same terminal to the same slot again is a harmless no-op.
*
* @param slot a configured slot name, or the bind is refused
* @param terminal the pane that will act as this architect
* @return {@code true} if the binding is now {@code terminal → slot}; {@code false} if it was
* refused — an unknown slot, a terminal already bound to a different slot, or a slot
* already hosting a different terminal
*/
public boolean bind(String slot, String terminal) {
if (slot == null || terminal == null || terminal.isBlank()) {
return false;
}
synchronized (terminalToSlot) {
if (!isSlot(slot)) {
return false; // unknown slot — nothing to bind to
}
String existingSlot = terminalToSlot.get(terminal);
if (existingSlot != null) {
return slot.equals(existingSlot); // already this slot (idempotent) or a different one
}
if (terminalToSlot.containsValue(slot)) {
return false; // slot already hosts a terminal — no second one
}
terminalToSlot.put(terminal, slot);
return true;
}
}
/**
* Compare-safe unbind of {@code expectedTerminal} from {@code slot} (CB-548).
*
* <p>The spawn lifecycle calls this when it tears a slot down. Only the exact binding
* {@code expectedTerminal → slot} is removed; if that terminal was since rebound to a different
* slot (or the slot to a different terminal), the call is a no-op returning {@code false} — a
* stale unbind must never remove a replacement.
*
* @param slot the slot the caller believes the terminal is bound to
* @param expectedTerminal the terminal it expects to be bound there
* @return {@code true} if {@code expectedTerminal → slot} was removed; {@code false} if nothing
* was (no such binding, or the binding had already moved)
*/
public boolean unbind(String slot, String expectedTerminal) {
if (slot == null || expectedTerminal == null) {
return false;
}
synchronized (terminalToSlot) {
String current = terminalToSlot.get(expectedTerminal);
if (current == null || !slot.equals(current)) {
return false; // absent, or a replacement/moved binding — leave it in place
}
terminalToSlot.remove(expectedTerminal);
return true;
}
}
}
@@ -46,20 +46,28 @@ public final class Authz {
return false; // authenticated as nothing ⇒ authorized for nothing
}
return switch (action) {
// Orchestration is the primary's alone. A worker driving spawn/stop/send would be a
// worker escalating into the orchestrator role.
case SPAWN, STOP, SEND, DRAIN -> caller.isPrimary();
// Fleet lifecycle is the primary's alone — spawn, stop, drain. An architect
// deliberately does NOT get these (CB-548), so it cannot tear down or stand up workers
// even though it coordinates them; and a worker driving any of these would be a worker
// escalating into the orchestrator role.
case SPAWN, STOP, DRAIN -> caller.isPrimary();
// Delivering a turn is open to the primary and the architect: an architect delegates
// to workers (that is the role's point) but still has no lifecycle rights. A worker is
// excluded — sending would be it escalating.
case SEND -> caller.isPrimary() || caller.isArchitect();
// The load-bearing rule: a caller acts only as the pane it occupies. CB-532 widened who
// that can be — a lead answering another lead is replying for its OWN terminal, which
// this already permits — while the rule itself is unchanged, and is what stops anyone
// forging a reply for a rendezvous someone else is waiting on. An unnamed primary
// (token/loopback, no pane) owns nothing and is still excluded.
// forging a reply for a rendezvous someone else is waiting on. An architect's own pane
// passes through the same check, so it can answer a funnel that delegated to it. An
// unnamed primary (token/loopback, no pane) owns nothing and is still excluded.
case REPLY, ASK -> caller.ownsSession(targetSession);
// Observation is open to both authenticated roles: a worker legitimately polls its own
// Observation is open to every authenticated role: a worker legitimately polls its own
// status, and the roster carries no secrets.
case READ, METRICS -> caller.isPrimary() || caller.isWorker();
case READ, METRICS -> caller.isPrimary() || caller.isWorker() || caller.isArchitect();
};
}
@@ -24,6 +24,10 @@ import java.util.function.Supplier;
* that pane as a lead's own — without this rule a lead running <em>inside</em> a herdr pane
* is misread as a worker and locked out of orchestration. More than one pane may be named,
* so two leads can work as peers rather than one being demoted.</li>
* <li>A loopback peer PID that maps to a pane bound to a CB-548 architect slot ⇒
* {@link Role#ARCHITECT}, carrying the slot name. Just unforgeable as a worker's, and
* resolved from the <em>live</em> terminal→slot binding (never a request argument), before
* the generic worker fallback.</li>
* <li>A loopback peer PID that maps to any other herdr pane ⇒ {@link Role#WORKER}. This is
* unforgeable (the OS reports the PID, herdr owns the PID→pane map) and is honoured
* regardless of auth mode, so enabling auth never breaks the fleet.</li>
@@ -47,6 +51,15 @@ public final class CallerResolver {
* it is TTL-cached, so this is a map lookup in the common case.
*/
private final Supplier<Map<String, String>> leadTerminals;
/**
* terminal_id → architect slot name; empty when nothing is configured. CB-548.
*
* <p>Like {@link #leadTerminals}, a supplier rather than a fixed map, so a binding injected
* after startup — when the later spawn lifecycle establishes a live architect session, or an
* operator pins one — takes effect without a restart. Consulted per resolve; today's wiring
* in {@code Bridged} reads a constant from config, which is the degenerate live case.
*/
private final Supplier<Map<String, String>> architectTerminals;
/** Loopback-trust resolver: no token required, historical behaviour. */
public CallerResolver(ConnectionIdentity identity) {
@@ -87,7 +100,17 @@ public final class CallerResolver {
*/
public CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token,
Map<String, String> leadTerminals) {
this(identity, tokenMode, token, fixed(leadTerminals));
this(identity, tokenMode, token, fixed(leadTerminals), null);
}
/**
* Map-form of both registries (CB-548): lead terminals and the initial architect terminal
* bindings, each snapshotted at construction (a handed-over map is not offered as live state).
*/
public CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token,
Map<String, String> leadTerminals,
Map<String, String> architectTerminals) {
this(identity, tokenMode, token, fixed(leadTerminals), fixed(architectTerminals));
}
/**
@@ -101,7 +124,23 @@ public final class CallerResolver {
public static CallerResolver withLeads(ConnectionIdentity identity, boolean tokenMode,
String token,
Supplier<Map<String, String>> leadTerminals) {
return new CallerResolver(identity, tokenMode, token, leadTerminals);
return new CallerResolver(identity, tokenMode, token, leadTerminals, null);
}
/**
* Live-registry form for both {@code leadTerminals} and the CB-548 architect registry: both
* are consulted on every resolve, so a slot binding injected after startup takes effect
* without a restart.
*
* <p>A static factory rather than a constructor overload, for the same reason as
* {@link #pinnedTo}: too many {@code Map}/{@code Supplier} combinations to make {@code null}
* unambiguous.
*/
public static CallerResolver withLeadsAndArchitects(ConnectionIdentity identity,
boolean tokenMode, String token,
Supplier<Map<String, String>> leadTerminals,
Supplier<Map<String, String>> architectTerminals) {
return new CallerResolver(identity, tokenMode, token, leadTerminals, architectTerminals);
}
private static Supplier<Map<String, String>> fixed(Map<String, String> leadTerminals) {
@@ -110,7 +149,8 @@ public final class CallerResolver {
}
private CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token,
Supplier<Map<String, String>> leadTerminals) {
Supplier<Map<String, String>> leadTerminals,
Supplier<Map<String, String>> architectTerminals) {
if (tokenMode && (token == null || token.isBlank())) {
throw new IllegalArgumentException(
"auth.mode=token requires a non-empty token; check that the env var named by "
@@ -120,6 +160,7 @@ public final class CallerResolver {
this.tokenMode = tokenMode;
this.expectedToken = tokenMode ? token.getBytes(StandardCharsets.UTF_8) : null;
this.leadTerminals = leadTerminals == null ? Map::of : leadTerminals;
this.architectTerminals = architectTerminals == null ? Map::of : architectTerminals;
}
/**
@@ -135,6 +176,17 @@ public final class CallerResolver {
return leadTerminals.get();
}
/**
* The currently-recognised architect slots, {@code terminal_id → slot name} (CB-548).
*
* <p>Read from the same supplier {@link #resolve} consults, so a slot that is <em>listed</em>
* here but would not <em>resolve</em> (or the reverse) cannot drift apart. Live for the same
* reason as {@link #leads()}.
*/
public Map<String, String> architects() {
return architectTerminals.get();
}
/**
* Resolve the caller of a request.
*
@@ -149,8 +201,17 @@ public final class CallerResolver {
if (lead != null) {
// The config names this pane as a lead's own. The pane mapping is exactly as
// unforgeable as a worker's, so it outranks the token path — no credential needed.
// Checked before the architect registry so a pane named in BOTH is still the lead
// (CB-548 preserves every existing leader behaviour).
return Principal.leader(lead, c.terminal(), c.pid());
}
String slot = architectTerminals.get().get(c.terminal());
if (slot != null) {
// The config/live binding names this pane as an architect slot's own. Same
// unforgeable pane mapping; the live binding, never a request argument, decides.
// Checked before the generic worker fallback, per the CB-548 precedence order.
return Principal.architect(slot, c.terminal(), c.pid());
}
return Principal.worker(c.terminal(), c.pid()); // unforgeable; never token-gated
}
@@ -10,7 +10,8 @@ package dev.ltms.bridged.auth;
* token or loopback trust, and for {@code ANONYMOUS}
* @param pid the connecting process id, or {@code -1} when not resolvable (audit context)
* @param name for a lead resolved from the CB-530 {@code leaders:} registry, which lead it is;
* {@code null} for every other caller, including an unnamed primary
* for an architect resolved from the CB-548 {@code architects:} registry, which
* slot it occupies; {@code null} for every other caller, including an unnamed primary
*/
public record Principal(Role role, String terminal, long pid, String name) {
@@ -58,10 +59,28 @@ public record Principal(Role role, String terminal, long pid, String name) {
return new Principal(Role.WORKER, terminal, pid);
}
/**
* An architect (CB-548), identified by the slot it occupies and the pane bound to it.
*
* <p>Carries {@link Role#ARCHITECT}. {@code slotName} is reporting only — it lets
* {@code bridge_whoami} say <em>which</em> architect slot is asking, and it is the key the
* (future) spawn lifecycle reads a profile back from. Identity is the {@code terminal}: like a
* worker's it comes from the connection and the live terminal→slot binding, so
* {@code ownsSession} works exactly as it does for a worker — an architect acts as its own
* pane and no other.
*/
public static Principal architect(String slotName, String terminal, long pid) {
return new Principal(Role.ARCHITECT, terminal, pid, slotName);
}
public boolean isPrimary() {
return role == Role.PRIMARY;
}
public boolean isArchitect() {
return role == Role.ARCHITECT;
}
public boolean isWorker() {
return role == Role.WORKER;
}
@@ -90,6 +109,7 @@ public record Principal(Role role, String terminal, long pid, String name) {
public String describe() {
return switch (role) {
case WORKER -> "worker:" + terminal;
case ARCHITECT -> "architect:" + name;
case PRIMARY -> name == null ? "primary" : "leader:" + name;
case ANONYMOUS -> "anonymous";
};
@@ -24,6 +24,16 @@ public enum Role {
*/
WORKER,
/**
* A config-declared architect slot (CB-548): a gateway-local named session on a strong-model
* profile that coordinates and delegates turns but does not own the fleet. Unforgeable like a
* worker's — derived from the connection's pane and the live terminal→slot binding, never from
* a request argument. May {@code SEND} a turn, {@code REPLY}/{@code ASK} only as its own pane,
* and {@code READ}/{@code METRICS}; may <em>not</em> {@code SPAWN}/{@code STOP}/{@code DRAIN}
* (those stay the primary's, to keep lifecycle in one pair of hands).
*/
ARCHITECT,
/** Authenticated as nothing. Authorized for nothing but {@code /healthz}. */
ANONYMOUS
}
@@ -1,6 +1,8 @@
package dev.ltms.bridged.config;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import org.slf4j.Logger;
@@ -11,6 +13,7 @@ import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -43,6 +46,11 @@ import java.util.Set;
* @param leaders named panes that orchestrate rather than are orchestrated (CB-530), keyed by
* lead name; supersedes the singular {@code primary} pin, which stays honoured.
* See {@link #leaderTerminals()} for how the two merge
* @param architects CB-548 architect slots, keyed by gateway-local unique slot name; each points
* at a strong-model profile the future spawn lifecycle reads back. An architect
* is <em>not</em> recognised like a lead: config declares the slots only, and a
* live session becomes an architect when the spawn lifecycle binds its terminal
* to a slot. Nothing here spawns a slot.
* @param leadScan opt-in discovery of leads by tab label (CB-531); {@code null} ⇒ no scanning,
* and only {@code leaders:}/{@code primary:} name a lead
* @param placement how to choose a worker profile for an unqualified spawn:
@@ -65,6 +73,7 @@ public record BridgedConfig(
Broker broker,
Primary primary,
Map<String, Leader> leaders,
Map<String, Architect> architects,
LeadScan leadScan,
String placement,
Auth auth) {
@@ -379,6 +388,29 @@ public record BridgedConfig(
public record Leader(String terminal, String kind, String model) {
}
/**
* One entry of the CB-548 {@code architects:} registry — a gateway-local named slot that points
* at a strong-model profile.
*
* <p>A slot is <em>declared</em>, not recognised: config names the slot and the profile it runs,
* and nothing else. Unlike a lead (which config pins by herdr {@code terminal_id} and is
* recognised at startup), an architect slot is idle at boot — config supplies no terminal, so no
* session resolves to one until the spawn lifecycle binds a live terminal to the slot. The
* stable name + profile pair is the only config-time identity; live identity is defined purely
* by the runtime {@link dev.ltms.bridged.auth.ArchitectRegistry} binding.
*
* <p>Why a {@code profile} reference: an architect is meant to run a strong model, and the slot
* records which {@code workers:} profile that is — the value the future spawn lifecycle reads.
* It must name a configured profile, enforced by {@link #validateArchitects()} (a stale or
* typo'd reference fails at startup rather than silently spawning the wrong backend later).
*
* @param profile the name of the strong-model {@code workers:} profile this slot runs;
* required and validated against {@link #workerProfiles()}
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Architect(String profile) {
}
/**
* Discover leads by tab label instead of by pasted {@code terminal_id} (CB-531).
*
@@ -539,13 +571,14 @@ public record BridgedConfig(
private static final Set<String> KNOWN_TOP_LEVEL_KEYS = Set.of(
"bind", "herdrSocket", "worker", "workers", "defaultWorker", "guard", "worktreeRoot",
"lifecycle", "spawnReadyTimeoutMs", "spawnReadyPollMs", "broker", "primary", "leaders",
"leadScan", "placement", "auth");
"architects", "leadScan", "placement", "auth");
/** Load and validate config from {@code path}. */
public static BridgedConfig load(Path path) {
try {
String yaml = Files.readString(path);
warnUnknownTopLevelKeys(yaml, path);
rejectDuplicateArchitectSlots(yaml);
BridgedConfig cfg = YAML.readValue(yaml, BridgedConfig.class);
return cfg.withDefaults();
} catch (IOException e) {
@@ -553,6 +586,101 @@ public record BridgedConfig(
}
}
/**
* Reject an {@code architects:} registry whose slot names repeat (CB-548).
*
* <p>The registry is a {@code Map} keyed by slot name, so by the time it is read duplicate keys
* have already collapsed last-wins — a duplicated slot name would silently drop one slot and the
* daemon would never know. Jackson's YAML parser does not fail on duplicate mapping keys by
* default, so duplicates are caught here, at parse time, before the map is built. Only the
* <em>top-level</em> {@code architects:} block is considered, and only its direct child keys (the
* slot names) — a nested field elsewhere, even one also named {@code architects:}, is ignored, so
* parsing of the rest of the config is unaffected.
*
* @throws IllegalStateException when two {@code architects:} entries share a slot name, naming it
*/
static void rejectDuplicateArchitectSlots(String yaml) {
try (JsonParser p = YAML.createParser(yaml)) {
if (p.nextToken() != JsonToken.START_OBJECT) {
return; // not a mapping at top level — readValue reports the malformed file
}
// Scan the TOP-LEVEL mapping only. Every other field's value (however deep, including
// any nested field also literally named "architects") is consumed whole by skipValue, so
// the loop below can only ever see the top-level field names — a nested `architects:` can
// neither suppress the real block nor be misread as one.
JsonToken t;
while ((t = p.nextToken()) != null && t != JsonToken.END_OBJECT) {
if (t == JsonToken.FIELD_NAME) {
String name = p.getCurrentName();
JsonToken value = p.nextToken();
if ("architects".equals(name)) {
if (value == JsonToken.START_OBJECT) {
rejectDuplicateChildSlotKeys(p);
}
return; // the single top-level architects block is handled; nothing more to check
}
skipValue(p, value);
}
}
} catch (IOException e) {
// Not a duplicate-name condition — let readValue report the malformed file itself.
}
}
/**
* Reject a duplicated <em>direct child</em> key of the (already-positioned) {@code architects:}
* mapping — i.e. a duplicated {@code slot name}.
*
* <p>Each slot's value is consumed whole by {@link #skipValue}, so a duplicated field <em>inside</em>
* a slot (e.g. two {@code profile:} keys, or a duplicate nested {@code architects:}) is never seen
* here and cannot masquerade as a duplicated slot name.
*
* @throws IllegalStateException when two {@code architects:} entries share a slot name, naming it
*/
private static void rejectDuplicateChildSlotKeys(JsonParser p) throws IOException {
Set<String> seen = new HashSet<>();
JsonToken t;
while ((t = p.nextToken()) != null && t != JsonToken.END_OBJECT) {
if (t == JsonToken.FIELD_NAME) {
if (!seen.add(p.getCurrentName())) {
throw new IllegalStateException("refusing to start: duplicate architect slot name '"
+ p.getCurrentName() + "' — slot names must be unique; a later entry would "
+ "silently overwrite the earlier one");
}
skipValue(p, p.nextToken()); // the slot's entire value
}
}
}
/**
* Consume the whole value that starts at {@code start}, including every nested structure, and
* leave the parser positioned just past it. Used so depth is handled structurally rather than by
* a heuristic — a nested field is never interpreted as a top-level {@code architects:}.
*/
private static void skipValue(JsonParser p, JsonToken start) throws IOException {
switch (start) {
case START_OBJECT: {
JsonToken t;
while ((t = p.nextToken()) != null && t != JsonToken.END_OBJECT) {
if (t == JsonToken.FIELD_NAME) {
skipValue(p, p.nextToken());
}
}
return;
}
case START_ARRAY: {
JsonToken t;
while ((t = p.nextToken()) != null && t != JsonToken.END_ARRAY) {
skipValue(p, t);
}
return;
}
default:
// A scalar (VALUE_* / VALUE_NULL) is already fully consumed by the nextToken that
// returned it — nothing further to skip.
}
}
/**
* Log a WARN naming any top-level key this version does not understand (CB-530).
*
@@ -615,7 +743,9 @@ public record BridgedConfig(
// leadScan is left as-is: null is "off", and LeadScan'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.
return new BridgedConfig(b, herdrSocket, worker, workers, defaultWorker, g, worktreeRoot, l, timeout, pollMs, broker, primary, leaders, leadScan, placementOrDefault, a);
// architects is left as-is: null is "none configured", and Architect's fields have no
// defaults to fill. Defaulting it here would change nothing, so leave the call natural.
return new BridgedConfig(b, herdrSocket, worker, workers, defaultWorker, g, worktreeRoot, l, timeout, pollMs, broker, primary, leaders, architects, leadScan, placementOrDefault, a);
}
/**
@@ -720,6 +850,47 @@ public record BridgedConfig(
}
}
/**
* Reject an architect slot whose profile reference does not resolve (CB-548).
*
* <p>An architect's {@code profile} is the strong-model {@code workers:} profile the future
* spawn lifecycle will read to stand the slot up. A reference that names no configured profile
* is a typo or a stale config — and unlike a worker spawn (which fails loudly at its call site
* when it cannot resolve), an architect slot fails only when something later tries to use it.
* This config class <em>does</em> have access to {@link #workerProfiles()}, so the reference
* is validated at startup and the mistake is named then, not discovered months later by a
* spawn that quietly has no backend to use.
*
* <p>Slot-name uniqueness needs no check here: the registry is a {@code Map} keyed by name, so
* duplicates are unrepresentable by construction once loaded — and {@link #load(Path)} already
* rejects a duplicated slot name at parse time, before the map collapses.
*
* @throws IllegalStateException when any architect slot is missing or names an unknown profile,
* naming the slot and the offending reference
*/
public void validateArchitects() {
if (architects == null) {
return;
}
Map<String, Worker> profiles = workerProfiles();
List<String> bad = new java.util.ArrayList<>();
architects.forEach((name, arch) -> {
if (arch == null || arch.profile() == null || arch.profile().isBlank()) {
bad.add("architect slot '" + name + "' has no profile: — give it the name of a "
+ "workers: profile (the strong-model backend it runs).");
return;
}
if (!profiles.containsKey(arch.profile())) {
bad.add("architect slot '" + name + "' references profile '" + arch.profile()
+ "', which is not a configured workers: profile (have: " + profiles.keySet()
+ ").");
}
});
if (!bad.isEmpty()) {
throw new IllegalStateException("refusing to start: " + String.join(" ", bad));
}
}
/** True for the loopback addresses and the unspecified-but-local forms we treat as same-host. */
private static boolean isLoopbackBind(String host) {
if (host == null || host.isBlank()) {
@@ -507,6 +507,17 @@ public final class BridgeMcp {
static McpSchema.CallToolResult whoami(Principal caller, SessionManager sessions) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("role", caller.role().name().toLowerCase());
if (caller.isArchitect()) {
// CB-548: the role reads "architect"; the name is the gateway-local slot the pane is
// bound to, and the pane itself so a peer knows where to reach it.
if (caller.name() != null) {
m.put("architect", caller.name());
}
if (caller.terminal() != null) {
m.put("sessionId", caller.terminal());
}
return text(json(m));
}
if (!caller.isWorker()) {
// CB-530: which lead, once more than one pane is configured as one. `role` deliberately
// still reads "primary" — the fallback ladder in CLAUDE.md keys on it, and a lead IS a
@@ -827,11 +838,13 @@ public final class BridgeMcp {
"Report who YOU are on the bridge — your role is resolved from your connection "
+ "(unforgeable), never from anything you claim. Returns role 'primary' (you "
+ "orchestrate: spawn/send/stop; reply ONLY to answer a peer lead that "
+ "messaged you, never to answer a worker) or 'worker' (you were delegated "
+ "to: you must end every turn with exactly one bridge_reply, and cannot "
+ "spawn or send), plus 'leader' naming which lead you are, your own "
+ "sessionId, and profile/worktree/branch when you are a worker. Call this "
+ "first when following role-conditional instructions rather than guessing.",
+ "messaged you, never to answer a worker), 'architect' (you delegate turns "
+ "and reply/ask as your own pane, but cannot spawn/stop/drain), or 'worker' "
+ "(you were delegated to: you must end every turn with exactly one "
+ "bridge_reply, and cannot spawn or send), plus 'leader'/'architect' naming "
+ "which one you are, your own sessionId, and profile/worktree/branch when "
+ "you are a worker. Call this first when following role-conditional "
+ "instructions rather than guessing.",
objectSchema(Map.of(), List.of()));
}
@@ -0,0 +1,238 @@
package dev.ltms.bridged.auth;
import dev.ltms.bridged.config.BridgedConfig;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.junit.jupiter.api.Assertions.*;
/**
* CB-548 — the architect-slot registry: the config snapshot of slot → profile, and the live
* terminal → slot bindings it owns. The role a binding produces is asserted in
* {@link CallerResolverTest}; this pins the registry object itself — its invariants and their
* thread-safety.
*/
class ArchitectRegistryTest {
private static final Map<String, BridgedConfig.Architect> SLOTS = Map.of(
"lead-designer", new BridgedConfig.Architect("sonnet"),
"reviewer", new BridgedConfig.Architect("gx10"));
private final ArchitectRegistry registry = new ArchitectRegistry(SLOTS);
@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 startsEmptySoNoTerminalResolvesToAnArchitect() {
assertTrue(registry.snapshot().isEmpty());
assertNull(registry.slotForTerminal("term_design"),
"config declares no architect terminal — nothing is recognised until a bind");
assertNull(registry.slotForTerminal(null), "no terminal ⇒ no slot");
}
// ── bind ──────────────────────────────────────────────────────────────────────────────────
@Test
void bindResolvesTheTerminalToTheSlot() {
assertTrue(registry.bind("lead-designer", "term_design"));
assertEquals("lead-designer", registry.slotForTerminal("term_design"));
assertEquals(Map.of("term_design", "lead-designer"), registry.snapshot());
}
@Test
void bindRefusesAnUnknownSlot() {
assertFalse(registry.bind("nope", "term_x"),
"a slot that is not configured must be refused — bind is not a way to invent one");
assertNull(registry.slotForTerminal("term_x"));
}
@Test
void bindRefusesATerminalInTwoSlots() {
assertTrue(registry.bind("lead-designer", "term_design"));
assertFalse(registry.bind("reviewer", "term_design"),
"a terminal may occupy at most one slot");
assertEquals("lead-designer", registry.slotForTerminal("term_design"),
"the first binding survives the refused second");
}
@Test
void bindRefusesASlotWithTwoTerminals() {
assertTrue(registry.bind("lead-designer", "term_design"));
assertFalse(registry.bind("lead-designer", "term_other"),
"a slot may host at most one terminal");
assertEquals("lead-designer", registry.slotForTerminal("term_design"),
"the first binding survives the refused second");
assertNull(registry.slotForTerminal("term_other"));
}
@Test
void rebindingTheSamePairIsAnIdempotentNoOp() {
assertTrue(registry.bind("lead-designer", "term_design"));
assertTrue(registry.bind("lead-designer", "term_design"),
"the same terminal → slot is harmless to repeat");
assertEquals(1, registry.snapshot().size());
}
// ── unbind ────────────────────────────────────────────────────────────────────────────────
@Test
void unbindRemovesTheExactBinding() {
assertTrue(registry.bind("lead-designer", "term_design"));
assertTrue(registry.unbind("lead-designer", "term_design"));
assertNull(registry.slotForTerminal("term_design"));
assertTrue(registry.snapshot().isEmpty());
}
@Test
void aStaleUnbindDoesNotRemoveAReplacement() {
// Bind, tear down, and stand the slot back up with a NEW terminal.
assertTrue(registry.bind("lead-designer", "term_design"));
registry.unbind("lead-designer", "term_design");
assertTrue(registry.bind("lead-designer", "term_new"));
// A late unbind naming the OLD terminal must not remove the replacement binding.
assertFalse(registry.unbind("lead-designer", "term_design"));
assertEquals("lead-designer", registry.slotForTerminal("term_new"),
"the replacement terminal stays bound");
}
@Test
void aStaleUnbindForATerminalThatMovedSlotsDoesNothing() {
// term_design starts in lead-designer, is torn down, and stands back up in a FREE slot.
assertTrue(registry.bind("lead-designer", "term_design"));
registry.unbind("lead-designer", "term_design");
assertTrue(registry.bind("reviewer", "term_design"));
// Unbinding against the slot it no longer occupies is refused; the new binding is intact.
assertFalse(registry.unbind("lead-designer", "term_design"),
"the old slot must not unbind a terminal that moved elsewhere");
assertEquals("reviewer", registry.slotForTerminal("term_design"));
}
@Test
void unbindOfNothingIsAFalseNoOp() {
assertFalse(registry.unbind("lead-designer", "term_design"),
"nothing was bound, so nothing is removed");
}
// ── snapshot ─────────────────────────────────────────────────────────────────────────────
@Test
void theSnapshotIsAnImmutableCopyNotAliveState() {
assertTrue(registry.bind("lead-designer", "term_design"));
Map<String, String> snap = registry.snapshot();
assertThrows(UnsupportedOperationException.class, () -> snap.put("x", "y"),
"a handed-out snapshot cannot be mutated in place");
// Later binds must not leak into an earlier snapshot.
assertTrue(registry.bind("reviewer", "term_review"));
assertFalse(snap.containsKey("term_review"),
"a snapshot is a point-in-time copy, not a live view");
}
// ── concurrency (CB-548 invariants hold under contention) ─────────────────────────────────
@Test
void concurrentBindsNeverGiveASlotTwoTerminals() throws Exception {
int n = 16;
ExecutorService pool = Executors.newFixedThreadPool(n);
try {
CountDownLatch go = new CountDownLatch(1);
List<Future<Boolean>> results = new ArrayList<>();
for (int i = 0; i < n; i++) {
final String term = "term_" + i; // every thread races for the SAME slot
results.add(pool.submit(() -> {
go.await();
return registry.bind("lead-designer", term);
}));
}
go.countDown();
int won = 0;
for (Future<Boolean> r : results) {
if (r.get()) {
won++;
}
}
assertEquals(1, won, "exactly one terminal may win the sole slot, got " + won);
assertEquals(1, registry.snapshot().size(),
"the slot hosts at most one terminal after the race");
} finally {
pool.shutdownNow();
}
}
@Test
void concurrentBindsNeverPutOneTerminalInTwoSlots() throws Exception {
int n = 16;
ExecutorService pool = Executors.newFixedThreadPool(n);
try {
CountDownLatch go = new CountDownLatch(1);
List<Future<String>> results = new ArrayList<>();
for (int i = 0; i < n; i++) {
final String slot = (i % 2 == 0) ? "lead-designer" : "reviewer"; // all race for ONE terminal
results.add(pool.submit(() -> {
go.await();
return registry.bind(slot, "shared_term")
? registry.slotForTerminal("shared_term") : null;
}));
}
go.countDown();
// Rebinding the same terminal to the same slot is a harmless idempotent true, so count
// winners is not the assertion — agreement is: every thread that reported success must
// have seen the terminal in the SAME slot, never in two at once.
String bound = null;
boolean conflict = false;
for (Future<String> r : results) {
String s = r.get();
if (s != null) {
if (bound == null) {
bound = s;
} else if (!bound.equals(s)) {
conflict = true;
}
}
}
assertFalse(conflict, "a terminal was observed in two slots at once");
assertNotNull(bound, "at least one thread bound the terminal");
assertEquals(1, registry.snapshot().size(),
"the terminal occupies exactly one slot in the final snapshot");
assertEquals(bound, registry.slotForTerminal("shared_term"));
} finally {
pool.shutdownNow();
}
}
/** A handed-over slot map is snapshotted at construction, not offered as live state. */
@Test
void theSlotSnapshotIsFixedByConstruction() {
Map<String, BridgedConfig.Architect> mutable = new HashMap<>(SLOTS);
ArchitectRegistry r = new ArchitectRegistry(mutable);
mutable.put("hijack", new BridgedConfig.Architect("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();
@@ -1,5 +1,6 @@
package dev.ltms.bridged.config;
import dev.ltms.bridged.auth.ArchitectRegistry;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -129,6 +130,7 @@ class BridgedConfigTest {
broker: {}
primary: {}
leaders: {}
architects: {}
leadScan: {}
placement: fixed
auth: {}
@@ -326,6 +328,257 @@ class BridgedConfigTest {
assertEquals(Map.of("term_real", "real"), BridgedConfig.load(f).leaderTerminals());
}
// ── CB-548: the architects registry ────────────────────────────────────────────────────────
@Test
void architectsBlockDeclaresSlotsByNameAndProfileOnly(@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:
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("sonnet", cfg.architects().get("reviewer").profile());
}
@Test
void anArchitectCarriesNoConfigTerminalSoNothingIsRecognisedYet(@TempDir Path dir) throws Exception {
// The corrected CB-548 premise: config declares slots (name + profile) only. A `terminal:`
// key left over from the earlier premise is ignored — an architect is NOT recognised from
// config the way a lead is, so it binds nothing at startup and resolves no architect.
Path f = dir.resolve("arch-stale-terminal.yaml");
Files.writeString(f, """
bind:
port: 8080
workers:
sonnet:
baseUrl: http://gx10.gw:8000
architects:
lead-designer:
terminal: term_design
profile: sonnet
""");
BridgedConfig cfg = BridgedConfig.load(f);
assertEquals("sonnet", cfg.architects().get("lead-designer").profile(),
"the profile is still read even when a stray terminal is ignored");
// The registry built from this config owns no bindings: the slot is idle at startup.
ArchitectRegistry r = new ArchitectRegistry(cfg.architects());
assertTrue(r.snapshot().isEmpty());
assertNull(r.slotForTerminal("term_design"),
"a config terminal must not resolve an architect — slots start idle");
}
@Test
void noArchitectsBlockLeavesNothingConfigured(@TempDir Path dir) throws Exception {
Path f = dir.resolve("no-arch.yaml");
Files.writeString(f, "bind:\n port: 8080\n");
assertNull(BridgedConfig.load(f).architects(),
"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:
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:
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:
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:
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 duplicateArchitectSlotNamesAreRejectedAtParseTime(@TempDir Path dir) throws Exception {
Path f = dir.resolve("arch-dup.yaml");
Files.writeString(f, """
bind:
port: 8080
workers:
sonnet:
baseUrl: http://gx10.gw:8000
architects:
lead-designer:
profile: sonnet
lead-designer:
profile: sonnet
""");
IllegalStateException e =
assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f));
assertTrue(e.getMessage().contains("lead-designer"),
"the refusal names the duplicated slot, was: " + e.getMessage());
assertTrue(e.getMessage().contains("duplicate architect"),
"the refusal says the slot name is duplicated");
}
@Test
void duplicateKeysOutsideArchitectsAreUnaffected(@TempDir Path dir) throws Exception {
// The duplicate check is scoped to the architects block — a duplicate elsewhere is not this
// guard's concern and must not change parsing of the rest of the config.
Path f = dir.resolve("dup-other.yaml");
Files.writeString(f, """
bind:
port: 8080
workers:
sonnet:
baseUrl: http://gx10.gw:8000
sonnet:
baseUrl: http://gx10.gw:8000
""");
// Last-wins for a non-architect duplicate is untouched: only the architects block is walked.
assertEquals(Set.of("sonnet"), BridgedConfig.load(f).workerProfiles().keySet());
}
@Test
void aNestedArchitectsFieldDoesNotSuppressRealDuplicateDetection(@TempDir Path dir) throws Exception {
// A field ALSO named `architects` nested under another block carries its own duplicate and
// sits BEFORE the real top-level block. Only the top-level block is ever inspected: the
// refusal must name the real slot (lead-designer), not the nested one (nested-slot).
Path f = dir.resolve("nested-arch.yaml");
Files.writeString(f, """
bind:
port: 8080
architects:
nested-slot:
k: v
nested-slot:
k: v
architects:
lead-designer:
profile: sonnet
lead-designer:
profile: sonnet
""");
IllegalStateException e =
assertThrows(IllegalStateException.class, () -> BridgedConfig.load(f));
assertTrue(e.getMessage().contains("lead-designer"),
"the real top-level duplicate must be reported, was: " + e.getMessage());
assertTrue(e.getMessage().contains("duplicate architect"),
"the refusal says the slot name is duplicated");
}
@Test
void nestedDuplicateFieldsInsideASlotAreNotDuplicateSlotNames(@TempDir Path dir) throws Exception {
// A duplicated field nested inside one slot's own value (here inside an ignored `extra:`
// sub-block) is not a duplicate SLOT name — it must not be rejected as one. Only the direct
// child keys of the architects mapping are slot names; whatever is deeper is the slot's
// business and must not masquerade as a duplicate slot.
Path f = dir.resolve("nested-dup-inside-slot.yaml");
Files.writeString(f, """
bind:
port: 8080
workers:
sonnet:
baseUrl: http://gx10.gw:8000
architects:
lead-designer:
profile: sonnet
extra:
a: 1
a: 1
""");
BridgedConfig cfg = assertDoesNotThrow(() -> BridgedConfig.load(f));
assertDoesNotThrow(cfg::validateArchitects,
"a nested duplicate inside a slot is not a duplicate slot and must not refuse startup");
assertEquals(Set.of("lead-designer"), cfg.architects().keySet());
assertEquals("sonnet", cfg.architects().get("lead-designer").profile());
}
@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);
}
}