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
@@ -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. Slots live in config (name → strong-model
// profile); the terminal → slot binding is the live half, sourced from the slots' declared
// terminals today and swapped for a live binding by the later spawn lifecycle. 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(),
() -> cfg.architectTerminals());
if (!architects.slots().isEmpty()) {
log.info("architect slots: {} configured {}, terminals {}", architects.slots().size(),
architects.slots().keySet(), cfg.architectTerminals().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::terminalBindings);
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::terminalBindings);
log.info("auth: loopback-trust (any loopback non-worker caller is the primary)");
}
@@ -0,0 +1,73 @@
package dev.ltms.bridged.auth;
import dev.ltms.bridged.config.BridgedConfig;
import java.util.Map;
import java.util.function.Supplier;
/**
* The architect-slot registry (CB-548): every gateway-local architect name and the strong-model
* profile it points at, plus the live binding 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 <em>future</em> spawn lifecycle will read when it stands the
* slot up. A read-only snapshot taken at construction.</li>
* <li><b>terminal bindings</b> — a {@link Supplier} consulted on every read, so a binding
* injected <em>after</em> startup (an operator pin, or the later lifecycle once it spawns a
* session) takes effect without a restart. {@link CallerResolver} reads this to turn a pane
* into an {@link Role#ARCHITECT}.</li>
* </ul>
*
* <p>Spawning/lifecycle is deliberately a separate unit: this class only exposes the map the
* resolver resolves against and the profile lookup that lifecycle will call. Nothing here
* creates or manages an architect session.
*/
public final class ArchitectRegistry {
private final Map<String, BridgedConfig.Architect> slots;
private final Supplier<Map<String, String>> terminalBindings;
public ArchitectRegistry(Map<String, BridgedConfig.Architect> slots,
Supplier<Map<String, String>> terminalBindings) {
this.slots = slots == null ? Map.of() : Map.copyOf(slots);
this.terminalBindings = terminalBindings == null ? Map::of : terminalBindings;
}
/** The configured slots, keyed by gateway-local unique name. Unmodifiable snapshot. */
public Map<String, BridgedConfig.Architect> slots() {
return slots;
}
/**
* The live {@code terminal_id → slot name} bindings, re-read on every call.
*
* <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.
*/
public Map<String, String> terminalBindings() {
return terminalBindings.get();
}
/** The slot a live terminal is bound to, or {@code null} if it is no architect slot. */
public String slotForTerminal(String terminal) {
return terminal == null ? null : terminalBindings.get().get(terminal);
}
/**
* The strong-model profile a slot runs under — what the future 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);
}
}
@@ -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
}
@@ -43,6 +43,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, and the identity a live session is matched by is
* its {@code terminal} binding (see {@link #architectTerminals()}). A slot is
* the hook the future spawn lifecycle reads a profile back from — nothing here
* spawns it.
* @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 +70,7 @@ public record BridgedConfig(
Broker broker,
Primary primary,
Map<String, Leader> leaders,
Map<String, Architect> architects,
LeadScan leadScan,
String placement,
Auth auth) {
@@ -379,6 +385,32 @@ 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 lead and an architect differ in <em>authority</em>, not in how identity is established:
* both are recognised by configuration rather than spawned. A lead resolves to
* {@link dev.ltms.bridged.auth.Role#PRIMARY} and owns the whole lifecycle (spawn/stop/drain);
* an architect resolves to {@link dev.ltms.bridged.auth.Role#ARCHITECT}, which delegates turns
* ({@code SEND}) and replies/asks as its own pane but cannot stand up or tear down workers —
* lifecycle stays in one pair of hands.
*
* <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 terminal the architect's herdr {@code terminal_id}; the field identity is matched by,
* via the live terminal→slot binding. Optional at config time — binding may be
* injected live — but a slot with no binding matches nothing yet.
* @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 terminal, String profile) {
}
/**
* Discover leads by tab label instead of by pasted {@code terminal_id} (CB-531).
*
@@ -436,6 +468,30 @@ public record BridgedConfig(
return Collections.unmodifiableMap(byTerminal);
}
/**
* The terminal → architect-slot-name map that {@link dev.ltms.bridged.auth.CallerResolver}
* resolves against (CB-548), derived from the {@code architects:} registry.
*
* <p>Keyed by terminal because a live session is matched by its pane; the value is the
* gateway-local slot name. Slot names are inherently unique (a map key); a duplicate terminal
* across two slots is last-wins here (the later entry overrides), which {@code leadership} has
* always tolerated rather than refused. This is consumed as the <em>initial</em> live binding —
* the supplier that feeds the resolver may be swapped for a live one by the future lifecycle.
*
* @return an unmodifiable map, empty when no architect slot is configured
*/
public Map<String, String> architectTerminals() {
Map<String, String> byTerminal = new LinkedHashMap<>();
if (architects != null) {
architects.forEach((name, arch) -> {
if (arch != null && arch.terminal() != null && !arch.terminal().isBlank()) {
byTerminal.put(arch.terminal(), name);
}
});
}
return Collections.unmodifiableMap(byTerminal);
}
/**
* API authentication (CB-501). Governs how a caller that is <em>not</em> an on-host worker
* pane proves it is the primary.
@@ -539,7 +595,7 @@ 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) {
@@ -615,7 +671,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 +778,46 @@ 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.
*
* @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()));
}