{
  "started_utc": "2026-11-01T03:19:11.543013+00:00",
  "finished_utc": "2026-11-01T03:19:12.538738+00:00",
  "query_sha256": "1543a03cc3be7f4b7b0df71c3ff90cbc61b08ddda84987b2910953bbf997a0a4",
  "data": {
    "files": [
      {
        "path": "src\\main\\java\\com\\icerti\\platform\\poc\\config\\RecManagedConfiguration.java",
        "sha256": "1948F12C0B5800341995EC05ECF43A6C4CDE32BA6ED3A5E47C55E6474F9DD475",
        "text": "package com.icerti.platform.poc.config;\r\n\r\nimport java.util.List;\r\nimport java.util.concurrent.Executors;\r\nimport java.util.concurrent.ScheduledExecutorService;\r\n\r\nimport org.slf4j.Logger;\r\nimport org.slf4j.LoggerFactory;\r\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnBean;\r\nimport org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;\r\nimport org.springframework.boot.context.event.ApplicationReadyEvent;\r\nimport org.springframework.boot.context.properties.EnableConfigurationProperties;\r\nimport org.springframework.context.ApplicationListener;\r\nimport org.springframework.context.annotation.Bean;\r\nimport org.springframework.context.annotation.Configuration;\r\nimport org.springframework.context.annotation.Lazy;\r\nimport org.springframework.context.annotation.Profile;\r\nimport org.springframework.beans.factory.ObjectProvider;\r\nimport org.springframework.beans.factory.annotation.Qualifier;\r\n\r\nimport com.fasterxml.jackson.databind.ObjectMapper;\r\nimport com.icerti.platform.poc.dto.security.ManagedAuthContracts.ActorType;\r\nimport com.icerti.platform.poc.repository.aicontrol.AiControlMapper;\r\nimport com.icerti.platform.poc.repository.server.RecRegistrationMapper;\r\nimport com.icerti.platform.poc.repository.camera.CameraMapper;\r\nimport com.icerti.platform.poc.repository.rec.RecStreamingConfigMapper;\r\nimport com.icerti.platform.poc.service.rec.ManagerControlCredentialProvider;\r\nimport com.icerti.platform.poc.service.rec.RecCameraRuntimeStatusPoller;\r\nimport com.icerti.platform.poc.service.rec.RecConnectionManager;\r\nimport com.icerti.platform.poc.service.rec.RecConnectionState;\r\nimport com.icerti.platform.poc.service.rec.RecConnectionManager.RecEndpoint;\r\nimport com.icerti.platform.poc.service.rec.RecControlEndpointObserver;\r\nimport com.icerti.platform.poc.service.rec.RecMessageCodec;\r\nimport com.icerti.platform.poc.service.rec.RecServerLifecycle;\r\nimport com.icerti.platform.poc.service.security.StreamingBindIpPolicy;\r\nimport com.icerti.platform.poc.service.stream.MediaServerClient;\r\nimport com.icerti.platform.poc.service.stream.rec.ManagedRecMediaClient;\r\n\r\n/** Production wiring for the Managed REC control channel. */\r\n@Configuration(proxyBeanMethods = false)\r\n@Profile(\"!test-web\")\r\n@ConditionalOnProperty(name = \"platform.rec.backend\", havingValue = \"managed\", matchIfMissing = true)\r\n@ConditionalOnBean(ManagerControlCredentialProvider.class)\r\n@EnableConfigurationProperties(RecManagedProperties.class)\r\npublic class RecManagedConfiguration {\r\n\r\n    private static final Logger LOGGER = LoggerFactory.getLogger(RecManagedConfiguration.class);\r\n\r\n    @Bean(name = \"recControlScheduler\", destroyMethod = \"shutdownNow\")\r\n    ScheduledExecutorService recControlScheduler(RecManagedProperties properties) {\r\n        return Executors.newScheduledThreadPool(properties.schedulerThreads(), runnable -> {\r\n            Thread thread = new Thread(runnable, \"rec-control\");\r\n            thread.setDaemon(false);\r\n            return thread;\r\n        });\r\n    }\r\n\r\n    @Bean(name = \"recControlTrustProvider\")\r\n    RecControlTrustProvider recControlTrustProvider(RecManagedProperties properties,\r\n            @Lazy AiControlMapper aiControlMapper,\r\n            @org.springframework.beans.factory.annotation.Value(\r\n                    \"${platform.ai-control.ca-file:}\") String aiControlCaFile,\r\n            @org.springframework.beans.factory.annotation.Value(\r\n                    \"${platform.ai-control.manager-ca-cert-file:}\") String managerCaFile,\r\n            @org.springframework.beans.factory.annotation.Value(\r\n                    \"${platform.rec.ca-dir:}\") String recCaDirectory) {\r\n        // The REC control endpoint uses a private CA (decision 2.3), so the default trust store\r\n        // cannot validate it. Trust is the bundled Root CA only. Hostname verification remains\r\n        // the default, with an explicit deployment opt-out for the fixed REC certificate whose\r\n        // SAN does not contain the reachable Tailscale address.\r\n        if (!properties.hostnameVerificationEnabled()) {\r\n            LOGGER.warn(\"media_control_tls_hostname_verification_disabled reason=media_certificate_san_mismatch\");\r\n        }\r\n        // M-B10-01: one managed channel now reaches REC devices and an AI Sidecar, whose\r\n        // certificates come from two different private CAs. Trust becomes the union of the\r\n        // anchors; unset means no AI server in this deployment and REC-only trust, exactly\r\n        // as before.\r\n        if (!aiControlCaFile.isBlank()) {\r\n            LOGGER.info(\"media_control_tls_dual_trust_enabled anchors=rec,ai\");\r\n        }\r\n        // The mapper is injected lazily for the reason DefaultAiConnectionRegistrar states: MyBatis\r\n        // registers mappers after user components are scanned.\r\n        // AICTRL-04: the Sidecar leaf is issued by manager-ca, so that CA is a trust anchor here.\r\n        // A REC deployment may pin anchors of its own: devices are shipped with a per-install root\r\n        // CA, and they all carry the same subject name, so the bundled anchor alone validates\r\n        // exactly one of them. Unset or absent means REC-only trust from the bundled anchor,\r\n        // exactly as before.\r\n        RecControlTrustProvider provider = new RecControlTrustProvider(\r\n                aiControlMapper::findControlCaBundles, aiControlCaFile, managerCaFile,\r\n                recCaDirectory, properties.connectTimeout(),\r\n                properties.hostnameVerificationEnabled());\r\n        // Built once here so that a file anchor that is set but unreadable still fails startup,\r\n        // exactly as it did when this bean was the client itself. Deferring that to the first\r\n        // connection attempt would turn a deployment mistake into a silent inability to connect.\r\n        provider.get();\r\n        return provider;\r\n    }\r\n\r\n    @Bean\r\n    RecMessageCodec recMessageCodec(ObjectMapper objectMapper) {\r\n        return new RecMessageCodec(objectMapper);\r\n    }\r\n\r\n    @Bean\r\n    RecConnectionManager recConnectionManager(\r\n            @Qualifier(\"recControlTrustProvider\") RecControlTrustProvider trustProvider,\r\n            RecMessageCodec codec,\r\n            ManagerControlCredentialProvider credentialProvider,\r\n            @Qualifier(\"recControlScheduler\") ScheduledExecutorService scheduler,\r\n            RecManagedProperties properties,\r\n            ObjectProvider<RecConnectionManager.ReconciliationPort> reconciliationPorts,\r\n            ObjectProvider<RecConnectionManager.AiConfigurationPort> aiConfigurationPort) {\r\n        List<RecConnectionManager.ReconciliationPort> ports = reconciliationPorts.orderedStream().toList();\r\n        if (properties.plaintextLoopbackEnabled()) {\r\n            LOGGER.info(\"media_control_plaintext_loopback_enabled reason=local_media_mock\");\r\n        }\r\n        // Camera configuration is delivered only through HTTP Start/Stop. Keep the legacy\r\n        // constructor seam inert; WSS owns authentication and status only.\r\n        RecConnectionManager manager = new RecConnectionManager(trustProvider, codec, credentialProvider, scheduler,\r\n                properties.requestTimeout(), ports, properties.plaintextLoopbackEnabled(),\r\n                () -> null);\r\n        manager.setRuntimeStatusDelayMs(runtimeStatusDelayMs);\r\n        return manager;\r\n    }\r\n\r\n    @org.springframework.beans.factory.annotation.Value(\"${platform.rec.runtime-status-delay-ms:1000}\")\r\n    private long runtimeStatusDelayMs = 1000;\r\n\r\n    /** 관제 화면의 정상/점검요망 판정에 쓰는 카메라 수신 상태. 조회 주기의 세 배가 지나면 모르는 것으로 본다. */\r\n    @Bean\r\n    RecCameraRuntimeStatusPoller recCameraRuntimeStatusPoller(@Lazy CameraMapper cameraMapper,\r\n            RecConnectionManager connectionManager,\r\n            @org.springframework.beans.factory.annotation.Value(\"${platform.rec.runtime-status.interval-ms:5000}\")\r\n            long intervalMs) {\r\n        return new RecCameraRuntimeStatusPoller(cameraMapper, connectionManager, System::currentTimeMillis,\r\n                intervalMs * 3);\r\n    }\r\n\r\n    @Bean\r\n    RecServerLifecycle recServerLifecycle(RecConnectionManager connectionManager) {\r\n        return new ManagedRecServerLifecycle(connectionManager);\r\n    }\r\n\r\n    /**\r\n     * The managed playback adapter.\r\n     *\r\n     * <p>Deliberately unconditional. It used to carry {@code @ConditionalOnBean} on its\r\n     * collaborators, but two of them are MyBatis mappers registered by a scanner that runs after\r\n     * this condition is evaluated, so the condition never matched: the bean silently disappeared\r\n     * and the application failed to start with \"no MediaServerClient\". A missing collaborator must\r\n     * fail loudly here rather than remove the only playback adapter of the managed backend.</p>\r\n     */\r\n    @Bean\r\n    MediaServerClient managedRecMediaClient(@Lazy CameraMapper cameraMapper,\r\n            @Lazy RecStreamingConfigMapper streamingConfigMapper, RecConnectionManager connectionManager,\r\n            StreamProperties streamProperties, StreamingBindIpPolicy bindIpPolicy) {\r\n        return new ManagedRecMediaClient(cameraMapper, streamingConfigMapper, connectionManager,\r\n                streamProperties, bindIpPolicy);\r\n    }\r\n\r\n    /**\r\n     * Opens the control channel whenever a login leaves the Manager without one.\r\n     *\r\n     * <p>Before this existed the WSS target was frozen at whatever the startup listener below read\r\n     * from the database. A device that came back at another address stayed unreachable until the\r\n     * application was restarted, even though the login had already refreshed\r\n     * {@code SERVER.control_ip} as the specification requires.\r\n     *\r\n     * <p>The decision is \"not ready at this endpoint\", not \"the address changed\". An unchanged\r\n     * address proves nothing about the channel: {@code replace} does not retry, so one failed\r\n     * attempt leaves nothing behind, and the startup listener skips a server whose address was\r\n     * still unknown at boot. A device in that state logs in again -- which is what it does when it\r\n     * reports that the control channel never came up -- and every one of those logins used to be\r\n     * dismissed as \"no change\". Live test of 2026-09-11: two device logins in a row produced no\r\n     * connection attempt at all.\r\n     *\r\n     * <p>{@code replace} is deliberately not joined. The caller is finishing a login that already\r\n     * succeeded, so the reconnect runs on its own and reports failure only to the log.\r\n     */\r\n    @Bean\r\n    RecControlEndpointObserver recControlEndpointObserver(RecConnectionManager connectionManager) {\r\n        return new RecControlEndpointObserver() {\r\n\r\n            @Override\r\n            public void endpointObserved(String serverId, String controlIp, int controlPort) {\r\n                openIfNotReady(connectionManager, new RecEndpoint(serverId, controlIp, controlPort));\r\n            }\r\n\r\n            /**\r\n             * Turns one observation into the endpoint the Manager has to dial.\r\n             *\r\n             * <p>The actor decides where the address comes from, and the actor is read from the\r\n             * observation rather than from anything the request body claimed. A REC device is\r\n             * dialled at the source address of its login, which is what {@code SERVER.control_ip}\r\n             * holds. An AI Sidecar is dialled at the address it advertised and the Manager\r\n             * committed, together with the version that address was published under; substituting\r\n             * the login source address there would hide a misconfigured AI server behind an\r\n             * address that happens to answer.\r\n             */\r\n            @Override\r\n            public void endpointObserved(ControlEndpointObservation observation) {\r\n                RecEndpoint endpoint = observation.actorType() == ActorType.AI\r\n                        ? RecEndpoint.ai(observation.serverId(), observation.advertisedIp(),\r\n                                observation.controlPort(), observation.connectionConfigVersion().longValue())\r\n                        : new RecEndpoint(observation.serverId(), observation.controlIp(),\r\n                                observation.controlPort());\r\n                openIfNotReady(connectionManager, endpoint);\r\n            }\r\n        };\r\n    }\r\n\r\n    /**\r\n     * Opens the channel unless this exact endpoint is already served by a ready one.\r\n     *\r\n     * <p>Two different questions are being asked here, and only asking both gives the right\r\n     * answer. \"Is the address the same?\" says nothing about whether a channel exists:\r\n     * {@code replace} does not retry, so one failed attempt leaves nothing behind, and the startup\r\n     * listener skips a server whose address was still unknown at boot. That is why the test is\r\n     * readiness at this endpoint and not address equality. The other direction matters just as\r\n     * much: a ready channel to this endpoint is left strictly alone, so a server that logs in\r\n     * again with an unchanged address never has a working connection torn down underneath it.\r\n     *\r\n     * <p>{@code replace} is deliberately not joined. The caller is finishing a login that already\r\n     * succeeded, so the reconnect runs on its own and reports failure only to the log.\r\n     */\r\n    private static void openIfNotReady(RecConnectionManager connectionManager, RecEndpoint endpoint) {\r\n        if (connectionManager.readyAt(endpoint)) {\r\n            return;\r\n        }\r\n        connectionManager.replace(endpoint).whenComplete((ignored, failure) -> {\r\n            if (failure != null) {\r\n                LOGGER.warn(\"media_control_endpoint_reconnect_failed serverId={} actorType={} host={} port={}\",\r\n                        endpoint.serverId(), endpoint.actorType(), endpoint.controlIp(),\r\n                        endpoint.controlPort(), failure);\r\n            }\r\n        });\r\n    }\r\n\r\n    @Bean\r\n    ApplicationListener<ApplicationReadyEvent> recManagedConnectionStartup(\r\n            RecRegistrationMapper registrationMapper, RecConnectionManager connectionManager) {\r\n        return event -> {\r\n            List<RecRegistrationMapper.ConnectionEndpoint> endpoints;\r\n            try {\r\n                endpoints = registrationMapper.findEnabledRecConnectionEndpoints();\r\n            } catch (RuntimeException ignored) {\r\n                endpoints = List.of();\r\n            }\r\n            for (RecRegistrationMapper.ConnectionEndpoint endpoint : endpoints) {\r\n                if (endpoint.controlIp() == null || endpoint.controlPort() == null) {\r\n                    continue;\r\n                }\r\n                try {\r\n                    connectionManager.connect(new RecEndpoint(endpoint.serverId(),\r\n                            endpoint.controlIp(), endpoint.controlPort()));\r\n                } catch (RuntimeException ignored) {\r\n                    // A bad or stale endpoint is a runtime connection state, not an application\r\n                    // startup failure. Endpoint policy remains in B-01-05.\r\n                }\r\n            }\r\n\r\n            // An AI server that logged in before the last restart is in exactly the position this\r\n            // listener exists for: its address is durable, nothing observes it again until it logs\r\n            // in of its own accord, and until then the Manager has no channel to it. The two reads\r\n            // are separate so that a failure of one does not cost the other its reconnects.\r\n            List<RecRegistrationMapper.AiConnectionEndpoint> aiEndpoints;\r\n            try {\r\n                aiEndpoints = registrationMapper.findEnabledAiConnectionEndpoints();\r\n            } catch (RuntimeException ignored) {\r\n                aiEndpoints = List.of();\r\n            }\r\n            for (RecRegistrationMapper.AiConnectionEndpoint endpoint : aiEndpoints) {\r\n                if (endpoint.advertisedIp() == null || endpoint.controlPort() == null\r\n                        || endpoint.connectionConfigVersion() == null) {\r\n                    continue;\r\n                }\r\n                try {\r\n                    connectionManager.connect(RecEndpoint.ai(endpoint.serverId(), endpoint.advertisedIp(),\r\n                            endpoint.controlPort(), endpoint.connectionConfigVersion().longValue()));\r\n                } catch (RuntimeException ignored) {\r\n                    // One unusable stored endpoint must not stop the remaining servers from being\r\n                    // dialled, and it is not an application startup failure.\r\n                }\r\n            }\r\n        };\r\n    }\r\n\r\n    static final class ManagedRecServerLifecycle implements RecServerLifecycle {\r\n        private final RecConnectionManager connectionManager;\r\n\r\n        ManagedRecServerLifecycle(RecConnectionManager connectionManager) {\r\n            this.connectionManager = connectionManager;\r\n        }\r\n\r\n        @Override\r\n        public RevocationDelivery revokeTokens(List<String> accountIds, List<RevocationTarget> targets) {\r\n            return RevocationDelivery.none();\r\n        }\r\n\r\n        @Override\r\n        public DecommissionDelivery decommission(DecommissionRequest request) {\r\n            connectionManager.stop(request.serverId());\r\n            return DecommissionDelivery.completed();\r\n        }\r\n\r\n        @Override\r\n        public EndpointChangeDelivery changeEndpoint(EndpointChangeRequest request) {\r\n            if (request.nextEndpoint() == null) {\r\n                connectionManager.stop(request.serverId());\r\n                return EndpointChangeDelivery.completed();\r\n            }\r\n            connectionManager.replace(request.nextEndpoint()).join();\r\n            return EndpointChangeDelivery.completed();\r\n        }\r\n\r\n        @Override\r\n        public EndpointChangeDelivery restoreEndpoint(EndpointChangeRequest request) {\r\n            if (request.nextEndpoint() == null) {\r\n                connectionManager.stop(request.serverId());\r\n                return EndpointChangeDelivery.completed();\r\n            }\r\n            connectionManager.replace(request.nextEndpoint()).join();\r\n            return EndpointChangeDelivery.completed();\r\n        }\r\n\r\n        @Override\r\n        public RecConnectionState state(String serverId) {\r\n            return connectionManager.state(serverId);\r\n        }\r\n    }\r\n}\r\n"
      },
      {
        "path": "src\\main\\java\\com\\icerti\\platform\\poc\\service\\rec\\RecConnectionManager.java",
        "sha256": "2C054D8F89074A191C33BF608E2BA90FA7B9BF2379F9414A1702395D3C0A83D2",
        "text": "package com.icerti.platform.poc.service.rec;\r\n\r\nimport static com.icerti.platform.poc.dto.rec.RecCatalogContracts.CAMERAS_RUNTIME_STATUS_LIST;\r\nimport static com.icerti.platform.poc.dto.rec.RecFoundationContracts.CONTROL_SERVER_STATUS;\r\nimport static com.icerti.platform.poc.dto.rec.RecFoundationContracts.TOKENS_FLUSH_CACHE;\r\nimport static com.icerti.platform.poc.service.aicontrol.AiControlContracts.AI_CONNECTION_INFO_GET;\r\nimport static com.icerti.platform.poc.service.aicontrol.AiControlContracts.AI_RUNTIME_STATUS_GET;\r\n\r\nimport java.io.IOException;\r\nimport java.net.URI;\r\nimport java.net.URISyntaxException;\r\nimport java.net.http.HttpClient;\r\nimport java.net.http.HttpResponse;\r\nimport java.net.http.WebSocket;\r\nimport java.net.http.WebSocketHandshakeException;\r\nimport java.time.Duration;\r\nimport java.util.List;\r\nimport java.util.Locale;\r\nimport java.util.Objects;\r\nimport java.util.concurrent.CompletableFuture;\r\nimport java.util.concurrent.CompletionException;\r\nimport java.util.concurrent.CompletionStage;\r\nimport java.util.concurrent.ConcurrentHashMap;\r\nimport java.util.concurrent.ScheduledExecutorService;\r\nimport java.util.concurrent.ScheduledFuture;\r\nimport java.util.concurrent.TimeUnit;\r\nimport java.util.function.DoubleSupplier;\r\nimport java.util.function.Supplier;\r\n\r\nimport org.slf4j.Logger;\r\nimport org.slf4j.LoggerFactory;\r\n\r\nimport com.fasterxml.jackson.databind.JsonNode;\r\nimport com.fasterxml.jackson.databind.ObjectMapper;\r\nimport com.icerti.platform.poc.dto.rec.RecFoundationContracts.EmptyParams;\r\nimport com.icerti.platform.poc.dto.rec.RecFoundationContracts.RecAction;\r\nimport com.icerti.platform.poc.dto.security.ManagedAuthContracts.ActorType;\r\n\r\n/** Maintains managed REC WSS connections and admits them only after bootstrap synchronization. */\r\npublic final class RecConnectionManager implements AutoCloseable {\r\n    static final long MIN_RECONNECT_DELAY_MS = 500L;\r\n    static final long MAX_RECONNECT_DELAY_MS = 10_000L;\r\n\r\n    /** A context that is not working through a login's retry budget. */\r\n    static final int UNLIMITED_RETRY_BUDGET = -1;\r\n\r\n    /**\r\n     * How many extra attempts a login observation is worth.\r\n     *\r\n     * <p>Sized against the device, measured 2026-09-21. A REC that refuses the first bootstrap call\r\n     * with {@code SERVER_NOT_READY} -- it is still initialising -- logs itself out 10.1s after its\r\n     * own login if no control channel came up by then ({@code ACCESS_TOKEN.revoke_reason='logout'}\r\n     * at 10.065s, 10.102s, 10.180s across many sessions). With the backoff restarted from its floor\r\n     * these four land near 0.5s, 1.6s, 3.7s and 7.9s, so all of them fall inside that window and\r\n     * the fifth would not.\r\n     *\r\n     * <p>The budget exists because the alternative to one-shot is not \"retry forever\". A login says\r\n     * the device is present now; it does not license an endless loop against an endpoint that\r\n     * refuses for a reason repeating will never change. When the budget runs out the candidate is\r\n     * abandoned exactly as it was before.\r\n     */\r\n    static final int LOGIN_RETRY_BUDGET = 4;\r\n\r\n    /**\r\n     * Until this logger existed a failed connection left no trace at all: the failure completes a\r\n     * future that the startup listener never observes, and no production code registers a\r\n     * {@link StateListener}. That is why the customer-facing report of 2026-09-10 could show REC\r\n     * bootstrap calls but not a single line about the control channel being refused.\r\n     */\r\n    private static final Logger LOGGER = LoggerFactory.getLogger(RecConnectionManager.class);\r\n\r\n    /**\r\n     * Reads the handshake rejection body only. It never serializes and never sees a token, so a\r\n     * shared read-only instance is safe here.\r\n     */\r\n    private static final ObjectMapper DIAGNOSTIC_MAPPER = new ObjectMapper();\r\n\r\n    /** Upper bound for any device-supplied text copied into a log line. */\r\n    private static final int MAX_DIAGNOSTIC_TEXT = 512;\r\n\r\n    private final ManagerControlCredentialProvider credentialProvider;\r\n    private final ScheduledExecutorService scheduler;\r\n    private final Duration requestTimeout;\r\n    private final WebSocketConnector connector;\r\n    private final Supplier<RecControlClient> clientFactory;\r\n    private final List<ReconciliationPort> reconciliationPorts;\r\n    private final DoubleSupplier jitterSource;\r\n    private final StateListener stateListener;\r\n    private final boolean plaintextLoopback;\r\n    private final ConcurrentHashMap<String, ConnectionContext> connections = new ConcurrentHashMap<>();\r\n    private volatile boolean closed;\r\n    private volatile long runtimeStatusDelayMs;\r\n\r\n    /** Configures the REC-only pause after token cache initialization. */\r\n    public void setRuntimeStatusDelayMs(long delayMs) {\r\n        if (delayMs < 0) {\r\n            throw new IllegalArgumentException(\"runtimeStatusDelayMs must not be negative\");\r\n        }\r\n        runtimeStatusDelayMs = delayMs;\r\n    }\r\n\r\n    public RecConnectionManager(HttpClient httpClient, RecMessageCodec codec,\r\n            ManagerControlCredentialProvider credentialProvider, ScheduledExecutorService scheduler,\r\n            Duration requestTimeout, List<ReconciliationPort> reconciliationPorts) {\r\n        this(httpClient, codec, credentialProvider, scheduler, requestTimeout, reconciliationPorts, false);\r\n    }\r\n\r\n    /**\r\n     * @param plaintextLoopback {@code platform.rec.plaintext-loopback}. 켜면 loopback 제어 주소에만\r\n     *        {@code ws://} 로 붙는다. TLS 가 없는 로컬 REC 목업용이며 운영에서는 끈다\r\n     */\r\n    public RecConnectionManager(HttpClient httpClient, RecMessageCodec codec,\r\n            ManagerControlCredentialProvider credentialProvider, ScheduledExecutorService scheduler,\r\n            Duration requestTimeout, List<ReconciliationPort> reconciliationPorts, boolean plaintextLoopback) {\r\n        this(httpClient, codec, credentialProvider, scheduler, requestTimeout, reconciliationPorts,\r\n                plaintextLoopback, () -> null);\r\n    }\r\n\r\n    /**\r\n     * @param aiConfigurationPort read once per AI candidate, after its bootstrap actions have\r\n     *        succeeded. Supplying {@code null} means no configuration is delivered on connect,\r\n     *        which is a wiring state and not a failed bootstrap\r\n     */\r\n    public RecConnectionManager(HttpClient httpClient, RecMessageCodec codec,\r\n            ManagerControlCredentialProvider credentialProvider, ScheduledExecutorService scheduler,\r\n            Duration requestTimeout, List<ReconciliationPort> reconciliationPorts, boolean plaintextLoopback,\r\n            Supplier<AiConfigurationPort> aiConfigurationPort) {\r\n        this(fixedClient(httpClient), codec, credentialProvider, scheduler, requestTimeout,\r\n                reconciliationPorts, plaintextLoopback, aiConfigurationPort);\r\n    }\r\n\r\n    /**\r\n     * @param httpClientSource read once per connection attempt rather than captured, for the same\r\n     *        reason {@code aiConfigurationPort} is: an AI server publishes the trust anchor for its\r\n     *        own control endpoint when it logs in, which is after this manager was built. A client\r\n     *        fixed at construction could never carry that anchor, so the first dial to a newly\r\n     *        registered AI server would fail and keep failing until the Manager was restarted\r\n     */\r\n    public RecConnectionManager(Supplier<HttpClient> httpClientSource, RecMessageCodec codec,\r\n            ManagerControlCredentialProvider credentialProvider, ScheduledExecutorService scheduler,\r\n            Duration requestTimeout, List<ReconciliationPort> reconciliationPorts, boolean plaintextLoopback,\r\n            Supplier<AiConfigurationPort> aiConfigurationPort) {\r\n        this(credentialProvider, scheduler, requestTimeout,\r\n                (uri, token, listener) -> httpClientSource.get().newWebSocketBuilder()\r\n                        .header(\"Authorization\", \"Bearer \" + token)\r\n                        .connectTimeout(requestTimeout)\r\n                        .buildAsync(uri, listener),\r\n                () -> new RecControlClient(codec, scheduler), reconciliationPorts,\r\n                Math::random, (serverId, state) -> { }, plaintextLoopback, aiConfigurationPort);\r\n        Objects.requireNonNull(httpClientSource, \"httpClientSource\");\r\n        Objects.requireNonNull(codec, \"codec\");\r\n    }\r\n\r\n    /**\r\n     * Wraps a fixed client so the pre-existing constructors keep their exact behaviour.\r\n     *\r\n     * <p>The null check happens here rather than after the delegating call, because the supplier\r\n     * is dereferenced lazily and a null client would otherwise surface at the first connection\r\n     * attempt instead of at construction.\r\n     */\r\n    private static Supplier<HttpClient> fixedClient(HttpClient httpClient) {\r\n        Objects.requireNonNull(httpClient, \"httpClient\");\r\n        return () -> httpClient;\r\n    }\r\n\r\n    RecConnectionManager(ManagerControlCredentialProvider credentialProvider,\r\n            ScheduledExecutorService scheduler, Duration requestTimeout, WebSocketConnector connector,\r\n            Supplier<RecControlClient> clientFactory, List<ReconciliationPort> reconciliationPorts,\r\n            DoubleSupplier jitterSource, StateListener stateListener) {\r\n        this(credentialProvider, scheduler, requestTimeout, connector, clientFactory, reconciliationPorts,\r\n                jitterSource, stateListener, false);\r\n    }\r\n\r\n    RecConnectionManager(ManagerControlCredentialProvider credentialProvider,\r\n            ScheduledExecutorService scheduler, Duration requestTimeout, WebSocketConnector connector,\r\n            Supplier<RecControlClient> clientFactory, List<ReconciliationPort> reconciliationPorts,\r\n            DoubleSupplier jitterSource, StateListener stateListener, boolean plaintextLoopback) {\r\n        this(credentialProvider, scheduler, requestTimeout, connector, clientFactory, reconciliationPorts,\r\n                jitterSource, stateListener, plaintextLoopback, () -> null);\r\n    }\r\n\r\n    RecConnectionManager(ManagerControlCredentialProvider credentialProvider,\r\n            ScheduledExecutorService scheduler, Duration requestTimeout, WebSocketConnector connector,\r\n            Supplier<RecControlClient> clientFactory, List<ReconciliationPort> reconciliationPorts,\r\n            DoubleSupplier jitterSource, StateListener stateListener, boolean plaintextLoopback,\r\n            Supplier<AiConfigurationPort> aiConfigurationPort) {\r\n        Objects.requireNonNull(aiConfigurationPort, \"aiConfigurationPort\");\r\n        this.plaintextLoopback = plaintextLoopback;\r\n        this.credentialProvider = Objects.requireNonNull(credentialProvider, \"credentialProvider\");\r\n        this.scheduler = Objects.requireNonNull(scheduler, \"scheduler\");\r\n        this.requestTimeout = requirePositive(requestTimeout, \"requestTimeout\");\r\n        this.connector = Objects.requireNonNull(connector, \"connector\");\r\n        this.clientFactory = Objects.requireNonNull(clientFactory, \"clientFactory\");\r\n        this.reconciliationPorts = List.copyOf(Objects.requireNonNull(reconciliationPorts, \"reconciliationPorts\"));\r\n        this.reconciliationPorts.forEach(port -> Objects.requireNonNull(port, \"reconciliationPort\"));\r\n        this.jitterSource = Objects.requireNonNull(jitterSource, \"jitterSource\");\r\n        this.stateListener = Objects.requireNonNull(stateListener, \"stateListener\");\r\n    }\r\n\r\n    public CompletableFuture<Void> connect(RecEndpoint endpoint) {\r\n        Objects.requireNonNull(endpoint, \"endpoint\");\r\n        if (closed) {\r\n            return CompletableFuture.failedFuture(new IllegalStateException(\"REC_CONNECTION_MANAGER_CLOSED\"));\r\n        }\r\n        ConnectionContext created = new ConnectionContext(endpoint);\r\n        ConnectionContext existing = connections.putIfAbsent(endpoint.serverId(), created);\r\n        if (existing != null) {\r\n            synchronized (existing) {\r\n                if (!existing.hasEndpoint(endpoint)) {\r\n                    return CompletableFuture.failedFuture(new IllegalStateException(\"REC_ENDPOINT_CONFLICT\"));\r\n                }\r\n                return existing.firstReady;\r\n            }\r\n        }\r\n        beginCandidate(created, endpoint, created.firstReady);\r\n        return created.firstReady;\r\n    }\r\n\r\n    /**\r\n     * Starts a replacement connection without interrupting a usable active connection. The\r\n     * caller owns endpoint policy; this method deliberately does not perform collision or\r\n     * resource checks.\r\n     */\r\n    public CompletableFuture<Void> replace(RecEndpoint endpoint) {\r\n        Objects.requireNonNull(endpoint, \"endpoint\");\r\n        if (closed) {\r\n            return CompletableFuture.failedFuture(new IllegalStateException(\"REC_CONNECTION_MANAGER_CLOSED\"));\r\n        }\r\n\r\n        ConnectionContext context = connections.get(endpoint.serverId());\r\n        if (context == null) {\r\n            ConnectionContext created = new ConnectionContext(endpoint);\r\n            ConnectionContext existing = connections.putIfAbsent(endpoint.serverId(), created);\r\n            if (existing == null) {\r\n                synchronized (created) {\r\n                    // Same budget as the branch below: a login this Manager had never seen before\r\n                    // is still a login, and the device it came from is initialising just the same.\r\n                    created.candidateRetryAllowed = true;\r\n                    created.candidateRetryBudget = LOGIN_RETRY_BUDGET;\r\n                }\r\n                beginCandidate(created, endpoint, created.firstReady);\r\n                return created.firstReady;\r\n            }\r\n            context = existing;\r\n        }\r\n\r\n        RecControlClient previousCandidate;\r\n        CompletableFuture<Void> replacementReady;\r\n        CompletableFuture<Void> pendingReady = null;\r\n        synchronized (context) {\r\n            if (context.stopped) {\r\n                return CompletableFuture.failedFuture(new IllegalStateException(\"REC_CONNECTION_STOPPED\"));\r\n            }\r\n            if (context.candidateEndpoint != null && context.candidateEndpoint.equals(endpoint)\r\n                    && context.candidateReady != null) {\r\n                // A candidate for this same endpoint is already in flight or waiting on a timer,\r\n                // so no second one is started. The login still counts, though, and this branch\r\n                // used to return without registering it at all -- which is why a device stuck in\r\n                // the retry loop never saw the effect of a login. Measured 2026-09-21: logins at\r\n                // 03:24:32.525 and 03:25:06.110 produced no dial of their own, and the attempt\r\n                // that did land came from the old 10s backoff, 5.68s and 2.16s later. The device\r\n                // stops waiting 10.1s after its login, so where that attempt falls decides\r\n                // everything.\r\n                context.candidateRetryAllowed = true;\r\n                context.candidateRetryBudget = LOGIN_RETRY_BUDGET;\r\n                context.reconnectAttempt = 0;\r\n                context.lastReconnectDelayMs = 0L;\r\n                pendingReady = context.candidateReady;\r\n                if (context.reconnectPending) {\r\n                    // The waiting retry is moved earlier rather than a second attempt started.\r\n                    // No new attempt is created, so repeated observations still do not stack\r\n                    // reconnects; only the moment the one pending attempt happens changes, from\r\n                    // wherever the old backoff had put it to the floor.\r\n                    cancelReconnect(context);\r\n                    scheduleReconnectLocked(context);\r\n                }\r\n            }\r\n        }\r\n        if (pendingReady != null) {\r\n            return pendingReady;\r\n        }\r\n\r\n        synchronized (context) {\r\n            if (context.stopped) {\r\n                return CompletableFuture.failedFuture(new IllegalStateException(\"REC_CONNECTION_STOPPED\"));\r\n            }\r\n            cancelReconnect(context);\r\n            previousCandidate = context.candidateClient;\r\n            completeSupersededCandidate(context);\r\n            context.candidateClient = null;\r\n            context.candidateState = RecConnectionState.DISCONNECTED;\r\n            context.candidateEndpoint = endpoint;\r\n            context.candidateGeneration = 0L;\r\n            // A login is worth a bounded number of attempts, not one and not endlessly many. One\r\n            // was too few: a device still initialising refuses the first bootstrap call and then\r\n            // logs itself out ~10.1s later, so a single attempt never met a ready device. The\r\n            // budget is spent only on refusals the device marked retryable; see LOGIN_RETRY_BUDGET.\r\n            context.candidateRetryAllowed = true;\r\n            context.candidateRetryBudget = LOGIN_RETRY_BUDGET;\r\n            // The backoff describes a peer that was not answering. This call says a peer just\r\n            // logged in, which is new evidence about a different situation, so the accumulated\r\n            // delay no longer describes anything true and is discarded.\r\n            //\r\n            // Keeping it was a defect, measured against the customer device on 2026-09-21. A\r\n            // device that refuses the first bootstrap call with SERVER_NOT_READY -- it is still\r\n            // initialising -- logs itself out 10.1s after its login if no control channel came up\r\n            // (ACCESS_TOKEN: revoke_reason='logout', 10.065s / 10.102s / 10.180s ...). A server\r\n            // that had been failing long enough to reach MAX_RECONNECT_DELAY_MS got its next\r\n            // attempt scheduled 10s out, so the retry landed at or after that deadline and the\r\n            // device never got a second chance inside its own window. From zero the retries fall\r\n            // at roughly 0.5s, 1.6s, 3.7s and 7.9s, four of them inside 10.1s.\r\n            //\r\n            // This cannot spin: the caller only reaches replace() when the channel is not ready\r\n            // at this endpoint, and a device that logs in repeatedly is already rate-limited by\r\n            // its own login interval.\r\n            context.reconnectAttempt = 0;\r\n            context.lastReconnectDelayMs = 0L;\r\n            replacementReady = new CompletableFuture<>();\r\n            context.candidateReady = replacementReady;\r\n        }\r\n        closeClient(previousCandidate);\r\n        beginCandidate(context, endpoint, replacementReady);\r\n        return replacementReady;\r\n    }\r\n\r\n    public RecConnectionState state(String serverId) {\r\n        ConnectionContext context = connections.get(requireText(serverId, \"serverId\"));\r\n        if (context == null) {\r\n            return RecConnectionState.DISCONNECTED;\r\n        }\r\n        synchronized (context) {\r\n            return reportedState(context);\r\n        }\r\n    }\r\n\r\n    /**\r\n     * Tells whether this server is already served by a usable connection to exactly this endpoint.\r\n     *\r\n     * <p>Exists for callers that react to a login: an unchanged address is not on its own a reason\r\n     * to do nothing. A connection may have failed earlier and left nothing behind -- {@link\r\n     * #replace(RecEndpoint)} does not retry -- and the startup listener skips a server whose\r\n     * address was still unknown when the application came up. Both cases look like \"no change\" at\r\n     * the next login while there is in fact no channel at all.\r\n     *\r\n     * @return {@code true} only when the connection is {@link RecConnectionState#READY} and bound\r\n     *         to this exact endpoint\r\n     */\r\n    public boolean readyAt(RecEndpoint endpoint) {\r\n        Objects.requireNonNull(endpoint, \"endpoint\");\r\n        ConnectionContext context = connections.get(endpoint.serverId());\r\n        if (context == null) {\r\n            return false;\r\n        }\r\n        synchronized (context) {\r\n            return reportedState(context) == RecConnectionState.READY\r\n                    && endpoint.equals(context.activeEndpoint);\r\n        }\r\n    }\r\n\r\n    public <P, R> CompletableFuture<R> execute(String serverId, RecAction<P, R> action, P params) {\r\n        ConnectionContext context = connections.get(requireText(serverId, \"serverId\"));\r\n        RecControlClient activeClient;\r\n        synchronized (context == null ? this : context) {\r\n            activeClient = context == null ? null : usableActiveClient(context);\r\n        }\r\n        if (activeClient == null) {\r\n            return CompletableFuture.failedFuture(new RecProtocolException(\"REC_CONNECTION_NOT_READY\"));\r\n        }\r\n        return activeClient.send(action, params, requestTimeout);\r\n    }\r\n\r\n    /**\r\n     * Sends a REC write through the server-scoped write lane. Reads continue to use\r\n     * {@link #execute(String, RecAction, Object)} directly, while every operation that changes REC\r\n     * state can share this lane with the future mutation dispatcher.\r\n     */\r\n    public <P, R> CompletableFuture<R> executeWrite(String serverId, RecAction<P, R> action, P params) {\r\n        String normalizedServerId = requireText(serverId, \"serverId\");\r\n        ConnectionContext context = connections.get(normalizedServerId);\r\n        if (context == null) {\r\n            return CompletableFuture.failedFuture(new RecProtocolException(\"REC_CONNECTION_NOT_READY\"));\r\n        }\r\n        return submitWrite(context, () -> {\r\n            RecControlClient activeClient;\r\n            synchronized (context) {\r\n                activeClient = usableActiveClient(context);\r\n            }\r\n            if (activeClient == null) {\r\n                return CompletableFuture.failedFuture(new RecProtocolException(\"REC_CONNECTION_NOT_READY\"));\r\n            }\r\n            return activeClient.send(action, params, requestTimeout);\r\n        });\r\n    }\r\n\r\n    /**\r\n     * Candidate synchronization uses the supplied client directly, but still enters the same\r\n     * server-scoped lane as active writes. This avoids the READY self-dependency of\r\n     * {@link #execute(String, RecAction, Object)} while keeping candidate and active writes\r\n     * serialized.\r\n     */\r\n    public <P, R> CompletableFuture<R> executeWrite(String serverId, RecControlClient client,\r\n            RecAction<P, R> action, P params) {\r\n        Objects.requireNonNull(client, \"client\");\r\n        String normalizedServerId = requireText(serverId, \"serverId\");\r\n        ConnectionContext context = connections.get(normalizedServerId);\r\n        if (context == null) {\r\n            return CompletableFuture.failedFuture(new RecProtocolException(\"REC_CONNECTION_NOT_READY\"));\r\n        }\r\n        return submitWrite(context, () -> {\r\n            synchronized (context) {\r\n                if (context.activeClient != client && context.candidateClient != client) {\r\n                    return CompletableFuture.failedFuture(new RecProtocolException(\"REC_CONNECTION_GENERATION_STALE\"));\r\n                }\r\n            }\r\n            return client.send(action, params, requestTimeout);\r\n        });\r\n    }\r\n\r\n    /**\r\n     * Reserves the server-scoped write lane for a whole multi-step operation instead of one action.\r\n     *\r\n     * <p>A REC mutation needs its actual preflight, its write and its read-back to stay one\r\n     * indivisible unit; issuing them as three separate {@link #executeWrite} calls would let another\r\n     * write of the same server interleave. The lease enters the same lane as every other write, so\r\n     * Q102 single-flight still holds across mutation, streaming and candidate synchronization.</p>\r\n     *\r\n     * <p>The returned lease is pinned to the connection generation that was active when it was\r\n     * granted. Every send through it fails with {@code REC_CONNECTION_GENERATION_STALE} once that\r\n     * generation is gone, which is what stops Q92 replay onto a new connection.</p>\r\n     *\r\n     * <p>The caller must {@link RecWriteLease#close() close} the lease; the lane stays occupied\r\n     * until it does.</p>\r\n     */\r\n    public CompletableFuture<RecWriteLease> acquireWriteLease(String serverId) {\r\n        ConnectionContext context = connections.get(requireText(serverId, \"serverId\"));\r\n        if (context == null) {\r\n            return CompletableFuture.failedFuture(new RecProtocolException(\"REC_CONNECTION_NOT_READY\"));\r\n        }\r\n        RecWriteLeaseFuture acquired = new RecWriteLeaseFuture();\r\n        CompletableFuture<Void> released = new CompletableFuture<>();\r\n        CompletableFuture<Void> lane = submitWrite(context, () -> {\r\n            RecControlClient activeClient;\r\n            long generation;\r\n            synchronized (context) {\r\n                activeClient = usableActiveClient(context);\r\n                generation = context.activeGeneration;\r\n            }\r\n            if (activeClient == null) {\r\n                acquired.completeExceptionally(new RecProtocolException(\"REC_CONNECTION_NOT_READY\"));\r\n                return CompletableFuture.completedFuture(null);\r\n            }\r\n            if (!acquired.grant(new RecWriteLease(this, context, activeClient, generation, released))) {\r\n                // The caller already gave up on the lease; free the lane immediately.\r\n                return CompletableFuture.completedFuture(null);\r\n            }\r\n            return released;\r\n        });\r\n        lane.whenComplete((ignored, failure) -> {\r\n            if (!acquired.isDone()) {\r\n                acquired.completeExceptionally(failure == null\r\n                        ? new RecProtocolException(\"REC_CONNECTION_NOT_READY\") : unwrap(failure));\r\n            }\r\n        });\r\n        return acquired;\r\n    }\r\n\r\n    public void stop(String serverId) {\r\n        String normalizedServerId = requireText(serverId, \"serverId\");\r\n        ConnectionContext context = connections.remove(normalizedServerId);\r\n        if (context == null) {\r\n            return;\r\n        }\r\n        RecControlClient activeClient;\r\n        RecControlClient candidateClient;\r\n        synchronized (context) {\r\n            context.stopped = true;\r\n            cancelReconnect(context);\r\n            activeClient = context.activeClient;\r\n            candidateClient = context.candidateClient;\r\n            context.activeClient = null;\r\n            context.activeEndpoint = null;\r\n            context.activeGeneration = 0L;\r\n            context.activeState = RecConnectionState.DISCONNECTED;\r\n            context.candidateClient = null;\r\n            context.candidateEndpoint = null;\r\n            context.candidateGeneration = 0L;\r\n            context.candidateState = RecConnectionState.DISCONNECTED;\r\n            context.candidateRetryAllowed = false;\r\n            publishState(context);\r\n            if (!context.firstReady.isDone()) {\r\n                context.firstReady.completeExceptionally(new RecProtocolException(\"REC_CONNECTION_STOPPED\"));\r\n            }\r\n            completeSupersededCandidate(context);\r\n        }\r\n        closeClient(candidateClient);\r\n        closeClient(activeClient);\r\n    }\r\n\r\n    @Override\r\n    public void close() {\r\n        closed = true;\r\n        List.copyOf(connections.keySet()).forEach(this::stop);\r\n    }\r\n\r\n    long lastReconnectDelayMs(String serverId) {\r\n        ConnectionContext context = connections.get(serverId);\r\n        return context == null ? 0L : context.lastReconnectDelayMs;\r\n    }\r\n\r\n    private void beginCandidate(ConnectionContext context, RecEndpoint endpoint,\r\n            CompletableFuture<Void> readyFuture) {\r\n        long generation;\r\n        synchronized (context) {\r\n            if (closed || context.stopped) {\r\n                return;\r\n            }\r\n            if (context.candidateEndpoint == null) {\r\n                context.candidateEndpoint = endpoint;\r\n            }\r\n            if (!endpoint.equals(context.candidateEndpoint)) {\r\n                return;\r\n            }\r\n            generation = ++context.generationSequence;\r\n            context.candidateGeneration = generation;\r\n            context.candidateClient = null;\r\n            context.candidateState = RecConnectionState.CONNECTING;\r\n            publishState(context);\r\n        }\r\n\r\n        final String token;\r\n        try {\r\n            token = requireText(credentialProvider.getRequiredToken(context.serverId), \"token\");\r\n        } catch (RuntimeException exception) {\r\n            candidateFailed(context, null, generation, readyFuture,\r\n                    new RecProtocolException(\"REC_CONTROL_CREDENTIAL_UNAVAILABLE\", exception));\r\n            return;\r\n        }\r\n\r\n        final RecControlClient client;\r\n        try {\r\n            client = Objects.requireNonNull(clientFactory.get(), \"clientFactory result\");\r\n        } catch (RuntimeException exception) {\r\n            candidateFailed(context, null, generation, readyFuture,\r\n                    new RecProtocolException(\"REC_CONTROL_CLIENT_CREATION_FAILED\", exception));\r\n            return;\r\n        }\r\n        synchronized (context) {\r\n            if (!isCandidateGeneration(context, generation)) {\r\n                closeClient(client);\r\n                return;\r\n            }\r\n            context.candidateClient = client;\r\n            context.candidateState = RecConnectionState.AUTHENTICATING;\r\n            publishState(context);\r\n        }\r\n\r\n        WebSocket.Listener listener = client.listenerFor(generation,\r\n                (disconnectedGeneration, cause) -> handleDisconnect(\r\n                        context, client, disconnectedGeneration, cause, readyFuture));\r\n        URI dialUri = endpoint.connectUri(plaintextLoopback);\r\n        connector.connect(dialUri, token, listener).whenComplete((webSocket, throwable) -> {\r\n            if (throwable != null) {\r\n                Throwable cause = unwrap(throwable);\r\n                String detail = describeHandshakeFailure(cause);\r\n                LOGGER.warn(\"media_control_upgrade_failed serverId={} uri={} {}\", context.serverId,\r\n                        dialUri, detail.isEmpty() ? \"detail=none\" : detail, cause);\r\n                candidateFailed(context, client, generation, readyFuture,\r\n                        new RecProtocolException(detail.isEmpty()\r\n                                ? \"REC_WEBSOCKET_UPGRADE_FAILED\"\r\n                                : \"REC_WEBSOCKET_UPGRADE_FAILED \" + detail, cause));\r\n                return;\r\n            }\r\n            LOGGER.info(\"media_control_upgrade_succeeded serverId={} uri={}\", context.serverId, dialUri);\r\n            beginSynchronization(context, endpoint, generation, client, readyFuture);\r\n        });\r\n    }\r\n\r\n    private void beginSynchronization(ConnectionContext context, RecEndpoint endpoint, long generation,\r\n            RecControlClient client, CompletableFuture<Void> readyFuture) {\r\n        synchronized (context) {\r\n            if (!isCandidate(context, generation, client) || client.activeGeneration() != generation) {\r\n                closeClient(client);\r\n                return;\r\n            }\r\n            context.candidateState = RecConnectionState.SYNCING;\r\n            publishState(context);\r\n        }\r\n\r\n        CompletableFuture<Void> synchronization = endpoint.actorType() == ActorType.AI\r\n                ? aiSynchronization(context, client)\r\n                : recSynchronization(context, client);\r\n        synchronization.whenComplete((ignored, throwable) -> {\r\n            if (throwable != null) {\r\n                Throwable cause = unwrap(throwable);\r\n                String detail = synchronizationFailureDetail(cause);\r\n                String stage = context.syncStage;\r\n                LOGGER.warn(\"media_control_sync_failed serverId={} uri={} stage={} causeType={} detail={}\",\r\n                        context.serverId, endpointUri(context),\r\n                        stage == null ? \"unknown\" : stage, cause.getClass().getSimpleName(),\r\n                        detail == null ? \"none\" : sanitizeDiagnosticText(detail));\r\n                candidateFailed(context, client, generation, readyFuture,\r\n                        new RecProtocolException(\"REC_BOOTSTRAP_SYNC_FAILED\", cause));\r\n                return;\r\n            }\r\n            LOGGER.info(\"media_control_sync_succeeded serverId={} uri={}\", context.serverId, endpointUri(context));\r\n            promoteCandidate(context, generation, client, readyFuture);\r\n        });\r\n    }\r\n\r\n    /**\r\n     * The REC bootstrap chain, unchanged.\r\n     *\r\n     * <p>The customer specification requires control.server.status followed by a camera\r\n     * re-synchronization (cameras.runtimeStatus.list) after every reconnect.\r\n     *\r\n     * <p>tokens.flushCache between them is required too, though the specification states it\r\n     * indirectly (Streaming Server API 3.5): the device caches introspection results \"in a\r\n     * process-wide cache\", never expires them because the tokens have no expiry, and only\r\n     * re-asks for a token it has not seen. The Manager rotates its own service token on every\r\n     * startup, so without this call the device keeps an entry for a token the Manager has\r\n     * already revoked. An earlier comment here called it our own policy; it is not optional.\r\n     *\r\n     * <p>The reconciliation ports are part of this chain and not of the AI one: every registered\r\n     * port reconciles REC state through the REC action catalogue.\r\n     */\r\n    private CompletableFuture<Void> recSynchronization(ConnectionContext context, RecControlClient client) {\r\n        CompletableFuture<Void> synchronization =\r\n                sendTracked(context, client, CONTROL_SERVER_STATUS)\r\n                .thenCompose(ignored -> sendTracked(context, client, TOKENS_FLUSH_CACHE))\r\n                .thenCompose(ignored -> delayRuntimeStatus(context, client))\r\n                .thenCompose(ignored -> sendTracked(context, client, CAMERAS_RUNTIME_STATUS_LIST))\r\n                .thenApply(ignored -> null);\r\n        for (ReconciliationPort port : reconciliationPorts) {\r\n                synchronization = synchronization.thenCompose(ignored -> {\r\n                    context.syncStage = port.getClass().getSimpleName();\r\n                    return toFuture(port.synchronize(context.serverId, client));\r\n                });\r\n        }\r\n        return synchronization;\r\n    }\r\n\r\n    private CompletableFuture<Void> delayRuntimeStatus(ConnectionContext context, RecControlClient client) {\r\n        long delayMs = runtimeStatusDelayMs;\r\n        if (delayMs == 0) {\r\n            return CompletableFuture.completedFuture(null);\r\n        }\r\n        CompletableFuture<Void> delay = new CompletableFuture<>();\r\n        synchronized (context) {\r\n            long generation = context.candidateGeneration;\r\n            context.syncStage = \"cameras.runtimeStatus.list.delay\";\r\n            ScheduledFuture<?> scheduled = scheduler.schedule(() -> {\r\n                synchronized (context) {\r\n                    if (closed || context.stopped || !isCandidate(context, generation, client)) {\r\n                        delay.completeExceptionally(new RecProtocolException(\"REC_CANDIDATE_REPLACED\"));\r\n                    } else {\r\n                        delay.complete(null);\r\n                    }\r\n                }\r\n            }, delayMs, TimeUnit.MILLISECONDS);\r\n            delay.whenComplete((ignored, failure) -> scheduled.cancel(false));\r\n            if (context.candidateReady != null) {\r\n                context.candidateReady.whenComplete((ignored, failure) -> delay.cancel(false));\r\n            }\r\n        }\r\n        return delay;\r\n    }\r\n\r\n    /**\r\n     * The AI bootstrap chain: exactly four actions, strictly in this order, then the AI port.\r\n     *\r\n     * <p>The order is the contract and not an implementation detail.\r\n     * {@code control.server.status} first, because a Sidecar that cannot describe its own channel\r\n     * has nothing else worth asking. {@code tokens.flushCache} second, for the same reason it sits\r\n     * in the REC chain: the Manager rotates its service token on every startup and the peer caches\r\n     * introspection results indefinitely, so a Sidecar asked anything before the flush may answer\r\n     * against a token the Manager has already revoked. Only then are the two AI-only reads made,\r\n     * runtime status before connection info, and each one waits for the previous answer -- these\r\n     * are sequential {@code thenCompose} steps and not a joined set, so a failure at any step\r\n     * leaves the remaining actions unsent.\r\n     *\r\n     * <p>Every step is a typed action of the shared codec, so a malformed envelope, a wrong\r\n     * requestId, an {@code ok=false} or a result that does not decode arrives here as a failed\r\n     * future. That failure is returned as a failure: {@link #beginSynchronization} turns it into\r\n     * {@code candidateFailed}, which leaves any working connection alone and schedules the usual\r\n     * backoff. Nothing in this chain converts a protocol failure into a completed one.\r\n     *\r\n     * <p>The REC reconciliation ports are deliberately absent -- every one of them reconciles REC\r\n     * state through the REC action catalogue -- and so is {@code ai.configuration.apply}, which is\r\n     * retired. Camera commands use the HTTP Start/Stop endpoints.\r\n     */\r\n    private CompletableFuture<Void> aiSynchronization(ConnectionContext context, RecControlClient client) {\r\n        return sendTracked(context, client, CONTROL_SERVER_STATUS)\r\n                .thenCompose(ignored -> sendTracked(context, client, TOKENS_FLUSH_CACHE))\r\n                .thenCompose(ignored -> sendTracked(context, client, AI_RUNTIME_STATUS_GET))\r\n                .thenCompose(ignored -> sendTracked(context, client, AI_CONNECTION_INFO_GET))\r\n                .thenApply(ignored -> null);\r\n    }\r\n\r\n    /**\r\n     * Sends one bootstrap call and records which one is in flight.\r\n     *\r\n     * <p>{@code media_control_sync_failed} used to name the code the device returned but not the\r\n     * call that returned it, and the two lead to different conclusions: the chain opens with\r\n     * {@code control.server.status}, so a rejection there says the device is not ready to talk at\r\n     * all, while the same rejection from {@code cameras.runtimeStatus.list} says it is talking and\r\n     * something about the cameras is wrong. The customer's reproduction procedure asks for exactly\r\n     * this distinction, and reading it out of a stack trace was not possible -- the chain is\r\n     * asynchronous and every stage unwinds through the same frames.\r\n     *\r\n     * <p>The field is written rather than threaded through the future because the alternative is\r\n     * to wrap each stage's failure, which would rewrite the exception the caller classifies. Only\r\n     * one synchronization runs per context at a time, and the value is read on that same chain's\r\n     * failure, so the last write is the stage that failed.\r\n     */\r\n    private <R> CompletableFuture<R> sendTracked(ConnectionContext context, RecControlClient client,\r\n            RecAction<EmptyParams, R> action) {\r\n        context.syncStage = action.action();\r\n        return client.send(action, new EmptyParams(), requestTimeout);\r\n    }\r\n\r\n    private static URI endpointUri(ConnectionContext context) {\r\n        synchronized (context) {\r\n            if (context.candidateEndpoint == null) {\r\n                return null;\r\n            }\r\n            return context.candidateEndpoint.uri();\r\n        }\r\n    }\r\n\r\n    private void promoteCandidate(ConnectionContext context, long generation, RecControlClient client,\r\n            CompletableFuture<Void> readyFuture) {\r\n        RecControlClient oldActive;\r\n        URI promotedUri;\r\n        synchronized (context) {\r\n            if (!isCandidate(context, generation, client) || client.activeGeneration() != generation) {\r\n                return;\r\n            }\r\n            oldActive = context.activeClient;\r\n            context.activeClient = client;\r\n            context.activeEndpoint = context.candidateEndpoint;\r\n            context.activeGeneration = generation;\r\n            context.activeState = RecConnectionState.READY;\r\n            context.candidateClient = null;\r\n            context.candidateEndpoint = null;\r\n            context.candidateGeneration = 0L;\r\n            context.candidateState = RecConnectionState.DISCONNECTED;\r\n            context.candidateReady = null;\r\n            context.candidateRetryAllowed = false;\r\n            context.candidateRetryBudget = UNLIMITED_RETRY_BUDGET;\r\n            context.reconnectAttempt = 0;\r\n            context.lastReconnectDelayMs = 0L;\r\n            promotedUri = context.activeEndpoint.uri();\r\n            publishState(context);\r\n        }\r\n\r\n        // Q91: the new client is active before the old client is closed.\r\n        closeClient(oldActive);\r\n        LOGGER.info(\"media_control_ready serverId={} uri={}\", context.serverId, promotedUri);\r\n        synchronized (context) {\r\n            if (context.activeClient == client && context.activeGeneration == generation) {\r\n                context.firstReady.complete(null);\r\n                readyFuture.complete(null);\r\n            }\r\n        }\r\n    }\r\n\r\n    private void candidateFailed(ConnectionContext context, RecControlClient client, long generation,\r\n            CompletableFuture<Void> readyFuture, Throwable cause) {\r\n        RecControlClient candidateToClose;\r\n        boolean retry;\r\n        synchronized (context) {\r\n            if (!isCandidateGeneration(context, generation)\r\n                    || (client != null && context.candidateClient != client)) {\r\n                return;\r\n            }\r\n            candidateToClose = context.candidateClient;\r\n            context.candidateClient = null;\r\n            context.candidateGeneration = 0L;\r\n            context.candidateState = RecConnectionState.DISCONNECTED;\r\n            retry = !hasUsableActive(context) && !closed && !context.stopped;\r\n            retry = retry && context.candidateRetryAllowed;\r\n            if (retry && context.candidateRetryBudget != UNLIMITED_RETRY_BUDGET) {\r\n                // A login's attempts are counted, and only the device may extend them. Spending a\r\n                // budget entry on a refusal the device called final would just re-ask a settled\r\n                // question, and spending one on a transport error would retry an endpoint that\r\n                // said nothing at all.\r\n                retry = context.candidateRetryBudget > 0 && deviceAskedToRetry(cause);\r\n                if (retry) {\r\n                    context.candidateRetryBudget--;\r\n                }\r\n            }\r\n            if (retry) {\r\n                context.candidateReady = readyFuture;\r\n            } else {\r\n                context.candidateEndpoint = null;\r\n                context.candidateReady = null;\r\n                context.candidateRetryAllowed = false;\r\n                context.candidateRetryBudget = UNLIMITED_RETRY_BUDGET;\r\n                readyFuture.completeExceptionally(cause);\r\n            }\r\n            publishState(context);\r\n            if (retry) {\r\n                scheduleReconnectLocked(context);\r\n            }\r\n        }\r\n        closeClient(candidateToClose == null ? client : candidateToClose);\r\n    }\r\n\r\n    /**\r\n     * Whether the device itself asked for the identical request to be repeated.\r\n     *\r\n     * <p>The bootstrap failure arrives wrapped -- {@code REC_BOOTSTRAP_SYNC_FAILED} carries the\r\n     * device's own exception as its cause -- so the chain is walked rather than the top frame\r\n     * inspected. Anything that is not a device error envelope answers no.\r\n     */\r\n    private static boolean deviceAskedToRetry(Throwable cause) {\r\n        for (Throwable current = cause; current != null; current = current.getCause()) {\r\n            if (current instanceof RecProtocolException protocolException\r\n                    && protocolException.deviceSaysRetryable()) {\r\n                return true;\r\n            }\r\n            if (current.getCause() == current) {\r\n                return false;\r\n            }\r\n        }\r\n        return false;\r\n    }\r\n\r\n    private void handleCandidateDisconnect(ConnectionContext context, RecControlClient client,\r\n            long generation, Throwable cause, CompletableFuture<Void> readyFuture) {\r\n        candidateFailed(context, client, generation,\r\n                readyFuture, cause == null\r\n                        ? new RecProtocolException(\"REC_CONNECTION_DISCONNECTED\") : cause);\r\n    }\r\n\r\n    private void handleDisconnect(ConnectionContext context, RecControlClient client,\r\n            long generation, Throwable cause, CompletableFuture<Void> readyFuture) {\r\n        synchronized (context) {\r\n            if (isActive(context, generation, client)) {\r\n                handleActiveDisconnect(context, client, generation, cause);\r\n                return;\r\n            }\r\n            if (isCandidate(context, generation, client)) {\r\n                handleCandidateDisconnect(context, client, generation, cause, readyFuture);\r\n            }\r\n        }\r\n    }\r\n\r\n    private void handleActiveDisconnect(ConnectionContext context, RecControlClient client,\r\n            long generation, Throwable cause) {\r\n        synchronized (context) {\r\n            if (!isActive(context, generation, client) || closed || context.stopped) {\r\n                return;\r\n            }\r\n            context.activeClient = null;\r\n            context.activeGeneration = 0L;\r\n            context.activeState = RecConnectionState.DISCONNECTED;\r\n            if (context.candidateEndpoint == null) {\r\n                context.candidateEndpoint = context.activeEndpoint;\r\n                context.candidateReady = new CompletableFuture<>();\r\n                context.candidateRetryAllowed = true;\r\n                scheduleReconnectLocked(context);\r\n            } else {\r\n                context.candidateRetryAllowed = true;\r\n            }\r\n            publishState(context);\r\n        }\r\n    }\r\n\r\n    private void scheduleReconnectLocked(ConnectionContext context) {\r\n        if (context.candidateEndpoint == null || context.reconnectPending || closed || context.stopped) {\r\n            return;\r\n        }\r\n        context.reconnectPending = true;\r\n        long delayMs = reconnectDelayMs(context.reconnectAttempt++);\r\n        context.lastReconnectDelayMs = delayMs;\r\n        context.reconnectScheduled = scheduler.schedule(() -> {\r\n            CompletableFuture<Void> readyFuture;\r\n            RecEndpoint endpoint;\r\n            synchronized (context) {\r\n                if (closed || context.stopped || !context.reconnectPending || context.candidateEndpoint == null) {\r\n                    return;\r\n                }\r\n                context.reconnectScheduled = null;\r\n                context.reconnectPending = false;\r\n                endpoint = context.candidateEndpoint;\r\n                readyFuture = context.candidateReady == null ? context.firstReady : context.candidateReady;\r\n            }\r\n            beginCandidate(context, endpoint, readyFuture);\r\n        }, delayMs, TimeUnit.MILLISECONDS);\r\n    }\r\n\r\n    private long reconnectDelayMs(int attempt) {\r\n        return reconnectDelayMs(attempt, jitterSource.getAsDouble());\r\n    }\r\n\r\n    static long reconnectDelayMs(int attempt, double jitter) {\r\n        if (!Double.isFinite(jitter) || jitter < 0.0 || jitter > 1.0) {\r\n            throw new IllegalArgumentException(\"jitter must be between 0 and 1\");\r\n        }\r\n        int shift = Math.min(Math.max(attempt, 0), 5);\r\n        long base = Math.min(MAX_RECONNECT_DELAY_MS, MIN_RECONNECT_DELAY_MS << shift);\r\n        long withJitter = base + Math.round(base * 0.25 * jitter);\r\n        return Math.min(MAX_RECONNECT_DELAY_MS, withJitter);\r\n    }\r\n\r\n    private void publishState(ConnectionContext context) {\r\n        RecConnectionState next = reportedState(context);\r\n        if (context.publishedState != next) {\r\n            context.publishedState = next;\r\n            stateListener.onStateChanged(context.serverId, next);\r\n        }\r\n    }\r\n\r\n    private static RecConnectionState reportedState(ConnectionContext context) {\r\n        if (hasUsableActive(context)) {\r\n            return RecConnectionState.READY;\r\n        }\r\n        if (context.candidateState != RecConnectionState.DISCONNECTED) {\r\n            return context.candidateState;\r\n        }\r\n        return context.activeState;\r\n    }\r\n\r\n    private static RecControlClient usableActiveClient(ConnectionContext context) {\r\n        return hasUsableActive(context) ? context.activeClient : null;\r\n    }\r\n\r\n    private static boolean hasUsableActive(ConnectionContext context) {\r\n        return context.activeClient != null && context.activeState == RecConnectionState.READY;\r\n    }\r\n\r\n    private static boolean isCandidateGeneration(ConnectionContext context, long generation) {\r\n        return !context.stopped && context.candidateGeneration == generation && generation > 0L;\r\n    }\r\n\r\n    private static boolean isCandidate(ConnectionContext context, long generation, RecControlClient client) {\r\n        return isCandidateGeneration(context, generation) && context.candidateClient == client;\r\n    }\r\n\r\n    private static boolean isActive(ConnectionContext context, long generation, RecControlClient client) {\r\n        return !context.stopped && context.activeGeneration == generation && context.activeClient == client;\r\n    }\r\n\r\n    private static void completeSupersededCandidate(ConnectionContext context) {\r\n        if (context.candidateReady != null && context.candidateReady != context.firstReady\r\n                && !context.candidateReady.isDone()) {\r\n            context.candidateReady.completeExceptionally(new RecProtocolException(\"REC_CANDIDATE_REPLACED\"));\r\n        }\r\n        context.candidateReady = null;\r\n    }\r\n\r\n    private static void closeClient(RecControlClient client) {\r\n        if (client != null) {\r\n            client.close();\r\n        }\r\n    }\r\n\r\n    private static void cancelReconnect(ConnectionContext context) {\r\n        ScheduledFuture<?> scheduled = context.reconnectScheduled;\r\n        context.reconnectScheduled = null;\r\n        context.reconnectPending = false;\r\n        if (scheduled != null) {\r\n            scheduled.cancel(false);\r\n        }\r\n    }\r\n\r\n    private static Throwable unwrap(Throwable throwable) {\r\n        return throwable instanceof CompletionException && throwable.getCause() != null\r\n                ? throwable.getCause() : throwable;\r\n    }\r\n\r\n    /**\r\n     * Keeps synchronization diagnostics useful without copying a device result into logs. The\r\n     * codec puts only action/result-shape metadata in the immediate cause of a decode failure;\r\n     * deeper causes may contain parser text or remote values and are intentionally not logged.\r\n     */\r\n    private static String synchronizationFailureDetail(Throwable cause) {\r\n        if (cause == null) {\r\n            return null;\r\n        }\r\n        Throwable diagnostic = cause.getCause();\r\n        if (diagnostic == null) {\r\n            return cause.getMessage();\r\n        }\r\n        Throwable decoderCause = diagnostic;\r\n        while (decoderCause.getCause() != null) {\r\n            decoderCause = decoderCause.getCause();\r\n        }\r\n        String detail = diagnostic.getMessage();\r\n        String decoderDetail = decoderCause == diagnostic ? null : decoderCause.getMessage();\r\n        if (decoderDetail == null || decoderDetail.isBlank()) {\r\n            return detail;\r\n        }\r\n        return detail + \" decoderDetail=\" + decoderDetail;\r\n    }\r\n\r\n    /**\r\n     * Summarizes a rejected WebSocket upgrade for a log line.\r\n     *\r\n     * <p>The JDK keeps the rejection body: {@code WebSocketHandshakeException.getResponse()} carries\r\n     * both the status code and the body as a {@code String}. The customer device distinguishes\r\n     * {@code SERVER_NOT_READY} from {@code INTROSPECTION_UNAVAILABLE} only through that body, so\r\n     * discarding it made the two indistinguishable from the Manager side.\r\n     *\r\n     * <p>This method is diagnostic and must never become a second failure. Every parsing problem\r\n     * degrades to a less precise description instead of throwing, and device-supplied text is\r\n     * always sanitized before it reaches a log.\r\n     *\r\n     * @return {@code status=...} with {@code code=}/{@code message=} when the body is the expected\r\n     *         error envelope, a truncated raw body when it is not, or an empty string when the\r\n     *         failure is not a handshake rejection at all\r\n     */\r\n    private static String describeHandshakeFailure(Throwable cause) {\r\n        if (!(cause instanceof WebSocketHandshakeException handshakeException)) {\r\n            return \"\";\r\n        }\r\n        HttpResponse<?> response = handshakeException.getResponse();\r\n        if (response == null) {\r\n            return \"\";\r\n        }\r\n        StringBuilder detail = new StringBuilder(\"status=\").append(response.statusCode());\r\n        String body = response.body() instanceof String rawBody ? rawBody : null;\r\n        if (body == null || body.isBlank()) {\r\n            return detail.toString();\r\n        }\r\n        try {\r\n            JsonNode error = DIAGNOSTIC_MAPPER.readTree(body).path(\"error\");\r\n            String code = error.path(\"code\").asText(null);\r\n            String message = error.path(\"message\").asText(null);\r\n            if (code == null && message == null) {\r\n                return detail.append(\" body=\").append(sanitizeDiagnosticText(body)).toString();\r\n            }\r\n            if (code != null) {\r\n                detail.append(\" code=\").append(sanitizeDiagnosticText(code));\r\n            }\r\n            if (message != null) {\r\n                detail.append(\" message=\").append(sanitizeDiagnosticText(message));\r\n            }\r\n            return detail.toString();\r\n        } catch (RuntimeException | IOException exception) {\r\n            // A body that is not the agreed envelope is still evidence; keep it verbatim.\r\n            return detail.append(\" body=\").append(sanitizeDiagnosticText(body)).toString();\r\n        }\r\n    }\r\n\r\n    /**\r\n     * Makes device-supplied text safe to log: one line, bounded length.\r\n     *\r\n     * <p>Line breaks are removed so a remote party cannot forge additional log records, and the\r\n     * result is truncated because a rejection body has no agreed size limit.\r\n     */\r\n    private static String sanitizeDiagnosticText(String value) {\r\n        StringBuilder sanitized = new StringBuilder(Math.min(value.length(), MAX_DIAGNOSTIC_TEXT));\r\n        for (int index = 0; index < value.length() && sanitized.length() < MAX_DIAGNOSTIC_TEXT; index++) {\r\n            char character = value.charAt(index);\r\n            sanitized.append(Character.isISOControl(character) ? ' ' : character);\r\n        }\r\n        if (value.length() > MAX_DIAGNOSTIC_TEXT) {\r\n            sanitized.append(\"...\");\r\n        }\r\n        return sanitized.toString().trim()\r\n                .replaceAll(\"(?i)(mediaToken|access_token|token|password|authorization)(\\\\s*[=:]\\\\s*)[^\\\\s,}\\\\]]+\",\r\n                        \"$1$2<redacted>\")\r\n                .replaceAll(\"(?i)([?&](?:mediaToken|access_token|token|password|authorization)=)[^&\\\\s]+\",\r\n                        \"$1<redacted>\");\r\n    }\r\n\r\n    private static <T> CompletableFuture<T> toFuture(CompletionStage<T> stage) {\r\n        return Objects.requireNonNull(stage, \"reconciliation result\").toCompletableFuture();\r\n    }\r\n\r\n    private static <T> CompletableFuture<T> submitWrite(ConnectionContext context,\r\n            Supplier<CompletionStage<T>> operation) {\r\n        synchronized (context) {\r\n            if (context.stopped) {\r\n                return CompletableFuture.failedFuture(new RecProtocolException(\"REC_CONNECTION_STOPPED\"));\r\n            }\r\n            CompletableFuture<T> result = context.writeTail\r\n                    .handle((ignored, failure) -> null)\r\n                    .thenCompose(ignored -> {\r\n                        try {\r\n                            synchronized (context) {\r\n                                if (context.stopped) {\r\n                                    return CompletableFuture.failedFuture(\r\n                                            new RecProtocolException(\"REC_CONNECTION_STOPPED\"));\r\n                                }\r\n                            }\r\n                            return toFuture(operation.get());\r\n                        } catch (RuntimeException exception) {\r\n                            return CompletableFuture.failedFuture(exception);\r\n                        }\r\n                    });\r\n            context.writeTail = result.handle((ignored, failure) -> null);\r\n            return result;\r\n        }\r\n    }\r\n\r\n    private static Duration requirePositive(Duration value, String name) {\r\n        Objects.requireNonNull(value, name);\r\n        if (value.isZero() || value.isNegative()) {\r\n            throw new IllegalArgumentException(name + \" must be positive\");\r\n        }\r\n        return value;\r\n    }\r\n\r\n    private static String requireText(String value, String name) {\r\n        if (value == null || value.isBlank()) {\r\n            throw new IllegalArgumentException(name + \" must not be blank\");\r\n        }\r\n        return value;\r\n    }\r\n\r\n    @FunctionalInterface\r\n    interface WebSocketConnector {\r\n        CompletableFuture<WebSocket> connect(URI uri, String token, WebSocket.Listener listener);\r\n    }\r\n\r\n    /** Legacy constructor seam retained for source compatibility; never invoked. */\r\n    @FunctionalInterface\r\n    public interface AiConfigurationPort {\r\n        CompletionStage<Void> applyConfiguration(String aiServerId, RecControlClient client);\r\n    }\r\n\r\n    @FunctionalInterface\r\n    public interface ReconciliationPort {\r\n        CompletionStage<Void> synchronize(String recServerId, RecControlClient client);\r\n    }\r\n\r\n    @FunctionalInterface\r\n    interface StateListener {\r\n        void onStateChanged(String recServerId, RecConnectionState state);\r\n    }\r\n\r\n    /**\r\n     * One managed control endpoint, of a REC device or of an AI Sidecar.\r\n     *\r\n     * <p>\r\n     * {@code actorType} is part of the endpoint and therefore of its identity. The lifecycle\r\n     * around it -- candidate, generation, reconnect, promotion -- is shared, but the protocol\r\n     * profile is not, and an endpoint that did not carry the actor would leave the profile to be\r\n     * guessed from the address at every step that needs it.\r\n     *\r\n     * <p>\r\n     * {@code plaintext} exists so a local mock can be reached over {@code ws://}\r\n     * and is refused for anything but loopback. This channel carries the MANAGER\r\n     * bearer token, so a plaintext hop off the box would put that token on the\r\n     * wire in the clear; a copied configuration must not be able to do that by\r\n     * accident. Production is always {@code wss}, which is also what the\r\n     * customer specification states.\r\n     */\r\n    public record RecEndpoint(String serverId, ActorType actorType, String controlIp, int controlPort,\r\n            Long connectionConfigVersion, boolean plaintext) {\r\n\r\n        /** Secure by default, so an existing caller cannot downgrade silently. */\r\n        public RecEndpoint(String serverId, String controlIp, int controlPort) {\r\n            this(serverId, ActorType.REC, controlIp, controlPort, null, false);\r\n        }\r\n\r\n        /** The REC endpoint as every existing caller builds it. */\r\n        public RecEndpoint(String serverId, String controlIp, int controlPort, boolean plaintext) {\r\n            this(serverId, ActorType.REC, controlIp, controlPort, null, plaintext);\r\n        }\r\n\r\n        /**\r\n         * The endpoint of an AI server, built from its durable connection row only.\r\n         *\r\n         * <p>{@code advertisedIp} is the address the AI server asked to be reached on, which is\r\n         * not the source address of its login: an AI Sidecar behind a NAT answers the second\r\n         * question with an address nobody can dial. {@code SERVER.control_ip} therefore never\r\n         * feeds this factory.\r\n         *\r\n         * <p>{@code connectionConfigVersion} is part of the endpoint identity. It versions the\r\n         * advertised connection configuration and nothing else -- it is not a desired config\r\n         * version, a camera config version or the connection generation this manager counts\r\n         * internally. Carrying it here is what makes a re-published configuration a different\r\n         * endpoint even when the address is unchanged, and what stops a callback from an older\r\n         * version being mistaken for the current one.\r\n         */\r\n        public static RecEndpoint ai(String serverId, String advertisedIp, int controlPort,\r\n                long connectionConfigVersion) {\r\n            return new RecEndpoint(serverId, ActorType.AI, advertisedIp, controlPort,\r\n                    Long.valueOf(connectionConfigVersion), false);\r\n        }\r\n\r\n        public RecEndpoint {\r\n            requireText(serverId, \"serverId\");\r\n            Objects.requireNonNull(actorType, \"actorType\");\r\n            if (!actorType.isMedia() && actorType != ActorType.AI) {\r\n                throw new IllegalArgumentException(\"control endpoint actor must be REC or AI\");\r\n            }\r\n            requireText(controlIp, \"controlIp\");\r\n            if (controlIp.chars().anyMatch(Character::isWhitespace)\r\n                    || controlIp.contains(\"/\") || controlIp.contains(\"?\") || controlIp.contains(\"@\")) {\r\n                throw new IllegalArgumentException(\"controlIp must contain only a host\");\r\n            }\r\n            if (controlPort < 1 || controlPort > 65535) {\r\n                throw new IllegalArgumentException(\"controlPort must be between 1 and 65535\");\r\n            }\r\n            if (actorType == ActorType.AI) {\r\n                if (connectionConfigVersion == null || connectionConfigVersion < 1L) {\r\n                    throw new IllegalArgumentException(\"An AI endpoint carries a connectionConfigVersion\");\r\n                }\r\n                // The approved AI control protocol is wss only; no deployment switch downgrades it.\r\n                if (plaintext) {\r\n                    throw new IllegalArgumentException(\"AI control channel is wss only\");\r\n                }\r\n            } else if (connectionConfigVersion != null) {\r\n                throw new IllegalArgumentException(\"A REC endpoint carries no connectionConfigVersion\");\r\n            }\r\n            if (plaintext && !isLoopback(controlIp)) {\r\n                throw new IllegalArgumentException(\r\n                        \"plaintext control channel is allowed for loopback only: \" + controlIp);\r\n            }\r\n            URI uri = buildUri(controlIp, controlPort, plaintext);\r\n            if (uri.getHost() == null || !\"/ws/v1\".equals(uri.getPath())) {\r\n                throw new IllegalArgumentException(\"controlIp is not a valid host\");\r\n            }\r\n        }\r\n\r\n        public URI uri() {\r\n            return buildUri(controlIp, controlPort, plaintext);\r\n        }\r\n\r\n        /**\r\n         * 실제로 접속할 주소. {@code plaintextLoopback} 이 켜져 있고 loopback 인 REC 면 {@code ws://} 다.\r\n         *\r\n         * <p>\r\n         * 설정을 엔드포인트 값에 넣지 않고 여기서만 적용한다. 엔드포인트는 readyAt·replace 에서\r\n         * 같은 서버인지 가르는 값이라, 만드는 곳마다 설정을 넘기다 한 곳이라도 빠지면 같은 서버를\r\n         * 다른 연결로 보게 된다.\r\n         *\r\n         * <p>\r\n         * AI 는 이 완화를 받지 않는다. 로컬 mock 용 설정 하나가 승인된 wss 계약을 평문으로 바꾸는\r\n         * 일이 없도록, 강등은 REC 에만 적용한다.\r\n         */\r\n        public URI connectUri(boolean plaintextLoopback) {\r\n            boolean loopbackDowngrade = actorType.isMedia() && plaintextLoopback && isLoopback(controlIp);\r\n            return buildUri(controlIp, controlPort, plaintext || loopbackDowngrade);\r\n        }\r\n\r\n        private static boolean isLoopback(String controlIp) {\r\n            String normalized = controlIp.toLowerCase(Locale.ROOT);\r\n            return normalized.equals(\"localhost\") || normalized.startsWith(\"127.\")\r\n                    || normalized.equals(\"::1\") || normalized.equals(\"[::1]\");\r\n        }\r\n\r\n        private static URI buildUri(String controlIp, int controlPort, boolean plaintext) {\r\n            try {\r\n                return new URI(plaintext ? \"ws\" : \"wss\", null, controlIp, controlPort, \"/ws/v1\",\r\n                        null, null);\r\n            } catch (URISyntaxException exception) {\r\n                throw new IllegalArgumentException(\"controlIp is not a valid host\", exception);\r\n            }\r\n        }\r\n    }\r\n\r\n    /**\r\n     * Future for one pending lease request. Cancellation is also an abandonment signal: if the\r\n     * lane grants the lease concurrently with cancellation, the granted lease is closed so the\r\n     * caller cannot leave the lane occupied without an owner.\r\n     */\r\n    private static final class RecWriteLeaseFuture extends CompletableFuture<RecWriteLease> {\r\n        private volatile boolean abandoned;\r\n        private volatile RecWriteLease grantedLease;\r\n\r\n        private boolean grant(RecWriteLease lease) {\r\n            grantedLease = lease;\r\n            boolean completed = complete(lease);\r\n            if (!completed || abandoned) {\r\n                lease.close();\r\n                return false;\r\n            }\r\n            return true;\r\n        }\r\n\r\n        @Override\r\n        public boolean cancel(boolean mayInterruptIfRunning) {\r\n            abandoned = true;\r\n            RecWriteLease lease = grantedLease;\r\n            if (lease != null) {\r\n                lease.close();\r\n            }\r\n            return super.cancel(mayInterruptIfRunning);\r\n        }\r\n    }\r\n\r\n    /**\r\n     * An exclusive hold on one server's write lane. Not thread-safe by contract: it is meant to be\r\n     * driven by the single caller that acquired it.\r\n     */\r\n    public static final class RecWriteLease implements AutoCloseable {\r\n        private final RecConnectionManager manager;\r\n        private final ConnectionContext context;\r\n        private final RecControlClient client;\r\n        private final long generation;\r\n        private final CompletableFuture<Void> released;\r\n\r\n        private RecWriteLease(RecConnectionManager manager, ConnectionContext context,\r\n                RecControlClient client, long generation, CompletableFuture<Void> released) {\r\n            this.manager = manager;\r\n            this.context = context;\r\n            this.client = client;\r\n            this.generation = generation;\r\n            this.released = released;\r\n        }\r\n\r\n        public long generation() {\r\n            return generation;\r\n        }\r\n\r\n        /** True while the pinned generation is still the active one for this server. */\r\n        public boolean valid() {\r\n            synchronized (context) {\r\n                return !context.stopped && context.activeClient == client\r\n                        && context.activeGeneration == generation\r\n                        && context.activeState == RecConnectionState.READY;\r\n            }\r\n        }\r\n\r\n        public <P, R> CompletableFuture<R> send(RecAction<P, R> action, P params) {\r\n            if (released.isDone()) {\r\n                return CompletableFuture.failedFuture(new RecProtocolException(\"REC_WRITE_LEASE_CLOSED\"));\r\n            }\r\n            if (!valid()) {\r\n                return CompletableFuture.failedFuture(\r\n                        new RecProtocolException(\"REC_CONNECTION_GENERATION_STALE\"));\r\n            }\r\n            return client.send(action, params, manager.requestTimeout);\r\n        }\r\n\r\n        @Override\r\n        public void close() {\r\n            released.complete(null);\r\n        }\r\n    }\r\n\r\n    private static final class ConnectionContext {\r\n        private final String serverId;\r\n        private final CompletableFuture<Void> firstReady = new CompletableFuture<>();\r\n        private RecEndpoint activeEndpoint;\r\n        private RecControlClient activeClient;\r\n        private RecConnectionState activeState = RecConnectionState.DISCONNECTED;\r\n        private long activeGeneration;\r\n        private RecEndpoint candidateEndpoint;\r\n        private RecControlClient candidateClient;\r\n        private RecConnectionState candidateState = RecConnectionState.DISCONNECTED;\r\n        private long candidateGeneration;\r\n        private long generationSequence;\r\n        private CompletableFuture<Void> candidateReady;\r\n        private boolean candidateRetryAllowed = true;\r\n\r\n        /**\r\n         * Remaining attempts for a login-driven candidate, or {@link #UNLIMITED_RETRY_BUDGET} when\r\n         * this candidate is not one -- a startup connect, or a reconnect after a ready channel\r\n         * dropped, both of which retry for as long as the endpoint stands.\r\n         */\r\n        private int candidateRetryBudget = UNLIMITED_RETRY_BUDGET;\r\n        private RecConnectionState publishedState = RecConnectionState.DISCONNECTED;\r\n        private int reconnectAttempt;\r\n        private long lastReconnectDelayMs;\r\n\r\n        /** The bootstrap call currently in flight, for the failure log. See {@code sendTracked}. */\r\n        private volatile String syncStage;\r\n        private ScheduledFuture<?> reconnectScheduled;\r\n        private boolean reconnectPending;\r\n        private boolean stopped;\r\n        private CompletableFuture<Void> writeTail = CompletableFuture.completedFuture(null);\r\n\r\n        private ConnectionContext(RecEndpoint endpoint) {\r\n            this.serverId = endpoint.serverId();\r\n            this.candidateEndpoint = endpoint;\r\n            this.candidateReady = firstReady;\r\n        }\r\n\r\n        private boolean hasEndpoint(RecEndpoint endpoint) {\r\n            return endpoint.equals(activeEndpoint) || endpoint.equals(candidateEndpoint);\r\n        }\r\n    }\r\n}\r\n"
      }
    ]
  }
}
