diff --git a/bridged/src/main/java/dev/ltms/bridged/Bridged.java b/bridged/src/main/java/dev/ltms/bridged/Bridged.java
index 267aa99..fcbbc78 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. 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)");
}
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..c5548b3
--- /dev/null
+++ b/bridged/src/main/java/dev/ltms/bridged/auth/ArchitectRegistry.java
@@ -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.
+ *
+ *
Two halves, split by who owns each:
+ *
+ * - slots — configured once, keyed by the gateway-local unique name; each carries the
+ * {@code profile} reference the future spawn lifecycle will read when it stands the
+ * slot up. A read-only snapshot taken at construction.
+ * - terminal bindings — a {@link Supplier} consulted on every read, so a binding
+ * injected after 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}.
+ *
+ *
+ * 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 slots;
+ private final Supplier