From 6a7342b1f05c5dae5588a8e10ecb29bd2b0b1bc2 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 12 Sep 2026 11:34:12 +0700 Subject: [PATCH] fleetd #518: make the FleetMcp caller-resolution wiring an explicit choice, and test it once for real FleetMcp's contextExtractor picked its principal-resolution path off `callers == null`, so "authorization off" also silently swapped in a second, untested identity heuristic (legacyPrincipal). Nothing drove that closure through a real MCP request, so the whole wiring was an unexercised claim. - callers (CallerResolver) is now required, never null. - A new AuthorizationMode enum (ENFORCED/UNENFORCED) is a required constructor parameter with no default, replacing the null-means-legacy idiom for whether denyFor enforces at all. - legacyPrincipal is deleted: there is exactly one resolution path now (callers.resolve(...)), so the mutation that swapped it for an unconditional legacy call no longer compiles ("cannot find symbol: method legacyPrincipal"). - FleetMcpContextExtractorTest boots the real transport on a real Jetty server and drives it with a real MCP client, proving fleet_whoami's resolved role comes from CallerResolver's token check. - Adapted FleetMcpAuthzTest/FleetMcpHandoverTest call sites; theLegacyConstructorLeavesTheGateOpen keeps its meaning under the new AuthorizationMode.UNENFORCED value. --- .../src/main/java/dev/ltms/fleet/Fleetd.java | 2 +- .../java/dev/ltms/fleet/mcp/FleetMcp.java | 86 +++++----- .../dev/ltms/fleet/mcp/FleetMcpAuthzTest.java | 38 +++-- .../mcp/FleetMcpContextExtractorTest.java | 147 ++++++++++++++++++ .../ltms/fleet/mcp/FleetMcpHandoverTest.java | 1 + 5 files changed, 218 insertions(+), 56 deletions(-) create mode 100644 fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpContextExtractorTest.java diff --git a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java index dbfaf10..29d8086 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java +++ b/fleetd/src/main/java/dev/ltms/fleet/Fleetd.java @@ -694,7 +694,7 @@ public final class Fleetd { }, outagePolicy); FleetMcp mcp = new FleetMcp(messages, workers, sessions, identity, presence, - primaryRegistry, callers, metrics, + primaryRegistry, callers, FleetMcp.AuthorizationMode.ENFORCED, metrics, capacitySource(config, cfg, profile -> liveCountRef.get().apply(profile)), new FleetMcp.HealthCoverageSource(() -> { var health = config.get().health(); diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java index 5f0a76b..16af1ef 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -93,7 +93,18 @@ public final class FleetMcp { private final HttpServletStreamableServerTransportProvider transport; private final McpSyncServer server; - private final CallerResolver authz; // CB-501: null → authorization not enforced (legacy) + /** + * fleetd #518: whether {@link #denyFor} enforces the CB-505 policy table at all. Replaces the + * old {@code CallerResolver authz} field, whose null-ness used to decide BOTH this AND which + * principal-resolution code path {@link #contextExtractor} ran — reaching "authorization off" + * by simply not passing a {@link CallerResolver} also meant the resolved {@link Principal} + * came from a second, separately-maintained heuristic ({@code legacyPrincipal}, now deleted) + * that nothing ever exercised. There is now exactly one resolution path ({@code callers}, + * required and non-null below) and a separate, explicitly-chosen {@link AuthorizationMode} + * for this flag — so a caller can turn enforcement off without silently swapping in a second, + * untested identity heuristic. + */ + private final boolean authorizationEnforced; private final Metrics metrics; // CB-502: null → auth failures not counted private final CapacitySource capacity; private final HealthCoverageSource healthCoverage; @@ -250,6 +261,17 @@ public final class FleetMcp { public static CoordinationSource none() { return new CoordinationSource(null, List.of()); } } + /** + * fleetd #518: whether {@link #denyFor} enforces the CB-505 policy table. A required + * constructor parameter with no default, so "authorization is off" can only be reached by a + * caller explicitly saying so — never by omitting a {@link CallerResolver} the way the old + * {@code callers == null} idiom allowed. {@code callers} itself is required either way: even + * under {@link #UNENFORCED}, the one real {@link CallerResolver} still resolves every caller's + * {@link Principal} (so {@code markSpawnedMemberPresent}/{@code recordPrimarySingleton} see a + * real identity), and {@link #denyFor} is the only thing that changes. + */ + public enum AuthorizationMode { ENFORCED, UNENFORCED } + /** * The only constructor (fleetd #480 Unit C correction round). Every field below used to have * its own defaulting overload — {@code leadChannel}/{@code outage}/{@code leadSeats}/ @@ -268,10 +290,16 @@ public final class FleetMcp { * {@link OutageSource#none()}, {@link LeadSeatSource#none()}, {@code List.of()} are all still * perfectly fine values, just never an implicit default reached by omission. * - * @param callers resolves each call's {@link Principal}; {@code null} disables - * authorization. This surface needs its own enforcement: {@code /mcp} is a - * raw servlet on Jetty's context handler and never passes through - * Javalin's {@code before} filter, so the REST guard does not cover it. + * @param callers resolves each call's {@link Principal}. Required, never {@code null} — + * fleetd #518: use {@link AuthorizationMode#UNENFORCED} to disable + * enforcement, not a missing resolver. This surface needs its own + * enforcement: {@code /mcp} is a raw servlet on Jetty's context handler and + * never passes through Javalin's {@code before} filter, so the REST guard + * does not cover it. + * @param authorizationMode fleetd #518: whether {@link #denyFor} enforces the CB-505 policy + * table ({@link AuthorizationMode#ENFORCED}) or leaves the gate open + * ({@link AuthorizationMode#UNENFORCED}, for the pre-CB-513 test suite that + * does not exercise authorization). Required, with no default. * @param metrics registry for auth-failure counting; may be {@code null} * @param quarantine CB-578 stage B facts for {@code fleet_profiles}; pass * {@link QuarantineSource#none()} for a caller that does not want the @@ -301,9 +329,13 @@ public final class FleetMcp { */ public FleetMcp(MessageService messages, PeerLauncher workers, SessionManager sessions, ConnectionIdentity identity, MemberPresence presence, PrimaryRegistry primaryRegistry, - CallerResolver callers, Metrics metrics, CapacitySource capacity, HealthCoverageSource healthCoverage, + CallerResolver callers, AuthorizationMode authorizationMode, Metrics metrics, + CapacitySource capacity, HealthCoverageSource healthCoverage, QuarantineSource quarantine, LeadChannel leadChannel, OutageSource outage, LeadSeatSource leadSeats, List peers, LeadRollover leadRollover) { + Objects.requireNonNull(callers, "callers"); + this.authorizationEnforced = Objects.requireNonNull(authorizationMode, "authorizationMode") + == AuthorizationMode.ENFORCED; this.leadChannel = leadChannel; this.peers = peers == null ? List.of() : List.copyOf(peers); this.capacity = capacity; @@ -322,11 +354,11 @@ public final class FleetMcp { // (CB-113) — its MCP initialize is the reliable "the agent is up" signal. .contextExtractor(req -> { // One resolution per call, shared with the REST surface via CallerResolver so - // the two paths cannot drift on who a caller is. - Principal p = callers != null - ? callers.resolve(req.getRemoteAddr(), req.getRemotePort(), - req.getHeader("Authorization")) - : legacyPrincipal(identity, req.getRemoteAddr(), req.getRemotePort()); + // the two paths cannot drift on who a caller is. fleetd #518: callers is + // required (never null) so there is no second, untested resolution path to + // fall back to here — AuthorizationMode governs enforcement, not identity. + Principal p = callers.resolve(req.getRemoteAddr(), req.getRemotePort(), + req.getHeader("Authorization")); // CB-532: guard on the ROLE, not on the terminal being null. This excludes a // lead, which carries its pane too, while including every spawned member role. // Enrolling a lead would count it as an available member in the roster. @@ -443,7 +475,7 @@ public final class FleetMcp { McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_list", Map.of()), null); if (denied != null) return denied; return listFleet(workers, sessions, messages, capacity, healthCoverage, quarantine, outage, - leadSeats, callers == null ? Map.of() : callers.leads(), + leadSeats, callers.leads(), callerTerminal(exchange), new CoordinationSource(leadChannel, peers), coordinatorVisibleTo(principal(exchange))); @@ -522,35 +554,9 @@ public final class FleetMcp { .toolCall(fleetWhoami, whoamiHandler) .toolCall(fleetHandover, handoverHandler) .build(); - this.authz = callers; this.metrics = metrics; } - /** - * Pre-CB-501 identity: worker if the connection maps to a pane, otherwise anonymous. Used - * only by the legacy constructor ({@code callers == null}), where authorization is not - * enforced anyway — but the resolved {@link Principal} still reaches non-authz logic (e.g. - * {@code markSpawnedMemberPresent}, {@code recordPrimarySingleton}), so it must not be trusted - * with a role it did not earn. - * - *

fleetd #509: this used to fall back to {@link Principal#primary}, unconditionally, for - * every caller the connection did not resolve to a worker pane — with none of - * {@code CallerResolver.java:254}'s two guards ({@code isLoopback}, {@code scanComplete}). - * That is the exact shape #317 and #505 each closed on the enforced path; this branch was the - * same trap, left open on the legacy one. It now returns {@link Principal#anonymous} instead, - * so an unresolved legacy caller earns no authority rather than the primary's. - * - *

Package-private (was {@code private}) so this is unit-testable directly, the same reason - * {@link #denyFor} was split out — it runs inside a contextExtractor closure that only fires on - * a real MCP request, so nothing else could pin this behaviour. - */ - static Principal legacyPrincipal(ConnectionIdentity identity, String addr, int port) { - ConnectionIdentity.Caller c = identity.resolve(addr, port); - return c.terminal() != null - ? Principal.worker(c.terminal(), c.pid()) - : Principal.anonymous(); - } - /** The caller reconstructed from the transport context. */ private static Principal principal(McpSyncServerExchange exchange) { return principalFrom(exchange.transportContext().get(CALLER_ROLE), @@ -605,8 +611,8 @@ public final class FleetMcp { McpSchema.CallToolResult denyFor(Principal caller, Authz.Action action, String target) { // The enforcement switch lives HERE rather than in the exchange-facing wrapper: any future // tool that calls this directly must not be able to skip the gate by accident. - if (authz == null) { - return null; // legacy constructor: authorization not enforced + if (!authorizationEnforced) { + return null; // AuthorizationMode.UNENFORCED: authorization not enforced (fleetd #518) } if (Authz.permits(caller, action, target)) { if (action != Authz.Action.READ) { diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java index d540948..633a025 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java @@ -78,10 +78,15 @@ class FleetMcpAuthzTest { // fleetd #480 correction round: FleetMcp has one constructor now (no defaulting // overloads — see its javadoc), so every feature this test does not exercise is passed // its explicit "off" value here rather than being omitted. + // + // fleetd #518: callers is now required (never null) either way — the resolver that used + // to be omitted to reach "legacy" is now always real, and AuthorizationMode is the + // separate, explicit choice that governs enforcement. mcp = new FleetMcp(messages, workers, sessions, identity, sessions.asPresence(), new PrimaryRegistry(null), - enforce ? CallerResolver.withLeadsAndMembers(identity, false, null, - Map::of, new MemberRegistry(null)) : null, + CallerResolver.withLeadsAndMembers(identity, false, null, + Map::of, new MemberRegistry(null)), + enforce ? FleetMcp.AuthorizationMode.ENFORCED : FleetMcp.AuthorizationMode.UNENFORCED, metrics, FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"), FleetMcp.QuarantineSource.none(), null, FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(), List.of(), null); @@ -187,28 +192,31 @@ class FleetMcpAuthzTest { // The 22 pre-existing FleetMcpTest cases rely on no authorization being enforced. FleetMcp m = mcp(false); assertNull(m.denyFor(ANON, Authz.Action.SPAWN, null), - "no CallerResolver supplied ⇒ authorization not enforced (legacy behaviour)"); + "AuthorizationMode.UNENFORCED chosen explicitly ⇒ authorization not enforced " + + "(legacy behaviour) — fleetd #518 replaced the old callers == null idiom"); } /** - * fleetd #509: {@code legacyPrincipal} (used only when {@code callers == null}, i.e. the - * legacy constructor above) used to fall back to {@link Principal#primary} for ANY caller the - * connection did not resolve to a worker pane — no {@code isLoopback} check, no - * {@code scanComplete} check, unlike the enforced path's {@code CallerResolver.java:254}. A - * non-loopback caller (an off-host client) is exactly the case that must never earn the - * primary's authority, and authorization being disabled in legacy mode does not make that - * safe: the resolved {@link Principal} still reaches non-authz logic such as - * {@code markSpawnedMemberPresent} and {@code recordPrimarySingleton}. + * fleetd #509 was originally proven against {@code FleetMcp.legacyPrincipal} — a second, + * separately-maintained principal-resolution heuristic that only ran when {@code callers} was + * omitted (null). fleetd #518 deleted that whole heuristic: {@code callers} is now required + * and non-null under every {@link FleetMcp.AuthorizationMode}, so the ONE real + * {@link CallerResolver} resolves every caller, enforced or not, and #509's property (a + * non-loopback / unresolved caller must never earn the primary's authority) is exactly what + * {@code CallerResolverTest.aNonLoopbackCallerIsNeverThePrimaryUnderLoopbackTrust} already + * proves on that one real path. There is no longer a second heuristic here to test. */ @Test - void legacyPrincipalIsAnonymousNotPrimaryForAnUnresolvedCaller() { + void anUnresolvedNonLoopbackCallerIsAnonymousUnderTheOneRealResolver() { ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> 999_999); + CallerResolver resolver = CallerResolver.withLeadsAndMembers(identity, false, null, + Map::of, new MemberRegistry(null)); // A non-loopback address never even reaches the pane scan — resolve() short-circuits it // to Caller(null, -1, true), the same "no terminal" shape a genuine primary's connection - // produces. legacyPrincipal must not conflate the two. - Principal p = FleetMcp.legacyPrincipal(identity, "8.8.8.8", 1234); + // produces on loopback. The real resolver must not conflate the two. + Principal p = resolver.resolve("8.8.8.8", 1234, null); assertEquals(Principal.anonymous(), p, - "an unresolved legacy caller must earn no authority, not the primary's"); + "an unresolved, non-loopback caller must earn no authority, not the primary's"); } // --- fleetd #439: who may see fleet_list's coordinator row ---------------------------------- diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpContextExtractorTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpContextExtractorTest.java new file mode 100644 index 0000000..6bbc59c --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpContextExtractorTest.java @@ -0,0 +1,147 @@ +package dev.ltms.fleet.mcp; + +import dev.ltms.fleet.auth.CallerResolver; +import dev.ltms.fleet.auth.MemberRegistry; +import dev.ltms.fleet.config.FleetConfig; +import dev.ltms.fleet.guard.SubscriptionGuard; +import dev.ltms.fleet.herdr.AgentControl; +import dev.ltms.fleet.herdr.FakeHerdr; +import dev.ltms.fleet.herdr.PaneLocator; +import dev.ltms.fleet.herdr.WorkspaceControl; +import dev.ltms.fleet.inject.Injector; +import dev.ltms.fleet.member.ClaudeCodeLauncher; +import dev.ltms.fleet.msg.InMemoryReplyInbox; +import dev.ltms.fleet.msg.MessageService; +import dev.ltms.fleet.msg.Rendezvous; +import dev.ltms.fleet.session.FakeWorktrees; +import dev.ltms.fleet.session.SessionManager; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.net.http.HttpRequest; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * fleetd #518 — Part 2: drive the {@code contextExtractor} closure for real. + * + *

{@code FleetMcp.deny()}/{@code denyFor()} has a full policy table of tests + * ({@code FleetMcpAuthzTest}), and {@code CallerResolver.resolve()} has its own full suite + * ({@code CallerResolverTest}). Neither one ever exercises the closure that WIRES them together + * inside {@code FleetMcp}'s constructor: it is built once, handed to the MCP SDK's transport, and + * only ever runs when a real MCP client makes a real HTTP request. Every existing test either + * calls {@code denyFor(Principal, ...)} with a hand-built {@link dev.ltms.fleet.auth.Principal} + * (never asking who the transport would actually have resolved) or drives a static handler method + * directly. A mutation that swapped the whole resolution decision for an unconditional fallback — + * bypassing {@link CallerResolver} entirely — passed the full suite, including every + * {@code FleetMcpAuthzTest} case, because none of them go through the transport at all. + * + *

This test boots the real {@code HttpServletStreamableServerTransportProvider} on a real + * Jetty server, drives it with a real MCP client over HTTP, and checks a result that only the + * real {@link CallerResolver} can produce: token-mode inspects the {@code Authorization} header + * and grants {@code PRIMARY} only for the right bearer token. The connection never resolves to a + * worker pane (the fake peer-pid lookup always misses), so the ONLY way {@code fleet_whoami} can + * come back as {@code primary} is if the closure actually called {@code callers.resolve(...)} and + * read that header — a behaviour the deleted {@code legacyPrincipal} heuristic never had at all. + */ +class FleetMcpContextExtractorTest { + + private static final String TOKEN = "s3cret-mcp-token"; + + private final FakeHerdr herdr = new FakeHerdr(); + private final AgentControl agents = new AgentControl(herdr); + private FleetMcp mcp; + private Server server; + + @AfterEach + void tearDown() throws Exception { + if (server != null) { + server.stop(); + } + if (mcp != null) { + mcp.close(); + } + } + + @Test + void aRealMcpRequestIsResolvedByTheRealCallerResolverNotAFallback() throws Exception { + FleetConfig.Profile cfg = new FleetConfig.Profile( + "ltms-local", "http://gx00.gw:8000", "coder", null, "FLEETD_WORKER_TOKEN", null, + "tab", "fleetd-workers", "worker: {profile} #{n}", null, null, null); + ClaudeCodeLauncher workers = new ClaudeCodeLauncher(agents, new WorkspaceControl(herdr), + new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), + _ -> "tok"); + SessionManager sessions = new SessionManager(workers, new FakeWorktrees()); + MessageService messages = new MessageService(agents, new Injector(agents), new Rendezvous(), + new InMemoryReplyInbox()); + // The peer-pid lookup always misses (-1), so no connection here is ever resolved to a + // worker pane — every call falls through to CallerResolver's token check, the one branch + // that is unreachable through the deleted legacy heuristic. + ConnectionIdentity identity = new ConnectionIdentity(new PaneLocator(herdr), _ -> -1); + CallerResolver callers = CallerResolver.withLeadsAndMembers(identity, true, TOKEN, + Map::of, new MemberRegistry(null)); + + mcp = new FleetMcp(messages, workers, sessions, identity, sessions.asPresence(), + new PrimaryRegistry(null), callers, FleetMcp.AuthorizationMode.ENFORCED, + null, FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"), + FleetMcp.QuarantineSource.none(), null, FleetMcp.OutageSource.none(), + FleetMcp.LeadSeatSource.none(), List.of(), null); + + ServletContextHandler handler = new ServletContextHandler(); + handler.setContextPath("/"); + handler.addServlet(new ServletHolder(mcp.servlet()), "/mcp"); + server = new Server(0); + server.setHandler(handler); + server.start(); + String baseUrl = "http://127.0.0.1:" + + ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + + // The right bearer token: the real CallerResolver grants PRIMARY, which fleet_whoami's + // READ gate lets through. + McpSchema.CallToolResult authorized = callWhoami(baseUrl, "Bearer " + TOKEN); + assertFalse(authorized.isError(), "a valid bearer token must resolve as PRIMARY and pass " + + "fleet_whoami's READ gate: " + textOf(authorized)); + assertTrue(textOf(authorized).contains("\"role\":\"primary\""), + "fleet_whoami must report the role the real CallerResolver resolved over this " + + "connection, not a fallback: " + textOf(authorized)); + + // No credential at all, over the SAME wiring: the real resolver refuses it as ANONYMOUS. + // legacyPrincipal never looked at the Authorization header, so it could not have told + // these two calls apart at all -- this is the assertion the deleted mutation would fail. + McpSchema.CallToolResult unauthorized = callWhoami(baseUrl, null); + assertTrue(unauthorized.isError(), "no credential must be refused, not silently let " + + "through: " + textOf(unauthorized)); + } + + private static McpSchema.CallToolResult callWhoami(String baseUrl, String authorizationHeader) { + HttpRequest.Builder requestTemplate = HttpRequest.newBuilder(); + if (authorizationHeader != null) { + requestTemplate.header("Authorization", authorizationHeader); + } + McpClientTransport transport = HttpClientStreamableHttpTransport.builder(baseUrl) + .endpoint("/mcp") + .requestBuilder(requestTemplate) + .build(); + try (McpSyncClient client = McpClient.sync(transport).build()) { + client.initialize(); + return client.callTool(McpSchema.CallToolRequest.builder("fleet_whoami").arguments(Map.of()).build()); + } + } + + private static String textOf(McpSchema.CallToolResult r) { + return ((McpSchema.TextContent) r.content().getFirst()).text(); + } +} diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpHandoverTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpHandoverTest.java index ed0b8ef..a93a129 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpHandoverTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpHandoverTest.java @@ -89,6 +89,7 @@ class FleetMcpHandoverTest { mcp = new FleetMcp(messages, workers, sessions, identity, sessions.asPresence(), new PrimaryRegistry(null), CallerResolver.withLeadsAndMembers(identity, false, null, Map::of, new MemberRegistry(null)), + FleetMcp.AuthorizationMode.ENFORCED, null, FleetMcp.CapacitySource.none(), new FleetMcp.HealthCoverageSource(() -> "off"), FleetMcp.QuarantineSource.none(), null, FleetMcp.OutageSource.none(), FleetMcp.LeadSeatSource.none(), List.of(), leadRollover); -- 2.52.0