CB-561: require member registry for architects
CI / contract (pull_request) Successful in 43s
CI / build (pull_request) Successful in 50s

This commit is contained in:
Dai Ha
2026-08-14 21:47:53 +02:00
parent f9a5e066b5
commit 4ffbcd0b7d
4 changed files with 24 additions and 51 deletions
@@ -65,13 +65,13 @@ public final class CallerResolver {
private final Function<String, MemberRole> memberSlotRoles; private final Function<String, MemberRole> memberSlotRoles;
private final Function<String, String> memberSlotNames; private final Function<String, String> memberSlotNames;
/** Loopback-trust resolver: no token required, historical behaviour. */ /** Loopback-trust resolver: no token required, historical behaviour. Test-only. */
public CallerResolver(ConnectionIdentity identity) { CallerResolver(ConnectionIdentity identity) {
this(identity, false, null, Map.of()); this(identity, false, null, Map.of());
} }
/** As {@link #CallerResolver(ConnectionIdentity, boolean, String, Map)} with no leads pinned. */ /** As {@link #CallerResolver(ConnectionIdentity, boolean, String, Map)} with no leads pinned. Test-only. */
public CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token) { CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token) {
this(identity, tokenMode, token, Map.of()); this(identity, tokenMode, token, Map.of());
} }
@@ -86,7 +86,7 @@ public final class CallerResolver {
* @param pinnedPrimaryTerminal the primary's own herdr {@code terminal_id} * @param pinnedPrimaryTerminal the primary's own herdr {@code terminal_id}
* ({@code null}/blank = unpinned) * ({@code null}/blank = unpinned)
*/ */
public static CallerResolver pinnedTo(ConnectionIdentity identity, boolean tokenMode, static CallerResolver pinnedTo(ConnectionIdentity identity, boolean tokenMode,
String token, String pinnedPrimaryTerminal) { String token, String pinnedPrimaryTerminal) {
return new CallerResolver(identity, tokenMode, token, return new CallerResolver(identity, tokenMode, token,
pinnedPrimaryTerminal == null || pinnedPrimaryTerminal.isBlank() pinnedPrimaryTerminal == null || pinnedPrimaryTerminal.isBlank()
@@ -102,21 +102,11 @@ public final class CallerResolver {
* {@link Role#PRIMARY} — rather than a worker. Empty = nothing pinned, * {@link Role#PRIMARY} — rather than a worker. Empty = nothing pinned,
* so every pane resolves as a worker. * so every pane resolves as a worker.
*/ */
public CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token, CallerResolver(ConnectionIdentity identity, boolean tokenMode, String token,
Map<String, String> leadTerminals) { Map<String, String> leadTerminals) {
this(identity, tokenMode, token, fixed(leadTerminals), null); 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));
}
/** /**
* Live-registry form: {@code leadTerminals} is consulted on every resolve, so leads discovered * Live-registry form: {@code leadTerminals} is consulted on every resolve, so leads discovered
* after startup (CB-531's tab scan) take effect without a restart. * after startup (CB-531's tab scan) take effect without a restart.
@@ -125,29 +115,18 @@ public final class CallerResolver {
* {@link #pinnedTo}: {@code Map} and {@code Supplier} overloads are ambiguous for a literal * {@link #pinnedTo}: {@code Map} and {@code Supplier} overloads are ambiguous for a literal
* {@code null}. * {@code null}.
*/ */
public static CallerResolver withLeads(ConnectionIdentity identity, boolean tokenMode, static CallerResolver withLeads(ConnectionIdentity identity, boolean tokenMode,
String token, String token,
Supplier<Map<String, String>> leadTerminals) { Supplier<Map<String, String>> leadTerminals) {
return new CallerResolver(identity, tokenMode, token, leadTerminals, null); return new CallerResolver(identity, tokenMode, token, leadTerminals, null);
} }
/** /**
* Live-registry form for both {@code leadTerminals} and the CB-548 architect registry: both * Live registry form that can confirm a bound slot is an architect slot.
* 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 * <p>This is the only public construction path. It keeps terminal bindings and slot roles in
* {@link #pinnedTo}: too many {@code Map}/{@code Supplier} combinations to make {@code null} * the same {@link MemberRegistry}, so a configured architect can resolve as an architect.
* unambiguous.
*/ */
public static CallerResolver withLeadsAndMembers(ConnectionIdentity identity,
boolean tokenMode, String token,
Supplier<Map<String, String>> leadTerminals,
Supplier<Map<String, String>> architectTerminals) {
return new CallerResolver(identity, tokenMode, token, leadTerminals, architectTerminals);
}
/** Live registry form that can confirm a bound slot is an architect slot. */
public static CallerResolver withLeadsAndMembers(ConnectionIdentity identity, public static CallerResolver withLeadsAndMembers(ConnectionIdentity identity,
boolean tokenMode, String token, boolean tokenMode, String token,
Supplier<Map<String, String>> leadTerminals, Supplier<Map<String, String>> leadTerminals,
@@ -315,6 +315,7 @@ class CallerResolverTest {
@Test @Test
void aBoundArchitectPaneResolvesToArchitectBeforeTheWorkerFallback() { void aBoundArchitectPaneResolvesToArchitectBeforeTheWorkerFallback() {
// This is the production construction path used by Bridged.
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
Map::of, boundMembers("architect:lead-designer", MemberRole.ARCHITECT)) Map::of, boundMembers("architect:lead-designer", MemberRole.ARCHITECT))
.resolve("127.0.0.1", 42, null); .resolve("127.0.0.1", 42, null);
@@ -338,9 +339,10 @@ class CallerResolverTest {
@Test @Test
void anUnboundPaneStillResolvesAsAWorker() { void anUnboundPaneStillResolvesAsAWorker() {
Map<String, String> arch = Map.of("term_elsewhere", "reviewer"); MemberRegistry members = new MemberRegistry(new BridgedConfig.Fleet(Map.of(),
Map.of("lead-designer", new BridgedConfig.Slot("sonnet")), Map.of(), Map.of(), null));
Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null, Principal p = CallerResolver.withLeadsAndMembers(workerIdentity(), false, null,
Map::of, () -> arch).resolve("127.0.0.1", 42, null); Map::of, members).resolve("127.0.0.1", 42, null);
assertEquals(Role.WORKER, p.role()); assertEquals(Role.WORKER, p.role());
assertNull(p.name()); assertNull(p.name());
@@ -376,16 +378,6 @@ class CallerResolverTest {
assertEquals("architect:lead-designer", r.members().get("term_a")); assertEquals("architect:lead-designer", r.members().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 @Test
void describeNamesTheArchitectSlot() { void describeNamesTheArchitectSlot() {
assertEquals("architect:lead-designer", assertEquals("architect:lead-designer",
@@ -2,6 +2,7 @@ package dev.ltms.bridged.mcp;
import dev.ltms.bridged.auth.Authz; import dev.ltms.bridged.auth.Authz;
import dev.ltms.bridged.auth.CallerResolver; import dev.ltms.bridged.auth.CallerResolver;
import dev.ltms.bridged.auth.MemberRegistry;
import dev.ltms.bridged.auth.Principal; import dev.ltms.bridged.auth.Principal;
import dev.ltms.bridged.auth.Role; import dev.ltms.bridged.auth.Role;
import dev.ltms.bridged.config.BridgedConfig; import dev.ltms.bridged.config.BridgedConfig;
@@ -69,7 +70,8 @@ class BridgeMcpAuthzTest {
mcp = new BridgeMcp(messages, workers, sessions, identity, sessions.asPresence(), mcp = new BridgeMcp(messages, workers, sessions, identity, sessions.asPresence(),
new PrimaryRegistry(null), new PrimaryRegistry(null),
enforce ? new CallerResolver(identity) : null, enforce ? CallerResolver.withLeadsAndMembers(identity, false, null,
Map::of, new MemberRegistry(null)) : null,
metrics); metrics);
return mcp; return mcp;
} }
@@ -1,6 +1,7 @@
package dev.ltms.bridged.rest; package dev.ltms.bridged.rest;
import dev.ltms.bridged.auth.CallerResolver; import dev.ltms.bridged.auth.CallerResolver;
import dev.ltms.bridged.auth.MemberRegistry;
import dev.ltms.bridged.config.BridgedConfig; import dev.ltms.bridged.config.BridgedConfig;
import dev.ltms.bridged.guard.SubscriptionGuard; import dev.ltms.bridged.guard.SubscriptionGuard;
import dev.ltms.bridged.herdr.AgentControl; import dev.ltms.bridged.herdr.AgentControl;
@@ -65,9 +66,8 @@ class BridgedAppAuthTest {
MessageService messages = new MessageService(agents, injector, new Rendezvous()); MessageService messages = new MessageService(agents, injector, new Rendezvous());
ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> pid); ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> pid);
CallerResolver callers = tokenMode CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, tokenMode, token,
? new CallerResolver(identity, true, token) Map::of, new MemberRegistry(null));
: new CallerResolver(identity);
metrics = BridgedMetrics.create(sessions, new dev.ltms.bridged.msg.InMemoryReplyInbox()); metrics = BridgedMetrics.create(sessions, new dev.ltms.bridged.msg.InMemoryReplyInbox());
app = new BridgedApp(herdr, workers, sessions, messages, sessions.asPresence(), null, app = new BridgedApp(herdr, workers, sessions, messages, sessions.asPresence(), null,