diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java
index 267aa99..34e828a 100644
--- a/bridged/src/main/java/dev/ltms/bridged/Bridged.java
+++ b/bridged/src/main/java/dev/ltms/bridged/Bridged.java
@@ -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)");
}
diff --git a/bridged/src/main/java/dev/ltms/bridged/auth/ArchitectRegistry.java b/bridged/src/main/java/dev/ltms/bridged/auth/ArchitectRegistry.java
new file mode 100644
index 0000000..1e7c673
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/auth/ArchitectRegistry.java
@@ -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 live bindings from a live architect's herdr terminal to
+ * its slot.
+ *
+ *
Two halves, split by who owns each:
+ *
+ * - slots — 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.
+ * - terminal bindings — owned by this registry and initially empty. 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}.
+ *
+ *
+ * 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 slots;
+ /** Live {@code terminal_id → slot name}; guarded by {@code this}. */
+ private final Map terminalToSlot = new HashMap<>();
+
+ public ArchitectRegistry(Map slots) {
+ this.slots = slots == null ? Map.of() : Map.copyOf(slots);
+ }
+
+ /** The configured slots, keyed by gateway-local unique name. Unmodifiable snapshot. */
+ public Map slots() {
+ return slots;
+ }
+
+ /**
+ * An immutable copy of the live {@code terminal_id → slot name} bindings.
+ *
+ * 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 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).
+ *
+ * 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).
+ *
+ *
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;
+ }
+ }
+}
diff --git a/bridged/src/main/java/dev/ltms/bridged/auth/Authz.java b/bridged/src/main/java/dev/ltms/bridged/auth/Authz.java
index 0f96310..a89d6aa 100644
--- a/bridged/src/main/java/dev/ltms/bridged/auth/Authz.java
+++ b/bridged/src/main/java/dev/ltms/bridged/auth/Authz.java
@@ -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();
};
}
diff --git a/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java b/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java
index 5f10789..ccc5ad6 100644
--- a/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java
+++ b/bridged/src/main/java/dev/ltms/bridged/auth/CallerResolver.java
@@ -24,6 +24,10 @@ import java.util.function.Supplier;
* that pane as a lead's own — without this rule a lead running inside 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.
+ *
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 live terminal→slot binding (never a request argument), before
+ * the generic worker fallback.
* 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.
@@ -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