You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by hg...@apache.org on 2026/04/28 14:28:43 UTC

(iggy) branch feat/message-bus-multi-transport updated (c70c1f92e -> 22313e522)

This is an automated email from the ASF dual-hosted git repository.

hubcio pushed a change to branch feat/message-bus-multi-transport
in repository https://gitbox.apache.org/repos/asf/iggy.git


 discard c70c1f92e feat(message_bus): add QUIC, TCP-TLS, WS, WSS transports for SDK clients
     add cca0e04e7 feat(configs): add ServerNgConfig schema with message_bus section (#3189)
     new 22313e522 feat(message_bus): add QUIC, TCP-TLS, WS, WSS transports for SDK clients

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (c70c1f92e)
            \
             N -- N -- N   refs/heads/feat/message-bus-multi-transport (22313e522)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 core/configs/src/lib.rs                            |   2 +
 core/configs/src/server_ng_config/defaults.rs      |  92 +++++++++
 core/configs/src/server_ng_config/displays.rs      |  69 +++++++
 core/configs/src/server_ng_config/message_bus.rs   | 218 +++++++++++++++++++++
 .../src/{server_config => server_ng_config}/mod.rs |  27 ++-
 core/configs/src/server_ng_config/server_ng.rs     | 198 +++++++++++++++++++
 core/configs/src/server_ng_config/validators.rs    | 130 ++++++++++++
 core/message_bus/src/config.rs                     |  15 --
 core/message_bus/src/installer/ws.rs               |   8 +-
 core/server-ng/config.toml                         |  35 ++++
 10 files changed, 768 insertions(+), 26 deletions(-)
 create mode 100644 core/configs/src/server_ng_config/defaults.rs
 create mode 100644 core/configs/src/server_ng_config/displays.rs
 create mode 100644 core/configs/src/server_ng_config/message_bus.rs
 copy core/configs/src/{server_config => server_ng_config}/mod.rs (52%)
 create mode 100644 core/configs/src/server_ng_config/server_ng.rs
 create mode 100644 core/configs/src/server_ng_config/validators.rs


(iggy) 01/01: feat(message_bus): add QUIC, TCP-TLS, WS, WSS transports for SDK clients

Posted by hg...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

hubcio pushed a commit to branch feat/message-bus-multi-transport
in repository https://gitbox.apache.org/repos/asf/iggy.git

commit 22313e5222bca0351951fa553c14efd0dbab9772
Author: Hubert Gruszecki <h....@gmail.com>
AuthorDate: Tue Apr 28 16:26:38 2026 +0200

    feat(message_bus): add QUIC, TCP-TLS, WS, WSS transports for SDK clients
    
    Replica plane stays TCP forever. The VSR FIFO and timing
    assumptions, the fd-delegation path, the writev batching and
    the view-change timer all rely on a plaintext byte stream
    between a bounded set of trusted replicas; a datagram or
    gateway-terminated transport breaks at least one of those.
    
    The SDK-client plane previously had only plain TCP; extend it
    with four additional transports:
    
    - QUIC: shard-0 terminal (compio-quic Endpoint demuxes by CID
      so per-connection state cannot migrate). 1 bidi stream per
      peer, 0-RTT off by default with listener defense-in-depth
      reject of 0-RTT 0-streams.
    - TCP-TLS: rustls 1.3, no client auth, 0-RTT off. Compio-tls
      TlsStream behind the unified TransportConn::run shape with
      bounded close_grace shutdown.
    - WS: compio-ws WebSocketStream over plaintext TCP. The
      pre-upgrade TCP fd cross-shard handover keeps fd-delegation
      on plain TCP only; rustls / quinn-proto state stays shard-0
      resident.
    - WSS: WebSocketStream over TlsStream. Both handshakes run on
      the per-connection install task so a slow peer cannot block
      the listener.
    
    Shared infrastructure:
    
    - TransportListener / TransportConn trait family; every
      transport implements run(self, ctx) so the installer is
      generic over them.
    - WebSocketConfig threaded through MessageBusConfig (default
      matches tungstenite's defaults). Used by both plain WS
      install path (accept_async_with_config) and WSS handshake
      inside WssTransportConn::run.
    - close_grace threaded through MessageBusConfig and applied
      uniformly across the TCP-TLS, WS, and WSS install paths so
      operator policy controls the wall-clock bound on
      stream.shutdown / ws.close in the safe-shutdown sequence.
    - Bounded safe-shutdown sequence for TLS-family transports;
      in-flight writes always complete, no select! over
      stream.shutdown(), no plaintext loss on cancel.
    - Single-task pump per connection for WS/WSS using compio-ws
      cancel-safe read primitive (verified against compio source).
    
    Bus layer auth contract: thin by design. Both planes connect
    unauthenticated at the wire and let the caller (core/server-ng)
    gate command access via application-level LOGIN commands:
    
    - SDK-client plane: pre-LOGIN allowlist PING / LOGIN_USER /
      LOGIN_WITH_PERSONAL_ACCESS_TOKEN. No subprotocol gate, no
      QUIC ALPN, no BLAKE3 envelope.
    - Replica plane: a future LOGIN_REPLICA command carries the
      cluster shared secret. The Ping frame at connect time only
      announces replica_id so the bus can key its registry; it
      carries no MAC.
    
    Per-connection metadata exposed to the caller via
    IggyMessageBus::client_meta keyed by client_id (peer addr,
    transport kind, optional TLS / QUIC / WS sub-info), populated
    on install and removed on disconnect. Shard ShardFramePayload
    ClientConnectionSetup / ClientWsConnectionSetup variants now
    carry ClientConnMeta so the owning shard receives the same
    peer addr that shard 0 saw at accept time.
    
    Operator-facing TOML reflects operator concerns (transport
    ports, cert paths) not bus-internal abstractions. The
    [message_bus] section keeps only bus-only knobs (max_batch,
    peer_queue_capacity, keepalive timings, close_grace,
    max_message_size, reconnect_period); transport listen
    addresses derive from the existing [tcp] + [tcp.tls] and
    [websocket] + [websocket.tls] sections at bootstrap. The
    unused tcp_tls_listen_addr / wss_listen_addr fields are
    dropped from both the runtime MessageBusConfig and the
    configs-side schema (start_on_shard_zero already takes both
    listen addrs as direct parameters).
    
    Crate layout grouped per concern: replica/, installer/,
    client_listener/ replace nine flat files; per-transport files
    share uniform tcp / tcp_tls / ws / wss / quic naming across
    installer/, client_listener/, transports/.
---
 Cargo.lock                                         |   7 +
 Cargo.toml                                         |   3 +
 core/configs/src/server_ng_config/defaults.rs      |   2 -
 core/configs/src/server_ng_config/displays.rs      |   5 +-
 core/configs/src/server_ng_config/message_bus.rs   |  75 +--
 core/configs/src/server_ng_config/server_ng.rs     |   2 -
 core/message_bus/Cargo.toml                        |   9 +
 core/message_bus/src/client_listener/mod.rs        |  49 ++
 core/message_bus/src/client_listener/quic.rs       | 111 +++++
 .../{client_listener.rs => client_listener/tcp.rs} |  16 +-
 core/message_bus/src/client_listener/tcp_tls.rs    | 136 +++++
 .../{client_listener.rs => client_listener/ws.rs}  |  65 +--
 core/message_bus/src/client_listener/wss.rs        | 131 +++++
 core/message_bus/src/config.rs                     |  44 +-
 core/message_bus/src/connector.rs                  |  35 +-
 core/message_bus/src/framing.rs                    |  24 +-
 core/message_bus/src/installer.rs                  | 542 --------------------
 core/message_bus/src/installer/common.rs           |  57 +++
 core/message_bus/src/installer/conn_info.rs        | 114 +++++
 core/message_bus/src/installer/mod.rs              | 137 +++++
 core/message_bus/src/installer/quic.rs             |  57 +++
 core/message_bus/src/installer/replica.rs          | 311 ++++++++++++
 core/message_bus/src/installer/tcp.rs              | 271 ++++++++++
 core/message_bus/src/installer/tcp_tls.rs          |  85 ++++
 core/message_bus/src/installer/ws.rs               |  56 +++
 core/message_bus/src/installer/wss.rs              |  83 +++
 core/message_bus/src/lib.rs                        | 240 ++++++++-
 .../src/lifecycle/connection_registry.rs           |   4 +-
 core/message_bus/src/lifecycle/mod.rs              |   2 +-
 core/message_bus/src/replica/io.rs                 | 502 +++++++++++++++++++
 .../{replica_listener.rs => replica/listener.rs}   |  75 +--
 .../message_bus/src/{transports => replica}/mod.rs |  13 +-
 core/message_bus/src/replica_io.rs                 | 154 ------
 core/message_bus/src/transports/mod.rs             | 148 +++++-
 core/message_bus/src/transports/quic.rs            | 554 +++++++++++++++++++++
 core/message_bus/src/transports/tcp.rs             | 417 ++++++++++++++++
 core/message_bus/src/transports/tcp_tls.rs         | 519 +++++++++++++++++++
 core/message_bus/src/transports/tls/mod.rs         | 257 ++++++++++
 core/message_bus/src/transports/ws.rs              | 427 ++++++++++++++++
 core/message_bus/src/transports/wss.rs             | 543 ++++++++++++++++++++
 core/message_bus/src/writer_task.rs                | 117 -----
 core/message_bus/tests/backpressure.rs             |   2 +-
 core/message_bus/tests/cancel_unsafe.rs            | 140 ++++++
 core/message_bus/tests/common/mod.rs               | 105 +++-
 core/message_bus/tests/connection_lost_notify.rs   |   2 +-
 core/message_bus/tests/directional_connection.rs   |   2 +-
 core/message_bus/tests/duplicate_client_id.rs      |  46 +-
 core/message_bus/tests/graceful_shutdown.rs        |   3 +-
 core/message_bus/tests/head_of_line.rs             |   2 +-
 core/message_bus/tests/installer_panic_cleanup.rs  | 232 +++++++++
 core/message_bus/tests/quic_client_roundtrip.rs    | 135 +++++
 core/message_bus/tests/reconnect.rs                |   2 +-
 core/message_bus/tests/reconnect_skip_connected.rs |   2 +-
 core/message_bus/tests/replica_roundtrip.rs        |   2 +-
 core/message_bus/tests/shard_zero_gating.rs        | 217 +++++++-
 core/message_bus/tests/tcp_client_roundtrip.rs     |   3 +-
 core/message_bus/tests/tcp_tls_client_listener.rs  | 127 +++++
 core/message_bus/tests/tcp_tls_client_roundtrip.rs | 159 ++++++
 core/message_bus/tests/vectored_batch.rs           |   2 +-
 core/message_bus/tests/ws_client_roundtrip.rs      | 143 ++++++
 core/message_bus/tests/wss_client_listener.rs      | 126 +++++
 core/message_bus/tests/wss_client_roundtrip.rs     | 160 ++++++
 core/server-ng/config.toml                         |   5 -
 core/server-ng/src/dedup.rs                        | 396 +++++++++++++++
 core/server-ng/src/lib.rs                          |   5 +
 core/server-ng/src/session_manager.rs              |   9 +-
 core/shard/src/builder.rs                          |   7 +-
 core/shard/src/coordinator.rs                      |  57 ++-
 core/shard/src/lib.rs                              |  26 +-
 core/shard/src/router.rs                           |  20 +-
 70 files changed, 7494 insertions(+), 1042 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index c213ee651..40b53a4b8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7888,12 +7888,19 @@ version = "0.1.0"
 dependencies = [
  "async-channel",
  "compio",
+ "compio-quic",
+ "compio-ws",
  "futures",
  "iggy_binary_protocol",
  "iggy_common",
  "libc",
  "rand 0.10.1",
+ "rcgen",
+ "rustls",
+ "rustls-pemfile",
+ "scopeguard",
  "socket2 0.6.3",
+ "tempfile",
  "thiserror 2.0.18",
  "tracing",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index 6589c4b2e..88cc5067b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -119,6 +119,8 @@ compio = { version = "0.18.0", features = [
 compio-buf = "0.8.1"
 # Pin compio-driver >= 0.11.2 to fix musl compilation (compio-rs/compio#668)
 compio-driver = "0.11.4"
+compio-quic = "=0.7.2"
+compio-ws = "=0.3.1"
 configs = { path = "core/configs", version = "0.1.0" }
 configs_derive = { path = "core/configs_derive", version = "0.1.0" }
 consensus = { path = "core/consensus" }
@@ -247,6 +249,7 @@ rust-embed = "8.11.0"
 rust-s3 = { version = "0.37.1", default-features = false, features = ["tokio-rustls-tls", "tags"] }
 rustls = { version = "0.23.39", features = ["ring"] }
 rustls-pemfile = "2.2.0"
+scopeguard = "1.2.0"
 secrecy = { version = "0.10", features = ["serde"] }
 send_wrapper = "0.6.0"
 serde = { version = "1.0.228", features = ["derive", "rc"] }
diff --git a/core/configs/src/server_ng_config/defaults.rs b/core/configs/src/server_ng_config/defaults.rs
index 9218d0828..ba79ef016 100644
--- a/core/configs/src/server_ng_config/defaults.rs
+++ b/core/configs/src/server_ng_config/defaults.rs
@@ -87,8 +87,6 @@ impl Default for MessageBusConfig {
             keepalive_retries: 3,
             close_peer_timeout: "2 s".parse().expect("'2 s' parses as IggyDuration"),
             close_grace: "2 s".parse().expect("'2 s' parses as IggyDuration"),
-            tcp_tls_listen_addr: None,
-            wss_listen_addr: None,
         }
     }
 }
diff --git a/core/configs/src/server_ng_config/displays.rs b/core/configs/src/server_ng_config/displays.rs
index 1c286aac3..0cd1d8707 100644
--- a/core/configs/src/server_ng_config/displays.rs
+++ b/core/configs/src/server_ng_config/displays.rs
@@ -54,8 +54,7 @@ impl Display for MessageBusConfig {
             f,
             "{{ max_batch: {}, max_message_size: {}, peer_queue_capacity: {}, \
              reconnect_period: {}, keepalive_idle: {}, keepalive_interval: {}, \
-             keepalive_retries: {}, close_peer_timeout: {}, close_grace: {}, \
-             tcp_tls_listen_addr: {:?}, wss_listen_addr: {:?} }}",
+             keepalive_retries: {}, close_peer_timeout: {}, close_grace: {} }}",
             self.max_batch,
             self.max_message_size,
             self.peer_queue_capacity,
@@ -65,8 +64,6 @@ impl Display for MessageBusConfig {
             self.keepalive_retries,
             self.close_peer_timeout,
             self.close_grace,
-            self.tcp_tls_listen_addr,
-            self.wss_listen_addr,
         )
     }
 }
diff --git a/core/configs/src/server_ng_config/message_bus.rs b/core/configs/src/server_ng_config/message_bus.rs
index a76b87e04..fee433582 100644
--- a/core/configs/src/server_ng_config/message_bus.rs
+++ b/core/configs/src/server_ng_config/message_bus.rs
@@ -19,17 +19,27 @@
 
 //! On-disk schema for the inter-shard / inter-replica message bus.
 //!
-//! Mirrors the runtime `core::message_bus::config::MessageBusConfig`
-//! field-for-field, but uses configs-crate idioms:
+//! Mirrors the runtime `core::message_bus::config::MessageBusConfig`,
+//! using configs-crate idioms:
 //!
 //! - `Duration` -> [`IggyDuration`] (DisplayFromStr-serde, `"5 s"` syntax)
 //! - `usize` / `u32` -> kept as the underlying integer type
-//! - `Option<SocketAddr>` -> `Option<String>` parsed at validate-time
-//! - WebSocket frame-layer tunables (`compio_ws::WebSocketConfig`) are
-//!   NOT part of this section; they live under `[websocket]` (the
-//!   existing [`super::super::server_config::websocket::WebSocketConfig`]).
-//!   The future wiring PR builds the runtime `WebSocketConfig` from that
-//!   section and feeds it into the bus at construction.
+//!
+//! Transport-section concerns intentionally do NOT live in
+//! `[message_bus]`:
+//!
+//! - TCP-TLS / WSS listen addresses derive from `[tcp]` + `[tcp.tls]`
+//!   and `[websocket]` + `[websocket.tls]` respectively. The future
+//!   wiring PR populates the bus's runtime listen-addr inputs from
+//!   those sections at bootstrap.
+//! - WebSocket frame-layer tunables (`compio_ws::WebSocketConfig`)
+//!   live under `[websocket]`. The future wiring PR builds the runtime
+//!   `WebSocketConfig` from that section and feeds it into the bus at
+//!   construction.
+//!
+//! `[message_bus]` keeps only bus-internal abstractions the operator
+//! does not see anywhere else in the schema (batch sizing, per-peer
+//! queue depth, keepalive timings, close-grace, reconnect period).
 //!
 //! Construction of the runtime type from this struct happens in the
 //! follow-up PR that wires `core/server-ng` to call
@@ -41,7 +51,6 @@ use configs::ConfigEnv;
 use iggy_common::{IggyByteSize, IggyDuration, Validatable};
 use serde::{Deserialize, Serialize};
 use serde_with::{DisplayFromStr, serde_as};
-use std::net::SocketAddr;
 
 /// Hard upper bound on [`MessageBusConfig::max_batch`], in iovecs.
 ///
@@ -111,18 +120,6 @@ pub struct MessageBusConfig {
     #[config_env(leaf)]
     #[serde_as(as = "DisplayFromStr")]
     pub close_grace: IggyDuration,
-
-    /// Optional TCP-TLS client listener address in `host:port` form.
-    /// `None` keeps the plane unbound. Resolved to [`SocketAddr`] by
-    /// [`Validatable::validate`].
-    #[serde(default)]
-    pub tcp_tls_listen_addr: Option<String>,
-
-    /// Optional WSS client listener address in `host:port` form.
-    /// `None` keeps the plane unbound. Resolved to [`SocketAddr`] by
-    /// [`Validatable::validate`].
-    #[serde(default)]
-    pub wss_listen_addr: Option<String>,
 }
 
 impl Validatable<ConfigurationError> for MessageBusConfig {
@@ -150,20 +147,6 @@ impl Validatable<ConfigurationError> for MessageBusConfig {
             eprintln!("{COMPONENT_NG} message_bus.keepalive_retries must be > 0");
             return Err(ConfigurationError::InvalidConfigurationValue);
         }
-        // Resolve optional listen addrs eagerly so a typo fails at boot,
-        // not at first connect.
-        if let Some(s) = &self.tcp_tls_listen_addr {
-            s.parse::<SocketAddr>().map_err(|e| {
-                eprintln!("{COMPONENT_NG} message_bus.tcp_tls_listen_addr '{s}' is invalid: {e}");
-                ConfigurationError::InvalidConfigurationValue
-            })?;
-        }
-        if let Some(s) = &self.wss_listen_addr {
-            s.parse::<SocketAddr>().map_err(|e| {
-                eprintln!("{COMPONENT_NG} message_bus.wss_listen_addr '{s}' is invalid: {e}");
-                ConfigurationError::InvalidConfigurationValue
-            })?;
-        }
         Ok(())
     }
 }
@@ -223,28 +206,6 @@ mod tests {
         assert!(c.validate().is_err());
     }
 
-    #[test]
-    fn rejects_invalid_tcp_tls_listen_addr() {
-        let mut c = baseline();
-        c.tcp_tls_listen_addr = Some("not-a-socket-addr".to_string());
-        assert!(c.validate().is_err());
-    }
-
-    #[test]
-    fn rejects_invalid_wss_listen_addr() {
-        let mut c = baseline();
-        c.wss_listen_addr = Some("nope".to_string());
-        assert!(c.validate().is_err());
-    }
-
-    #[test]
-    fn accepts_valid_listen_addrs() {
-        let mut c = baseline();
-        c.tcp_tls_listen_addr = Some("0.0.0.0:9443".to_string());
-        c.wss_listen_addr = Some("127.0.0.1:9444".to_string());
-        c.validate().expect("valid listen addrs accepted");
-    }
-
     /// Tripwire: pins the local copy of `IOV_MAX_LIMIT` against the
     /// runtime crate's value. If `core/message_bus` ever bumps its
     /// `IOV_MAX_LIMIT`, this test fails the configs build until the
diff --git a/core/configs/src/server_ng_config/server_ng.rs b/core/configs/src/server_ng_config/server_ng.rs
index 1df243ba1..f6876770d 100644
--- a/core/configs/src/server_ng_config/server_ng.rs
+++ b/core/configs/src/server_ng_config/server_ng.rs
@@ -174,8 +174,6 @@ mod tests {
         assert_eq!(cfg.message_bus.max_batch, 256);
         assert_eq!(cfg.message_bus.peer_queue_capacity, 256);
         assert_eq!(cfg.message_bus.keepalive_retries, 3);
-        assert!(cfg.message_bus.tcp_tls_listen_addr.is_none());
-        assert!(cfg.message_bus.wss_listen_addr.is_none());
     }
 
     #[test]
diff --git a/core/message_bus/Cargo.toml b/core/message_bus/Cargo.toml
index 4d3569482..abccfac98 100644
--- a/core/message_bus/Cargo.toml
+++ b/core/message_bus/Cargo.toml
@@ -31,15 +31,24 @@ publish = false
 [dependencies]
 async-channel = { workspace = true }
 compio = { workspace = true }
+compio-quic = { workspace = true }
+compio-ws = { workspace = true }
 futures = { workspace = true }
 iggy_binary_protocol = { workspace = true }
 iggy_common = { workspace = true }
 libc = { workspace = true }
 rand = { workspace = true }
+rustls = { workspace = true }
+rustls-pemfile = { workspace = true }
+scopeguard = { workspace = true }
 socket2 = { workspace = true, features = ["all"] }
 thiserror = { workspace = true }
 tracing = { workspace = true }
 
+[dev-dependencies]
+rcgen = { workspace = true }
+tempfile = { workspace = true }
+
 [lints.clippy]
 enum_glob_use = "deny"
 pedantic = "deny"
diff --git a/core/message_bus/src/client_listener/mod.rs b/core/message_bus/src/client_listener/mod.rs
new file mode 100644
index 000000000..453c87da1
--- /dev/null
+++ b/core/message_bus/src/client_listener/mod.rs
@@ -0,0 +1,49 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Per-transport listeners for SDK clients. Each submodule exposes a
+//! `bind` + `run` pair following the same shape; only shard 0 binds.
+//!
+//! Cross-transport invariants:
+//!
+//! - Accept callbacks own the connection from acceptance; any handshake
+//!   work runs in the install path (per [`crate::installer`]) so a slow
+//!   peer cannot block the accept loop.
+//! - The `on_accepted` callback is responsible for minting a
+//!   `client_id`. The plain TCP and pre-upgrade WS paths route to the
+//!   owning shard via `ShardFramePayload::ClientConnectionSetup` and
+//!   `ClientWsConnectionSetup` respectively. TCP-TLS, WSS, and QUIC
+//!   stay shard-0 terminal: their connection state is not serialisable
+//!   so the callback installs locally on shard 0 instead of shipping a
+//!   setup frame.
+
+use crate::{GenericHeader, Message};
+use std::rc::Rc;
+
+pub mod quic;
+pub mod tcp;
+pub mod tcp_tls;
+pub mod ws;
+pub mod wss;
+
+/// Callback for the owning-shard install path in [`crate::installer`].
+/// Dispatches `(client_id, request_message)` into the shard's pipeline.
+///
+/// Transport-agnostic: every client transport surfaces a parsed
+/// `RequestHeader` message with the same handler signature, regardless
+/// of whether the wire is plain TCP, TLS, WS, WSS, or QUIC.
+pub type RequestHandler = Rc<dyn Fn(u128, Message<GenericHeader>)>;
diff --git a/core/message_bus/src/client_listener/quic.rs b/core/message_bus/src/client_listener/quic.rs
new file mode 100644
index 000000000..3e2709aa4
--- /dev/null
+++ b/core/message_bus/src/client_listener/quic.rs
@@ -0,0 +1,111 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! QUIC listener for SDK clients.
+//!
+//! Runs only on shard 0. The accept loop drives the QUIC handshake to
+//! completion AND accepts the first bidirectional stream pair before
+//! invoking the supplied callback, so the callback receives a
+//! ready-for-traffic [`compio_quic::Connection`] plus its
+//! `(SendStream, RecvStream)` pair. No ALPN is advertised; protocol
+//! version is validated by the caller (server-ng) inside the LOGIN
+//! command.
+//!
+//! 0-RTT data is refused at accept time
+//! ([`crate::transports::quic::server_config_with_cert`] sets
+//! `max_early_data_size = 0` at the rustls layer; the accept path
+//! double-checks `RecvStream::is_0rtt()` for defense in depth and
+//! closes with `QUIC_PROTOCOL_VIOLATION` on mismatch).
+//!
+//! QUIC stays shard-0 terminal: a `compio_quic::Endpoint` binds a
+//! single UDP socket and demuxes incoming packets to in-flight
+//! connections by Connection ID, and per-connection TLS / packet-number
+//! / congestion state lives in `quinn-proto::Connection`, which is
+//! non-serialisable. Cross-shard handover is fundamentally out of
+//! reach for QUIC; the callback always installs locally on shard 0.
+
+use crate::lifecycle::ShutdownToken;
+use crate::transports::quic::QuicTransportListener;
+use crate::{AcceptedQuicClientFn, transports::TransportListener};
+use compio_quic::{Endpoint, ServerConfig};
+use futures::FutureExt;
+use iggy_common::IggyError;
+use std::net::SocketAddr;
+use tracing::{debug, error, info};
+
+/// Bind a QUIC [`Endpoint`] without starting the accept loop.
+///
+/// `server_config` is built by the bootstrap caller via
+/// [`crate::transports::quic::server_config_with_cert`] from the
+/// operator-provided cert chain + key DER.
+///
+/// # Errors
+///
+/// Returns [`IggyError::CannotBindToSocket`] if the bind fails.
+#[allow(clippy::future_not_send)]
+pub async fn bind(
+    addr: SocketAddr,
+    server_config: ServerConfig,
+) -> Result<(Endpoint, SocketAddr), IggyError> {
+    let endpoint = Endpoint::server(addr, server_config)
+        .await
+        .map_err(|_| IggyError::CannotBindToSocket(addr.to_string()))?;
+    let actual = endpoint
+        .local_addr()
+        .map_err(|e| IggyError::IoError(e.to_string()))?;
+    Ok((endpoint, actual))
+}
+
+/// Run the QUIC listener accept loop until the shutdown token fires.
+///
+/// Each accepted connection is handed to `on_accepted` along with its
+/// first bidirectional stream pair. The callback owns the connection
+/// from that point on; it mints a `client_id` and calls
+/// [`crate::installer::install_client_quic_conn`].
+#[allow(clippy::future_not_send)]
+pub async fn run(endpoint: Endpoint, token: ShutdownToken, on_accepted: AcceptedQuicClientFn) {
+    info!(
+        "Client listener (QUIC) accepting on {:?}",
+        endpoint.local_addr().ok()
+    );
+
+    let listener = QuicTransportListener::new(endpoint);
+    loop {
+        futures::select! {
+            () = token.wait().fuse() => {
+                debug!("Client listener (QUIC) shutting down");
+                break;
+            }
+            result = listener.accept().fuse() => {
+                match result {
+                    Ok((conn, peer_addr)) => {
+                        debug!(%peer_addr, "QUIC client accepted, handing to installer");
+                        let (connection, streams) = conn.into_parts();
+                        on_accepted(connection, streams);
+                    }
+                    Err(e) => {
+                        // Endpoint-fatal (e.g. endpoint closed). The
+                        // shutdown token will normally fire just before
+                        // this; surface the error and exit.
+                        error!("Client listener (QUIC) accept failed: {e}");
+                        break;
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/core/message_bus/src/client_listener.rs b/core/message_bus/src/client_listener/tcp.rs
similarity index 85%
copy from core/message_bus/src/client_listener.rs
copy to core/message_bus/src/client_listener/tcp.rs
index 75f13afe5..6ab56466e 100644
--- a/core/message_bus/src/client_listener.rs
+++ b/core/message_bus/src/client_listener/tcp.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! TCP listener for consensus-protocol SDK clients.
+//! TCP listener for SDK clients.
 //!
 //! Runs only on shard 0. Every accepted connection is handed to an
 //! `on_accepted` callback supplied by the shard bootstrap, which mints a
@@ -26,20 +26,14 @@
 //! The owning shard's router handler wraps the duplicated fd and installs
 //! writer + reader tasks via [`crate::installer`].
 
+use crate::AcceptedClientFn;
 use crate::lifecycle::ShutdownToken;
-use crate::{AcceptedClientFn, GenericHeader, Message};
 use compio::net::{SocketOpts, TcpListener};
 use futures::FutureExt;
 use iggy_common::IggyError;
 use std::net::SocketAddr;
-use std::rc::Rc;
 use tracing::{debug, error, info};
 
-/// Callback preserved for the owning-shard install path in
-/// [`crate::installer`]. Dispatches `(client_id, request_message)` into the
-/// shard's consensus pipeline.
-pub type RequestHandler = Rc<dyn Fn(u128, Message<GenericHeader>)>;
-
 /// Bind the listener and return the bound address without starting the
 /// accept loop. Useful for tests that need the port before driving traffic.
 ///
@@ -71,14 +65,14 @@ pub async fn bind(addr: SocketAddr) -> Result<(TcpListener, SocketAddr), IggyErr
 #[allow(clippy::future_not_send)]
 pub async fn run(listener: TcpListener, token: ShutdownToken, on_accepted: AcceptedClientFn) {
     info!(
-        "Consensus client listener accepting on {:?}",
+        "Client listener (TCP) accepting on {:?}",
         listener.local_addr().ok()
     );
 
     loop {
         futures::select! {
             () = token.wait().fuse() => {
-                debug!("Consensus client listener shutting down");
+                debug!("Client listener (TCP) shutting down");
                 break;
             }
             result = listener.accept().fuse() => {
@@ -88,7 +82,7 @@ pub async fn run(listener: TcpListener, token: ShutdownToken, on_accepted: Accep
                         on_accepted(stream);
                     }
                     Err(e) => {
-                        error!("Consensus client listener accept failed: {e}");
+                        error!("Client listener (TCP) accept failed: {e}");
                     }
                 }
             }
diff --git a/core/message_bus/src/client_listener/tcp_tls.rs b/core/message_bus/src/client_listener/tcp_tls.rs
new file mode 100644
index 000000000..963c5996f
--- /dev/null
+++ b/core/message_bus/src/client_listener/tcp_tls.rs
@@ -0,0 +1,136 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! TCP-TLS listener for SDK clients.
+//!
+//! Runs only on shard 0. The accept loop performs no TLS work: it accepts
+//! the underlying [`compio::net::TcpStream`] and hands it together with
+//! a clone of [`Arc<rustls::ServerConfig>`] to the supplied callback.
+//! The TLS handshake runs INSIDE the install path
+//! ([`crate::installer::install_client_tls_stream`]) so a slow or
+//! malicious peer cannot block subsequent accepts.
+//!
+//! TCP-TLS is shard-0 terminal. Pre-handshake the fd is plain TCP and
+//! could in principle be dup'd to another shard, but the receiving shard
+//! would then have to perform the rustls handshake against a config that
+//! lives on shard 0 — losing the point of the cross-shard handover.
+//! Post-handshake the rustls connection state holds per-record sequence
+//! numbers, key schedule, and write buffers tied to the local task, with
+//! no dupable plaintext fd.
+//!
+//! The TLS plane structurally cannot preserve `Frozen<MESSAGE_ALIGN>`
+//! ownership: rustls's encrypt step copies plaintext bytes into the
+//! outbound ciphertext buffer, so the zero-copy guarantee on plaintext
+//! TCP does not carry over.
+
+use crate::AcceptedTlsClientFn;
+use crate::lifecycle::ShutdownToken;
+use crate::transports::tls::{TlsServerCredentials, install_default_crypto_provider};
+use compio::net::{SocketOpts, TcpListener};
+use futures::FutureExt;
+use iggy_common::IggyError;
+use std::net::SocketAddr;
+use std::sync::Arc;
+use tracing::{debug, error, info};
+
+/// Bind the TCP-TLS listener and pre-build the shared
+/// [`Arc<rustls::ServerConfig>`] from the supplied credentials.
+///
+/// The returned config is cloned into every accepted connection so each
+/// install path drives its own `UnbufferedServerConnection` against the
+/// same key material. A single `ServerConfig` allocation is shared across
+/// all clients.
+///
+/// Like the WS pre-upgrade listener, `TCP_NODELAY` + `SO_KEEPALIVE` are
+/// applied at bind time; the install path re-applies on every accepted
+/// stream because Linux does not propagate listener options to accepted
+/// sockets.
+///
+/// # Errors
+///
+/// - [`IggyError::IoError`] if the rustls server config cannot be built
+///   from `credentials` (cert / key mismatch).
+/// - [`IggyError::CannotBindToSocket`] if the TCP bind fails.
+#[allow(clippy::future_not_send)]
+pub async fn bind(
+    addr: SocketAddr,
+    credentials: TlsServerCredentials,
+) -> Result<(TcpListener, Arc<rustls::ServerConfig>, SocketAddr), IggyError> {
+    install_default_crypto_provider();
+    let mut cfg = rustls::ServerConfig::builder()
+        .with_no_client_auth()
+        .with_single_cert(credentials.cert_chain, credentials.key_der)
+        .map_err(|e| IggyError::IoError(format!("TCP-TLS server config build failed: {e}")))?;
+    // Defense-in-depth alongside the quic plane: TLS 1.3 0-RTT is off by
+    // default for the in-process TCP-TLS transport. The driver does not
+    // expose an early-data API; pin to zero so a future rustls default
+    // cannot enable it accidentally.
+    cfg.max_early_data_size = 0;
+
+    let opts = SocketOpts::new().nodelay(true).keepalive(true);
+    let listener = TcpListener::bind_with_options(addr, &opts)
+        .await
+        .map_err(|_| IggyError::CannotBindToSocket(addr.to_string()))?;
+    let actual = listener
+        .local_addr()
+        .map_err(|e| IggyError::IoError(e.to_string()))?;
+    Ok((listener, Arc::new(cfg), actual))
+}
+
+/// Run the TCP-TLS listener accept loop until the shutdown token fires.
+///
+/// Each accepted [`compio::net::TcpStream`] is handed to `on_accepted`
+/// together with a clone of the shared [`Arc<rustls::ServerConfig>`].
+/// The callback owns the stream from that point on; production wiring
+/// routes through shard 0's coordinator (mints a `client_id`, builds
+/// the install context, calls
+/// [`crate::installer::install_client_tls_stream`]).
+#[allow(clippy::future_not_send)]
+pub async fn run(
+    listener: TcpListener,
+    config: Arc<rustls::ServerConfig>,
+    token: ShutdownToken,
+    on_accepted: AcceptedTlsClientFn,
+) {
+    info!(
+        "Client listener (TCP-TLS) accepting on {:?}",
+        listener.local_addr().ok()
+    );
+
+    loop {
+        futures::select! {
+            () = token.wait().fuse() => {
+                debug!("Client listener (TCP-TLS) shutting down");
+                break;
+            }
+            result = listener.accept().fuse() => {
+                match result {
+                    Ok((stream, peer_addr)) => {
+                        debug!(
+                            %peer_addr,
+                            "TCP-TLS client accepted, handing to installer"
+                        );
+                        on_accepted(stream, Arc::clone(&config));
+                    }
+                    Err(e) => {
+                        error!("Client listener (TCP-TLS) accept failed: {e}");
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/core/message_bus/src/client_listener.rs b/core/message_bus/src/client_listener/ws.rs
similarity index 53%
rename from core/message_bus/src/client_listener.rs
rename to core/message_bus/src/client_listener/ws.rs
index 75f13afe5..62807d0b8 100644
--- a/core/message_bus/src/client_listener.rs
+++ b/core/message_bus/src/client_listener/ws.rs
@@ -15,19 +15,27 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! TCP listener for consensus-protocol SDK clients.
+//! Pre-upgrade TCP listener for WebSocket SDK clients.
 //!
-//! Runs only on shard 0. Every accepted connection is handed to an
-//! `on_accepted` callback supplied by the shard bootstrap, which mints a
-//! client id (encoding the target shard in the top 16 bits), duplicates
-//! the fd, and ships the setup frame to the owning shard.
+//! Runs only on shard 0. The accept loop performs no protocol work
+//! beyond `TcpListener::accept`: every accepted stream is handed
+//! verbatim to the supplied callback, which dups the fd and ships a
+//! `ShardFramePayload::ClientWsConnectionSetup` frame to the
+//! round-robin-selected target shard. The HTTP-Upgrade handshake runs
+//! on the owning shard inside
+//! [`crate::ConnectionInstaller::install_client_ws_fd`]; no
+//! subprotocol negotiation is performed at the bus layer.
 //!
-//! Clients send `RequestHeader` messages and receive `ReplyHeader` messages.
-//! The owning shard's router handler wraps the duplicated fd and installs
-//! writer + reader tasks via [`crate::installer`].
+//! The fd at ship-time is plain TCP, so fd-delegation (TCP-only) is
+//! well-defined. The WS state machine only materialises after the
+//! upgrade, on the owning shard, where it can stay non-`Send`.
+//!
+//! `ShardFramePayload::ClientWsConnectionSetup` is defined in
+//! `core/shard/src/lib.rs`; the rustdoc cannot intra-link across crates
+//! without pulling `shard` in as a doc-only dep.
 
+use crate::AcceptedWsClientFn;
 use crate::lifecycle::ShutdownToken;
-use crate::{AcceptedClientFn, GenericHeader, Message};
 use compio::net::{SocketOpts, TcpListener};
 use futures::FutureExt;
 use iggy_common::IggyError;
@@ -35,22 +43,21 @@ use std::net::SocketAddr;
 use std::rc::Rc;
 use tracing::{debug, error, info};
 
-/// Callback preserved for the owning-shard install path in
-/// [`crate::installer`]. Dispatches `(client_id, request_message)` into the
-/// shard's consensus pipeline.
-pub type RequestHandler = Rc<dyn Fn(u128, Message<GenericHeader>)>;
-
-/// Bind the listener and return the bound address without starting the
-/// accept loop. Useful for tests that need the port before driving traffic.
+/// Bind the WS pre-upgrade TCP listener and return the bound address.
+///
+/// Mirrors [`crate::client_listener::tcp::bind`] in shape (`TCP_NODELAY` +
+/// `SO_KEEPALIVE` on by default). The receiving shard re-applies socket
+/// options on the dup'd fd via the existing client-install path, so
+/// kernel-level options propagate end-to-end.
 ///
 /// # Errors
 ///
 /// Returns [`IggyError::CannotBindToSocket`] if the bind fails.
 #[allow(clippy::future_not_send)]
 pub async fn bind(addr: SocketAddr) -> Result<(TcpListener, SocketAddr), IggyError> {
-    // `SO_REUSEPORT` intentionally not set: only shard 0 binds the client
+    // `SO_REUSEPORT` intentionally not set: only shard 0 binds the WS
     // listener. The shard-0 coordinator round-robins accepts to owning
-    // shards via `ShardFramePayload::ClientConnectionSetup`.
+    // shards via `ShardFramePayload::ClientWsConnectionSetup`.
     let opts = SocketOpts::new().nodelay(true).keepalive(true);
     let listener = TcpListener::bind_with_options(addr, &opts)
         .await
@@ -61,34 +68,34 @@ pub async fn bind(addr: SocketAddr) -> Result<(TcpListener, SocketAddr), IggyErr
     Ok((listener, actual))
 }
 
-/// Run the client listener accept loop until the shutdown token fires. The
-/// `on_accepted` callback owns the accepted stream from that point on.
-///
-/// # Security
+/// Run the WS pre-upgrade listener accept loop until the shutdown
+/// token fires.
 ///
-/// This listener has no authentication on accept. Deploy behind an
-/// authenticating reverse proxy or a trusted-network boundary.
+/// Each accepted [`compio::net::TcpStream`] is handed verbatim to
+/// `on_accepted` (no upgrade attempted here). The callback owns the
+/// stream from that point on.
 #[allow(clippy::future_not_send)]
-pub async fn run(listener: TcpListener, token: ShutdownToken, on_accepted: AcceptedClientFn) {
+pub async fn run(listener: TcpListener, token: ShutdownToken, on_accepted: AcceptedWsClientFn) {
     info!(
-        "Consensus client listener accepting on {:?}",
+        "Client listener (WS, pre-upgrade) accepting on {:?}",
         listener.local_addr().ok()
     );
 
+    let on_accepted: Rc<_> = on_accepted;
     loop {
         futures::select! {
             () = token.wait().fuse() => {
-                debug!("Consensus client listener shutting down");
+                debug!("Client listener (WS) shutting down");
                 break;
             }
             result = listener.accept().fuse() => {
                 match result {
                     Ok((stream, peer_addr)) => {
-                        debug!(%peer_addr, "client accepted, delegating fd");
+                        debug!(%peer_addr, "WS client TCP accepted, delegating fd pre-upgrade");
                         on_accepted(stream);
                     }
                     Err(e) => {
-                        error!("Consensus client listener accept failed: {e}");
+                        error!("Client listener (WS) accept failed: {e}");
                     }
                 }
             }
diff --git a/core/message_bus/src/client_listener/wss.rs b/core/message_bus/src/client_listener/wss.rs
new file mode 100644
index 000000000..f4dac690a
--- /dev/null
+++ b/core/message_bus/src/client_listener/wss.rs
@@ -0,0 +1,131 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! WSS (WebSocket-over-TLS) listener for SDK clients.
+//!
+//! Runs only on shard 0. The accept loop performs no TLS or HTTP-Upgrade
+//! work: it accepts the underlying [`compio::net::TcpStream`] and hands
+//! it together with a clone of [`Arc<rustls::ServerConfig>`] to the
+//! supplied callback. Both the rustls handshake and the WebSocket
+//! HTTP-Upgrade handshake run INSIDE the install path
+//! ([`crate::installer::install_client_wss_stream`] →
+//! [`crate::transports::wss::WssTransportConn`]'s `run` method) so a
+//! slow or malicious peer cannot block subsequent accepts.
+//!
+//! WSS is shard-0 terminal. The plain TCP fd that exists pre-handshake
+//! could in principle be dup'd to another shard, but the receiving
+//! shard would then have to perform both the TLS handshake and the WS
+//! upgrade against state that lives on shard 0 — losing the point of
+//! the cross-shard handover. Post-upgrade the rustls + tungstenite
+//! state machines hold per-record sequence numbers, key schedule,
+//! masking state, and write buffers tied to the local task, with no
+//! dupable plaintext fd.
+//!
+//! Like the TCP-TLS plane, WSS structurally cannot preserve
+//! `Frozen<MESSAGE_ALIGN>` ownership: rustls's encrypt step copies
+//! plaintext bytes into the outbound ciphertext buffer, and the WS
+//! frame builder concatenates header + body into a per-frame scratch
+//! before that copy. The zero-copy guarantee on plaintext TCP does not
+//! carry over.
+//!
+use crate::AcceptedWssClientFn;
+use crate::lifecycle::ShutdownToken;
+use crate::transports::tls::{TlsServerCredentials, install_default_crypto_provider};
+use compio::net::{SocketOpts, TcpListener};
+use futures::FutureExt;
+use iggy_common::IggyError;
+use std::net::SocketAddr;
+use std::sync::Arc;
+use tracing::{debug, error, info};
+
+/// Bind the WSS listener and pre-build the shared
+/// [`Arc<rustls::ServerConfig>`] from the supplied credentials.
+///
+/// Identical pattern to [`crate::client_listener::tcp_tls::bind`]: a single
+/// `ServerConfig` allocation is shared across all clients via
+/// [`Arc::clone`] on each accept.
+///
+/// # Errors
+///
+/// - [`IggyError::IoError`] if the rustls server config cannot be built
+///   from `credentials` (cert / key mismatch).
+/// - [`IggyError::CannotBindToSocket`] if the TCP bind fails.
+#[allow(clippy::future_not_send)]
+pub async fn bind(
+    addr: SocketAddr,
+    credentials: TlsServerCredentials,
+) -> Result<(TcpListener, Arc<rustls::ServerConfig>, SocketAddr), IggyError> {
+    install_default_crypto_provider();
+    let mut cfg = rustls::ServerConfig::builder()
+        .with_no_client_auth()
+        .with_single_cert(credentials.cert_chain, credentials.key_der)
+        .map_err(|e| IggyError::IoError(format!("WSS server config build failed: {e}")))?;
+    cfg.max_early_data_size = 0;
+
+    let opts = SocketOpts::new().nodelay(true).keepalive(true);
+    let listener = TcpListener::bind_with_options(addr, &opts)
+        .await
+        .map_err(|_| IggyError::CannotBindToSocket(addr.to_string()))?;
+    let actual = listener
+        .local_addr()
+        .map_err(|e| IggyError::IoError(e.to_string()))?;
+    Ok((listener, Arc::new(cfg), actual))
+}
+
+/// Run the WSS listener accept loop until the shutdown token fires.
+///
+/// Each accepted [`compio::net::TcpStream`] is handed to `on_accepted`
+/// together with a clone of the shared [`Arc<rustls::ServerConfig>`].
+/// The callback owns the stream from that point on; production wiring
+/// routes through shard 0's coordinator (mints a `client_id`, builds
+/// the install context, calls
+/// [`crate::installer::install_client_wss_stream`]).
+#[allow(clippy::future_not_send)]
+pub async fn run(
+    listener: TcpListener,
+    config: Arc<rustls::ServerConfig>,
+    token: ShutdownToken,
+    on_accepted: AcceptedWssClientFn,
+) {
+    info!(
+        "Client listener (WSS) accepting on {:?}",
+        listener.local_addr().ok()
+    );
+
+    loop {
+        futures::select! {
+            () = token.wait().fuse() => {
+                debug!("Client listener (WSS) shutting down");
+                break;
+            }
+            result = listener.accept().fuse() => {
+                match result {
+                    Ok((stream, peer_addr)) => {
+                        debug!(
+                            %peer_addr,
+                            "WSS client accepted, handing to installer"
+                        );
+                        on_accepted(stream, Arc::clone(&config));
+                    }
+                    Err(e) => {
+                        error!("Client listener (WSS) accept failed: {e}");
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/core/message_bus/src/config.rs b/core/message_bus/src/config.rs
index d99b3fb49..118c72069 100644
--- a/core/message_bus/src/config.rs
+++ b/core/message_bus/src/config.rs
@@ -26,13 +26,30 @@
 //! constructs [`crate::IggyMessageBus`] from `ServerConfig` lands.
 //! Keeping the type in-crate for now avoids churning the configs crate
 //! ahead of that wiring.
+//!
+//! Neither plane is authenticated at the bus layer: identity and
+//! credential checks belong to the caller (`core/server-ng`) via
+//! `LOGIN_*` commands. This struct therefore carries no secret /
+//! token-source state.
 
+use compio::ws::tungstenite::protocol::WebSocketConfig;
 use std::time::Duration;
 
+/// Hard upper bound on `max_batch`, in iovecs.
+///
+/// Linux's `IOV_MAX` is 1024 (`/usr/include/bits/uio_lim.h`). Future WS
+/// transports emit one iovec for the header and one for the body, so a
+/// batch of N messages costs `2 * N` iovecs; cap `max_batch` at
+/// `IOV_MAX / 2 = 512` to keep that worst case below the syscall limit.
+/// Bus construction asserts this in [`crate::IggyMessageBus::with_config`];
+/// breaching it at boot panics rather than silently delivering writev
+/// `EMSGSIZE` errors on every batch.
+pub const IOV_MAX_LIMIT: usize = 512;
+
 /// Aggregated runtime configuration for an `IggyMessageBus` instance.
 ///
 /// All fields map onto constants that previously lived inline across
-/// `writer_task`, `framing`, `socket_opts`, `installer`, `connector`,
+/// `transports::tcp`, `framing`, `socket_opts`, `installer`, `connector`,
 /// and `lifecycle::connection_registry`. [`Default`] returns the prior
 /// hardcoded values so existing call sites (tests, single-shard
 /// simulators) can migrate without behaviour change.
@@ -70,6 +87,29 @@ pub struct MessageBusConfig {
     /// Timeout for per-peer close drain (flush writer, tear down
     /// reader) before force-cancellation.
     pub close_peer_timeout: Duration,
+
+    /// Wall-clock bound on a single `stream.shutdown()` (or `ws.close()`)
+    /// invocation in the safe-shutdown sequence. Threaded into the
+    /// single-task close path of the TLS-family transports
+    /// (`transports::tcp_tls`, `transports::wss`) and consumed inside
+    /// `compio::time::timeout(close_grace, ...)`.
+    ///
+    /// Independent of [`Self::close_peer_timeout`] (which bounds the
+    /// registry-level drain over both reader and writer joins).
+    pub close_grace: Duration,
+
+    /// WebSocket frame-layer tunables (read/write buffer sizes, max
+    /// frame size, max message size, accept-unmasked-frames flag).
+    /// Threaded into `compio_ws::accept_async_with_config` on the WS
+    /// install path and into `WssTransportConn::ws_handshake` for WSS.
+    /// `WebSocketConfig::default()` matches `tungstenite`'s defaults
+    /// (effectively unbounded message size, masked-frames-only).
+    ///
+    /// Server-ng builds this from `configs::server_config::websocket::WebSocketConfig`
+    /// (note: that type uses the standalone `tungstenite` crate which
+    /// may differ in major version from the one re-exported by
+    /// `compio_ws`; the conversion happens at the bootstrap layer).
+    pub ws_config: WebSocketConfig,
 }
 
 impl Default for MessageBusConfig {
@@ -88,6 +128,8 @@ impl Default for MessageBusConfig {
             keepalive_interval: Duration::from_secs(5),
             keepalive_retries: 3,
             close_peer_timeout: Duration::from_secs(2),
+            close_grace: Duration::from_secs(2),
+            ws_config: WebSocketConfig::default(),
         }
     }
 }
diff --git a/core/message_bus/src/connector.rs b/core/message_bus/src/connector.rs
index 641d50683..1b702bdb8 100644
--- a/core/message_bus/src/connector.rs
+++ b/core/message_bus/src/connector.rs
@@ -18,11 +18,17 @@
 //! Outbound replica connector.
 //!
 //! Runs only on shard 0. For each peer replica with `peer_id > self_id` the
-//! connector dials at startup and re-dials on a periodic sweep. On a
-//! successful `Ping` handshake the accepted stream is handed to the
-//! `on_dialed` callback supplied by the shard bootstrap, which duplicates
-//! the fd and ships it to the owning shard via the inter-shard channel
+//! connector dials at startup and re-dials on a periodic sweep. After a
+//! successful TCP connect the connector sends a plaintext `Ping` frame
+//! announcing this replica's id and `cluster_id` and hands the stream
+//! to the `on_dialed` callback supplied by the shard bootstrap, which
+//! duplicates the fd and ships it to the owning shard via the
+//! inter-shard channel
 //! (see `shard::coordinator::ShardZeroCoordinator`).
+//!
+//! No transport-level authentication: the future `LOGIN_REPLICA`
+//! command in the caller (`server-ng`) carries the cluster shared
+//! secret post-connect.
 
 use crate::IggyMessageBus;
 use crate::framing;
@@ -41,7 +47,7 @@ use tracing::{debug, info, warn};
 ///
 /// Equivalent to `MessageBusConfig::default().reconnect_period`; exposed
 /// as a named const for test / bench ergonomics. Kept in sync with the
-/// [`MessageBusConfig::default`] impl. Remove once the configs-crate
+/// `MessageBusConfig::default` impl. Remove once the configs-crate
 /// migration lands and bootstrap always reads the period from
 /// `ServerConfig`.
 pub const DEFAULT_RECONNECT_PERIOD: Duration = Duration::from_secs(5);
@@ -95,9 +101,11 @@ async fn connect_all(
         // connection lives on shard 0; `owning_shard` covers multi-shard
         // deployments where the fd was delegated to a peer shard but the
         // mapping broadcast reached shard 0. Either hit means a previous
-        // sweep (or the inbound listener) already installed this peer and
-        // we must not dial again - redialing would tear down the live
-        // socket via the `AlreadyRegistered` race and flap the mapping.
+        // sweep (or the inbound listener) already installed this peer.
+        // Redialing wastes a TCP round-trip; the live entry stays intact
+        // (the loser of the registry insert race in `install_replica_conn`
+        // gets `RejectedRegistration` back and tears DOWN ITS OWN orphan
+        // tasks via `drain_rejected_registration`, never the winner's).
         if bus.replicas().contains(peer_id) || bus.owning_shard(peer_id).is_some() {
             debug!(
                 replica = peer_id,
@@ -125,9 +133,10 @@ async fn periodic_reconnect(
     debug!("replica reconnect periodic task exiting");
 }
 
-/// Dial a single peer, send the `Ping` handshake, and hand the stream to
-/// `on_dialed` on success. Dial / handshake failures are logged and
-/// swallowed; VSR tolerates missing peers and the periodic sweep retries.
+/// Dial a single peer, send the plaintext `Ping` frame, and hand the
+/// stream to `on_dialed` on success. Dial / write failures are logged
+/// and swallowed; VSR tolerates missing peers and the periodic sweep
+/// retries.
 #[allow(clippy::future_not_send)]
 async fn connect_one(
     bus: &Rc<IggyMessageBus>,
@@ -170,7 +179,9 @@ async fn connect_one(
     on_dialed(stream, peer_id);
 }
 
-/// Build a Ping handshake message identifying our replica id.
+/// Build a plaintext `Ping` frame announcing this replica's id and
+/// `cluster_id`. No nonce, no timestamp, no MAC: cluster-secret
+/// validation moves to the future `LOGIN_REPLICA` command in the caller.
 fn build_ping_message(cluster_id: u128, replica_id: u8) -> Message<GenericHeader> {
     #[allow(clippy::cast_possible_truncation)]
     Message::<GenericHeader>::new(size_of::<GenericHeader>()).transmute_header(
diff --git a/core/message_bus/src/framing.rs b/core/message_bus/src/framing.rs
index 391a86aa2..1ba01d71c 100644
--- a/core/message_bus/src/framing.rs
+++ b/core/message_bus/src/framing.rs
@@ -28,6 +28,7 @@ use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
 use iggy_binary_protocol::consensus::iobuf::Owned;
 use iggy_binary_protocol::{GenericHeader, HEADER_SIZE, Message};
 use iggy_common::IggyError;
+use tracing::error;
 
 /// Default hard ceiling on a single wire frame. Frames above this are
 /// almost certainly a protocol violation or a malicious peer; we drop
@@ -52,15 +53,16 @@ const _: () = {
 /// Write a consensus message to a stream. Zero-copy: the message's owned
 /// buffer is handed straight to `io_uring` as a `Frozen`.
 ///
-/// Used by the handshake and framing test paths. Hot-path bus traffic goes
-/// through [`crate::writer_task`] which batches many messages into a single
-/// `writev` instead.
+/// Used by the handshake and framing test paths. Hot-path bus traffic
+/// goes through the per-transport writer task (see
+/// [`crate::transports::tcp`]) which batches many messages into a
+/// single `writev` instead.
 ///
 /// # Errors
 ///
 /// Returns `IggyError::TcpError` if the write fails.
 #[allow(clippy::future_not_send)]
-pub async fn write_message<S: AsyncWriteExt + Unpin>(
+pub async fn write_message<S: AsyncWriteExt>(
     stream: &mut S,
     message: Message<GenericHeader>,
 ) -> Result<(), IggyError> {
@@ -89,7 +91,7 @@ pub async fn write_message<S: AsyncWriteExt + Unpin>(
 /// Returns `IggyError::TcpError` on I/O errors.
 /// Returns `IggyError::InvalidCommand` if the header fails validation.
 #[allow(clippy::future_not_send)]
-pub async fn read_message<S: AsyncReadExt + Unpin>(
+pub async fn read_message<S: AsyncReadExt>(
     stream: &mut S,
     max_message_size: usize,
 ) -> Result<Message<GenericHeader>, IggyError> {
@@ -126,7 +128,17 @@ pub async fn read_message<S: AsyncReadExt + Unpin>(
     // (e.g. capacity overflow, unsupported buffer kind) and silently
     // ignoring it would leave `owned` at header-only capacity, causing the
     // subsequent `read_exact` to read into an ungrown buffer.
-    IoBufMut::reserve_exact(&mut owned, body_size).map_err(|_| IggyError::TcpError)?;
+    //
+    // No `IggyError::OutOfMemory` variant exists; `TcpError` is the closest
+    // bucket. The `error!` log gives operators the actionable diagnostic
+    // (size, OOM-vs-overflow signal) the error code itself loses.
+    IoBufMut::reserve_exact(&mut owned, body_size).map_err(|_| {
+        error!(
+            body_size,
+            "framing reserve_exact failed (OOM or capacity overflow); dropping connection"
+        );
+        IggyError::TcpError
+    })?;
     let BufResult(result, slice) = stream
         .read_exact(owned.slice(HEADER_SIZE..total_size))
         .await;
diff --git a/core/message_bus/src/installer.rs b/core/message_bus/src/installer.rs
deleted file mode 100644
index 3ab6d615a..000000000
--- a/core/message_bus/src/installer.rs
+++ /dev/null
@@ -1,542 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-//! Connection installer trait.
-//!
-//! Shard 0 accepts / dials all TCP connections and ships the duplicated fd
-//! to the owning shard via the inter-shard `ShardFrame` channel. The owning
-//! shard's router handler wraps the fd on its own compio runtime and
-//! registers the connection on its local bus. This trait exposes that
-//! registration surface in a way the shard layer can call without knowing
-//! the concrete bus type.
-
-use crate::client_listener::RequestHandler;
-use crate::fd_transfer::{self, DupedFd};
-use crate::framing;
-use crate::lifecycle::{InstanceToken, RejectedRegistration, Shutdown};
-use crate::replica_listener::MessageHandler;
-use crate::socket_opts::{apply_keepalive_for_connection, apply_nodelay_for_connection};
-use crate::{IggyMessageBus, lifecycle::ShutdownToken};
-use compio::net::{OwnedReadHalf, TcpStream};
-use futures::FutureExt;
-use iggy_binary_protocol::Command2;
-use std::cell::Cell;
-use std::rc::Rc;
-use std::time::{Duration, Instant};
-use tracing::{debug, info, warn};
-
-/// Operations a shard needs to perform on its local bus when the router
-/// receives an inter-shard connection-setup or mapping frame.
-///
-/// The production implementation is on `Rc<IggyMessageBus>`. The simulator
-/// does not exercise this path; if it ever does, add a no-op impl on
-/// `SharedSimOutbox`.
-pub trait ConnectionInstaller {
-    /// Wrap a duplicated TCP fd into a `TcpStream` on the local compio
-    /// runtime, spawn writer + reader tasks, and register the replica
-    /// connection on this shard.
-    ///
-    /// Takes ownership of `fd`. On registration failure the fd is closed
-    /// by dropping the wrapping `TcpStream`; on caller-side failure (e.g.
-    /// inter-shard send drops the setup frame) the `DupedFd` closes the
-    /// fd on drop.
-    fn install_replica_fd(&self, fd: DupedFd, replica_id: u8, on_message: MessageHandler);
-
-    /// Same for an SDK client connection. The owning shard is already
-    /// encoded in the top 16 bits of `client_id`.
-    fn install_client_fd(&self, fd: DupedFd, client_id: u128, on_request: RequestHandler);
-
-    /// Update the replica -> owning shard mapping used by the `send_to_replica`
-    /// slow path on non-owning shards.
-    fn set_shard_mapping(&self, replica: u8, owning_shard: u16);
-
-    /// Forget the replica -> owning shard mapping (e.g. after a connection
-    /// loss, before the next allocate).
-    fn remove_shard_mapping(&self, replica: u8);
-}
-
-impl ConnectionInstaller for Rc<IggyMessageBus> {
-    fn install_replica_fd(&self, fd: DupedFd, replica_id: u8, on_message: MessageHandler) {
-        let stream = fd_transfer::wrap_duped_fd(fd);
-        install_replica_stream(self, replica_id, stream, on_message);
-    }
-
-    fn install_client_fd(&self, fd: DupedFd, client_id: u128, on_request: RequestHandler) {
-        let stream = fd_transfer::wrap_duped_fd(fd);
-        install_client_stream(self, client_id, stream, on_request);
-    }
-
-    fn set_shard_mapping(&self, replica: u8, owning_shard: u16) {
-        IggyMessageBus::set_shard_mapping(self, replica, owning_shard);
-    }
-
-    fn remove_shard_mapping(&self, replica: u8) {
-        IggyMessageBus::remove_shard_mapping(self, replica);
-    }
-}
-
-/// Install a pre-wrapped replica TCP stream on the bus. Shared by
-/// `install_replica_fd` and, once shard 0 acts as the delegate target for
-/// itself, the accept path.
-#[allow(clippy::future_not_send, clippy::too_many_lines)]
-pub fn install_replica_stream(
-    bus: &Rc<IggyMessageBus>,
-    peer_id: u8,
-    stream: TcpStream,
-    on_message: MessageHandler,
-) {
-    if bus.replicas().contains(peer_id) {
-        debug!(
-            replica = peer_id,
-            "replica already registered on this shard, dropping delegated fd"
-        );
-        drop(stream);
-        return;
-    }
-
-    let cfg = bus.config();
-    if let Err(e) = apply_keepalive_for_connection(
-        &stream,
-        cfg.keepalive_idle,
-        cfg.keepalive_interval,
-        cfg.keepalive_retries,
-    ) {
-        warn!(replica = peer_id, "keepalive failed on delegated fd: {e}");
-    }
-    if let Err(e) = apply_nodelay_for_connection(&stream) {
-        // Linux does not propagate TCP_NODELAY from the listener to the
-        // accepted fd, so we toggle it here on every installed stream.
-        // A miss means we stay Nagle-on for this peer, not a failure.
-        warn!(replica = peer_id, "nodelay failed on delegated fd: {e}");
-    }
-
-    let (read_half, write_half) = stream.into_split();
-    let (tx, rx) = async_channel::bounded(bus.peer_queue_capacity());
-
-    // Writer and reader both observe abnormal close and used to fire
-    // `notify_connection_lost` twice per disconnect, causing shard 0 to
-    // broadcast two `ReplicaMappingClear` rounds and churn the mapping.
-    // Shared one-shot guard: whichever half exits first wins.
-    let notified = Rc::new(Cell::new(false));
-    // If the registry insert below races with a concurrent install for
-    // the same peer id and loses, both spawned halves must skip their
-    // post-loop cleanup: calling `replicas().remove` / `close_peer` or
-    // `notify_connection_lost` would evict the winner's entry and
-    // clobber its mapping broadcast. `compio::runtime::JoinHandle::drop`
-    // does not cancel the spawned task, so we have to tell the tasks to
-    // stand down in-band.
-    let install_aborted = Rc::new(Cell::new(false));
-
-    // Generation token published by the registry on a successful insert.
-    // Writer and reader post-loops release the slot only when the stored
-    // token matches; a stale-install exit that wakes up after a later
-    // reinstall would otherwise evict the new slot.
-    let install_token: Rc<Cell<Option<InstanceToken>>> = Rc::new(Cell::new(None));
-
-    // Per-connection shutdown used to kick the reader off its
-    // `io_uring` read SQE when the registry insert below loses a race.
-    // The bus-wide token cannot be triggered here (it would tear down
-    // every other connection); closing the writer's sender also does not
-    // reach a reader blocked on `framing::read_message`. The `Shutdown`
-    // is moved into the registry entry on success so its `Sender` survives
-    // the connection's lifetime: dropping the `Shutdown` would close the
-    // broadcast channel and falsely wake the reader's `select!` arm.
-    // On insert race the loser receives the `Shutdown` back via
-    // `RejectedRegistration` and triggers it before draining the orphan
-    // tasks.
-    let (conn_shutdown, conn_token) = Shutdown::new();
-
-    let writer_token = bus.token();
-    let bus_for_writer = Rc::clone(bus);
-    let writer_label = format!("{peer_id}");
-    let notified_writer = Rc::clone(&notified);
-    let aborted_writer = Rc::clone(&install_aborted);
-    let token_for_writer = Rc::clone(&install_token);
-    let max_batch = bus.config().max_batch;
-    let writer_handle = compio::runtime::spawn(async move {
-        crate::writer_task::run(
-            rx,
-            write_half,
-            writer_token,
-            "replica",
-            writer_label,
-            max_batch,
-        )
-        .await;
-        if aborted_writer.get() || bus_for_writer.is_shutting_down() {
-            return;
-        }
-        let Some(token) = token_for_writer.get() else {
-            return;
-        };
-        if !bus_for_writer
-            .replicas()
-            .remove_if_token_matches(peer_id, token)
-        {
-            return;
-        }
-        if !notified_writer.replace(true) {
-            bus_for_writer.notify_connection_lost(peer_id);
-        }
-    });
-
-    let bus_for_reader = Rc::clone(bus);
-    let read_token = bus.token();
-    let notified_reader = Rc::clone(&notified);
-    let aborted_reader = Rc::clone(&install_aborted);
-    let token_for_reader = Rc::clone(&install_token);
-    let close_peer_timeout = bus.config().close_peer_timeout;
-    let max_message_size = bus.config().max_message_size;
-    let reader_handle = compio::runtime::spawn(async move {
-        replica_read_loop(
-            peer_id,
-            read_half,
-            &on_message,
-            &read_token,
-            &conn_token,
-            &aborted_reader,
-            max_message_size,
-        )
-        .await;
-        if aborted_reader.get() {
-            debug!(
-                replica = peer_id,
-                "aborted replica install: skipping post-loop cleanup"
-            );
-            return;
-        }
-        if !read_token.is_triggered() {
-            let Some(token) = token_for_reader.get() else {
-                return;
-            };
-            let closed = bus_for_reader
-                .replicas()
-                .close_peer_if_token_matches(peer_id, token, close_peer_timeout)
-                .await;
-            if closed && !notified_reader.replace(true) {
-                bus_for_reader.notify_connection_lost(peer_id);
-            }
-        }
-        info!(replica = peer_id, "peer replica disconnected");
-    });
-
-    match bus
-        .replicas()
-        .insert(peer_id, tx, writer_handle, reader_handle, conn_shutdown)
-    {
-        Ok(token) => {
-            install_token.set(Some(token));
-        }
-        Err(rejected) => {
-            // Tell both halves to stand down: the winner's entry is live and
-            // must not be touched by this losing install.
-            install_aborted.set(true);
-            warn!(replica = peer_id, "replica registry insert raced");
-            // `drain_rejected_registration` triggers the per-connection
-            // shutdown (returned with `rejected`) to wake the reader off
-            // its `io_uring` read SQE, then awaits writer / reader
-            // handles. `compio::runtime::JoinHandle::drop` only detaches,
-            // so without this drain the reader would outlive the race on
-            // a half-open socket until peer EOF. Hand the handle to
-            // `track_background` so `IggyMessageBus::shutdown` awaits the
-            // drain before returning - `.detach()` would orphan it and
-            // leak the half-closed socket across shutdown.
-            let drain_handle =
-                compio::runtime::spawn(drain_rejected_registration(rejected, close_peer_timeout));
-            bus.track_background(drain_handle);
-        }
-    }
-}
-
-/// Install a pre-wrapped client TCP stream on the bus.
-#[allow(clippy::future_not_send, clippy::too_many_lines)]
-pub fn install_client_stream(
-    bus: &Rc<IggyMessageBus>,
-    client_id: u128,
-    stream: TcpStream,
-    on_request: RequestHandler,
-) {
-    let cfg = bus.config();
-    if let Err(e) = apply_keepalive_for_connection(
-        &stream,
-        cfg.keepalive_idle,
-        cfg.keepalive_interval,
-        cfg.keepalive_retries,
-    ) {
-        warn!(
-            client = client_id,
-            "keepalive failed on delegated client fd: {e}"
-        );
-    }
-    if let Err(e) = apply_nodelay_for_connection(&stream) {
-        warn!(
-            client = client_id,
-            "nodelay failed on delegated client fd: {e}"
-        );
-    }
-
-    let (read_half, write_half) = stream.into_split();
-    let (tx, rx) = async_channel::bounded(bus.peer_queue_capacity());
-
-    // If the registry insert below loses a race for `client_id`, the
-    // losing reader must NOT invoke `on_request` (it would route
-    // responses through the wrong registry entry) and must NOT call
-    // `close_peer` (it would evict the winner). See the replica path
-    // above for the same pattern.
-    let install_aborted = Rc::new(Cell::new(false));
-
-    // See replica path for the rationale on instance-token fencing.
-    let install_token: Rc<Cell<Option<InstanceToken>>> = Rc::new(Cell::new(None));
-
-    // Per-connection shutdown for fast reader wake on insert race; see
-    // the replica installer for the full rationale.
-    let (conn_shutdown, conn_token) = Shutdown::new();
-
-    let writer_token = bus.token();
-    let bus_for_writer = Rc::clone(bus);
-    let writer_label = format!("{client_id:#034x}");
-    let aborted_writer = Rc::clone(&install_aborted);
-    let token_for_writer = Rc::clone(&install_token);
-    let max_batch = bus.config().max_batch;
-    let writer_handle = compio::runtime::spawn(async move {
-        crate::writer_task::run(
-            rx,
-            write_half,
-            writer_token,
-            "client",
-            writer_label,
-            max_batch,
-        )
-        .await;
-        if aborted_writer.get() || bus_for_writer.is_shutting_down() {
-            return;
-        }
-        let Some(token) = token_for_writer.get() else {
-            return;
-        };
-        bus_for_writer
-            .clients()
-            .remove_if_token_matches(client_id, token);
-    });
-
-    let bus_for_reader = Rc::clone(bus);
-    let read_token = bus.token();
-    let aborted_reader = Rc::clone(&install_aborted);
-    let token_for_reader = Rc::clone(&install_token);
-    let close_peer_timeout = bus.config().close_peer_timeout;
-    let max_message_size = bus.config().max_message_size;
-    let reader_handle = compio::runtime::spawn(async move {
-        client_read_loop(
-            client_id,
-            read_half,
-            &on_request,
-            &read_token,
-            &conn_token,
-            &aborted_reader,
-            max_message_size,
-        )
-        .await;
-        if aborted_reader.get() {
-            debug!(
-                client = client_id,
-                "aborted client install: skipping post-loop cleanup"
-            );
-            return;
-        }
-        if !read_token.is_triggered() {
-            let Some(token) = token_for_reader.get() else {
-                return;
-            };
-            bus_for_reader
-                .clients()
-                .close_peer_if_token_matches(client_id, token, close_peer_timeout)
-                .await;
-        }
-        info!(client = client_id, "consensus client disconnected");
-    });
-
-    match bus
-        .clients()
-        .insert(client_id, tx, writer_handle, reader_handle, conn_shutdown)
-    {
-        Ok(token) => {
-            install_token.set(Some(token));
-        }
-        Err(rejected) => {
-            // Shard 0 mints client ids as `(target_shard << 112) | seq` with a
-            // monotonic `seq` starting at 1, so wrap requires 2^112 mints and
-            // a collision here is a bootstrap bug or a foreign id leaking
-            // into the setup path. Flip `install_aborted` so the orphan
-            // reader drops inbound frames instead of forwarding them via
-            // `on_request` (which would route responses through the winner's
-            // entry and silently misroute).
-            install_aborted.set(true);
-            warn!(
-                client_id,
-                "duplicate client id in registry, dropping delegated fd \
-                 (shard 0 counter invariant violated)"
-            );
-            // See replica installer for the track_background rationale.
-            let drain_handle =
-                compio::runtime::spawn(drain_rejected_registration(rejected, close_peer_timeout));
-            bus.track_background(drain_handle);
-        }
-    }
-}
-
-/// Drive a losing-insert's writer + reader [`JoinHandle`]s to completion
-/// (or force-cancel at the deadline).
-///
-/// The winning entry must never be touched here; `install_aborted` has
-/// already told both tasks to skip post-loop cleanup. Closing the
-/// sender wakes the writer; triggering the per-connection shutdown
-/// wakes the reader off its `io_uring` read SQE without waiting for
-/// peer EOF. Awaiting both handles with `close_peer_timeout` budget
-/// guarantees the reader cannot outlive the race on a half-open
-/// socket. `compio::runtime::JoinHandle::drop` detaches, so letting
-/// the handles go out of scope would leak the tasks.
-///
-/// Both handles share a single `timeout` budget: after the writer returns
-/// (or is cancelled) the reader only gets the remaining time. Two
-/// independent full-timeout awaits would let a stuck loser occupy up to
-/// `2 * timeout` of shutdown wall-clock on its own.
-#[allow(clippy::future_not_send)]
-async fn drain_rejected_registration(rejected: RejectedRegistration, timeout: Duration) {
-    let RejectedRegistration {
-        sender,
-        writer_handle,
-        reader_handle,
-        conn_shutdown,
-    } = rejected;
-    sender.close();
-    conn_shutdown.trigger();
-    let deadline = Instant::now() + timeout;
-    let _ = compio::time::timeout(timeout, writer_handle).await;
-    let remaining = deadline.saturating_duration_since(Instant::now());
-    if !remaining.is_zero() {
-        let _ = compio::time::timeout(remaining, reader_handle).await;
-    }
-}
-
-/// Read loop for a delegated replica connection. Identical to the
-/// `replica_listener` version but kept here to avoid cross-module coupling.
-///
-/// `aborted` is set by the installer when the registry insert loses a
-/// duplicate-replica-id race. The loop checks it before dispatching each
-/// message so the losing reader can never invoke `on_message` with the
-/// replica id owned by the winning install — otherwise two physical peers
-/// would feed the same VSR slot and break replication safety.
-///
-/// `conn_token` is a per-connection shutdown the installer triggers from
-/// the insert-race path: it wakes the reader off its `io_uring` read SQE
-/// immediately rather than waiting for peer EOF.
-#[allow(clippy::future_not_send)]
-async fn replica_read_loop(
-    replica_id: u8,
-    mut read_half: OwnedReadHalf<TcpStream>,
-    on_message: &MessageHandler,
-    token: &ShutdownToken,
-    conn_token: &ShutdownToken,
-    aborted: &Cell<bool>,
-    max_message_size: usize,
-) {
-    loop {
-        futures::select! {
-            () = token.wait().fuse() => {
-                debug!(replica = replica_id, "replica read loop shutting down");
-                return;
-            }
-            () = conn_token.wait().fuse() => {
-                debug!(replica = replica_id, "replica read loop aborted by per-connection shutdown");
-                return;
-            }
-            result = framing::read_message(&mut read_half, max_message_size).fuse() => {
-                match result {
-                    Ok(msg) => {
-                        if aborted.get() {
-                            return;
-                        }
-                        on_message(replica_id, msg);
-                    }
-                    Err(e) => {
-                        debug!(replica = replica_id, "read error: {e}");
-                        return;
-                    }
-                }
-            }
-        }
-    }
-}
-
-/// Read loop for a delegated client connection. Rejects any command other
-/// than `Request` (the client side of the consensus protocol only speaks
-/// request/reply).
-///
-/// `aborted` is set by the installer when the registry insert loses a
-/// duplicate-client-id race. The loop checks it before dispatching each
-/// request so the losing reader can never invoke `on_request` with the
-/// client id owned by the winning install.
-///
-/// `conn_token` is a per-connection shutdown the installer triggers from
-/// the insert-race path: it wakes the reader off its `io_uring` read SQE
-/// immediately rather than waiting for peer EOF.
-#[allow(clippy::future_not_send)]
-async fn client_read_loop(
-    client_id: u128,
-    mut read_half: OwnedReadHalf<TcpStream>,
-    on_request: &RequestHandler,
-    token: &ShutdownToken,
-    conn_token: &ShutdownToken,
-    aborted: &Cell<bool>,
-    max_message_size: usize,
-) {
-    loop {
-        futures::select! {
-            () = token.wait().fuse() => {
-                debug!(client = client_id, "client read loop shutting down");
-                return;
-            }
-            () = conn_token.wait().fuse() => {
-                debug!(client = client_id, "client read loop aborted by per-connection shutdown");
-                return;
-            }
-            result = framing::read_message(&mut read_half, max_message_size).fuse() => {
-                match result {
-                    Ok(msg) => {
-                        if aborted.get() {
-                            return;
-                        }
-                        let cmd = msg.header().command;
-                        if cmd != Command2::Request {
-                            warn!(
-                                client = client_id,
-                                ?cmd,
-                                "unexpected command from client, expected Request"
-                            );
-                            continue;
-                        }
-                        on_request(client_id, msg);
-                    }
-                    Err(e) => {
-                        debug!(client = client_id, "client read error: {e}");
-                        return;
-                    }
-                }
-            }
-        }
-    }
-}
diff --git a/core/message_bus/src/installer/common.rs b/core/message_bus/src/installer/common.rs
new file mode 100644
index 000000000..918c23ad5
--- /dev/null
+++ b/core/message_bus/src/installer/common.rs
@@ -0,0 +1,57 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Shared install-path helpers used by both the replica and client
+//! install paths.
+
+use crate::lifecycle::RejectedRegistration;
+use std::time::{Duration, Instant};
+
+/// Drive a losing-insert's transport + dispatch [`compio::runtime::JoinHandle`]s
+/// to completion (or force-cancel at the deadline).
+///
+/// The winning entry must never be touched here; `install_aborted` has
+/// already told both tasks to skip post-loop cleanup. Closing the
+/// sender wakes the transport's writer; triggering the per-connection
+/// shutdown wakes the transport's reader off its `io_uring` read SQE
+/// without waiting for peer EOF. Awaiting both handles with
+/// `close_peer_timeout` budget guarantees neither task can outlive the
+/// race on a half-open socket. `compio::runtime::JoinHandle::drop`
+/// detaches, so letting the handles go out of scope would leak the
+/// tasks.
+///
+/// Both handles share a single `timeout` budget: after the transport
+/// returns (or is cancelled) the dispatch only gets the remaining time.
+/// Two independent full-timeout awaits would let a stuck loser occupy
+/// up to `2 * timeout` of shutdown wall-clock on its own.
+#[allow(clippy::future_not_send)]
+pub(super) async fn drain_rejected_registration(rejected: RejectedRegistration, timeout: Duration) {
+    let RejectedRegistration {
+        sender,
+        writer_handle,
+        reader_handle,
+        conn_shutdown,
+    } = rejected;
+    sender.close();
+    conn_shutdown.trigger();
+    let deadline = Instant::now() + timeout;
+    let _ = compio::time::timeout(timeout, writer_handle).await;
+    let remaining = deadline.saturating_duration_since(Instant::now());
+    if !remaining.is_zero() {
+        let _ = compio::time::timeout(remaining, reader_handle).await;
+    }
+}
diff --git a/core/message_bus/src/installer/conn_info.rs b/core/message_bus/src/installer/conn_info.rs
new file mode 100644
index 000000000..dba906b9a
--- /dev/null
+++ b/core/message_bus/src/installer/conn_info.rs
@@ -0,0 +1,114 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Per-client transport metadata exposed to the caller (`server-ng`).
+//!
+//! Constructed by the listener / install path on shard 0 with whatever
+//! is known at accept time (`client_id`, `peer_addr`, `transport`) and
+//! optionally extended post-handshake with TLS / QUIC / WS details. The
+//! bus stores it in a side-table keyed by `client_id`; the caller
+//! retrieves it via [`crate::IggyMessageBus::client_meta`] when it
+//! needs to make per-connection policy decisions (rate limiting,
+//! audit, transport-aware command gating).
+
+use std::net::SocketAddr;
+
+/// Wire transport carrying a given client connection.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ClientTransportKind {
+    Tcp,
+    TcpTls,
+    Ws,
+    Wss,
+    Quic,
+}
+
+/// TLS handshake details for [`ClientTransportKind::TcpTls`] and
+/// [`ClientTransportKind::Wss`].
+///
+/// Reserved field: the install path does not populate it today
+/// (`tls` stays `None` on the meta record). A future PR will extract
+/// SNI + negotiated ALPN from the rustls connection post-handshake.
+/// Empty / `None` values will mean the rustls connection did not
+/// expose that property (e.g. ALPN was not negotiated).
+#[derive(Debug, Clone, Default)]
+pub struct TlsConnectionInfo {
+    /// SNI hostname the client sent in `ClientHello`, if any.
+    pub sni: Option<String>,
+    /// Negotiated ALPN protocol, if any. The bus does not advertise an
+    /// ALPN itself; this is whatever the rustls config negotiated.
+    pub alpn: Option<Vec<u8>>,
+}
+
+/// QUIC connection details for [`ClientTransportKind::Quic`].
+///
+/// Reserved field: the install path does not populate it today
+/// (`quic` stays `None` on the meta record). A future PR will fill
+/// the RTT estimate from `Connection::stats()` post-handshake.
+#[derive(Debug, Clone, Default)]
+pub struct QuicConnectionInfo {
+    /// Smoothed round-trip time estimate at install time.
+    pub rtt: Option<std::time::Duration>,
+}
+
+/// WebSocket upgrade details for [`ClientTransportKind::Ws`] and
+/// [`ClientTransportKind::Wss`].
+///
+/// Reserved field: the install path does not populate it today
+/// (`ws` stays `None` on the meta record). A future PR will capture
+/// the `User-Agent` from the upgrade request headers.
+#[derive(Debug, Clone, Default)]
+pub struct WsUpgradeInfo {
+    /// `User-Agent` header value, if any. Used for audit.
+    pub user_agent: Option<String>,
+}
+
+/// Per-connection metadata the bus surfaces to its caller.
+///
+/// `peer_addr` and `transport` are known at accept time; the optional
+/// sub-info structs are populated post-handshake by the corresponding
+/// install path (or stay `None` until a future PR wires them).
+#[derive(Debug, Clone)]
+pub struct ClientConnMeta {
+    pub client_id: u128,
+    pub peer_addr: SocketAddr,
+    pub transport: ClientTransportKind,
+    pub tls: Option<TlsConnectionInfo>,
+    pub quic: Option<QuicConnectionInfo>,
+    pub ws: Option<WsUpgradeInfo>,
+}
+
+impl ClientConnMeta {
+    /// Construct a minimal meta record with only the eagerly-known
+    /// fields. Sub-info structs default to `None` and may be filled in
+    /// by the install path after the corresponding handshake completes.
+    #[must_use]
+    pub const fn new(
+        client_id: u128,
+        peer_addr: SocketAddr,
+        transport: ClientTransportKind,
+    ) -> Self {
+        Self {
+            client_id,
+            peer_addr,
+            transport,
+            tls: None,
+            quic: None,
+            ws: None,
+        }
+    }
+}
diff --git a/core/message_bus/src/installer/mod.rs b/core/message_bus/src/installer/mod.rs
new file mode 100644
index 000000000..5db4a84a0
--- /dev/null
+++ b/core/message_bus/src/installer/mod.rs
@@ -0,0 +1,137 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Connection installer trait + per-transport install paths.
+//!
+//! Shard 0 accepts / dials all TCP connections and ships the duplicated fd
+//! to the owning shard via the inter-shard `ShardFrame` channel. The owning
+//! shard's router handler wraps the fd on its own compio runtime and
+//! registers the connection on its local bus. The [`ConnectionInstaller`]
+//! trait exposes that registration surface in a way the shard layer can
+//! call without knowing the concrete bus type.
+//!
+//! The per-transport `install_*` entry points live in the submodules
+//! ([`replica`], [`tcp`], [`tcp_tls`], [`ws`], [`wss`], [`quic`]) and
+//! are re-exported below so external call sites use stable
+//! `crate::installer::install_*` paths.
+
+mod common;
+
+pub mod conn_info;
+pub mod quic;
+pub mod replica;
+pub mod tcp;
+pub mod tcp_tls;
+pub mod ws;
+pub mod wss;
+
+pub use quic::install_client_quic_conn;
+pub use replica::{install_replica_conn, install_replica_stream};
+pub use tcp::{install_client_conn, install_client_stream};
+pub use tcp_tls::install_client_tls_stream;
+pub use ws::install_client_ws_stream;
+pub use wss::install_client_wss_stream;
+
+use crate::IggyMessageBus;
+use crate::client_listener::RequestHandler;
+use crate::fd_transfer::{self, DupedFd};
+use crate::installer::conn_info::ClientConnMeta;
+use crate::replica::listener::MessageHandler;
+use std::rc::Rc;
+use tracing::warn;
+
+/// Operations a shard needs to perform on its local bus when the router
+/// receives an inter-shard connection-setup or mapping frame.
+///
+/// The production implementation is on `Rc<IggyMessageBus>`. The simulator
+/// does not exercise this path; if it ever does, add a no-op impl on
+/// `SharedSimOutbox`.
+pub trait ConnectionInstaller {
+    /// Wrap a duplicated TCP fd into a `TcpStream` on the local compio
+    /// runtime, spawn writer + reader tasks, and register the replica
+    /// connection on this shard.
+    ///
+    /// Takes ownership of `fd`. On registration failure the fd is closed
+    /// by dropping the wrapping `TcpStream`; on caller-side failure (e.g.
+    /// inter-shard send drops the setup frame) the `DupedFd` closes the
+    /// fd on drop.
+    fn install_replica_fd(&self, fd: DupedFd, replica_id: u8, on_message: MessageHandler);
+
+    /// Same for an SDK client connection. The owning shard is already
+    /// encoded in the top 16 bits of `meta.client_id`. `meta` is stored
+    /// on the bus and exposed via [`IggyMessageBus::client_meta`] for
+    /// the lifetime of the connection.
+    fn install_client_fd(&self, fd: DupedFd, meta: ClientConnMeta, on_request: RequestHandler);
+
+    /// Same for an SDK WebSocket client's pre-upgrade TCP fd. The
+    /// receiving shard wraps the fd, runs
+    /// `compio_ws::accept_async_with_config` (configured by
+    /// `MessageBusConfig::ws_config`) to drive the HTTP-Upgrade
+    /// handshake, then installs WS reader / writer tasks via
+    /// [`install_client_ws_stream`] on success. On handshake failure
+    /// the fd is closed by dropping the wrapping `TcpStream`. No
+    /// subprotocol negotiation: the caller (server-ng) gates command
+    /// access via the LOGIN allowlist.
+    fn install_client_ws_fd(&self, fd: DupedFd, meta: ClientConnMeta, on_request: RequestHandler);
+
+    /// Update the replica -> owning shard mapping used by the `send_to_replica`
+    /// slow path on non-owning shards.
+    fn set_shard_mapping(&self, replica: u8, owning_shard: u16);
+
+    /// Forget the replica -> owning shard mapping (e.g. after a connection
+    /// loss, before the next allocate).
+    fn remove_shard_mapping(&self, replica: u8);
+}
+
+impl ConnectionInstaller for Rc<IggyMessageBus> {
+    fn install_replica_fd(&self, fd: DupedFd, replica_id: u8, on_message: MessageHandler) {
+        let stream = fd_transfer::wrap_duped_fd(fd);
+        install_replica_stream(self, replica_id, stream, on_message);
+    }
+
+    fn install_client_fd(&self, fd: DupedFd, meta: ClientConnMeta, on_request: RequestHandler) {
+        let stream = fd_transfer::wrap_duped_fd(fd);
+        install_client_stream(self, meta, stream, on_request);
+    }
+
+    fn install_client_ws_fd(&self, fd: DupedFd, meta: ClientConnMeta, on_request: RequestHandler) {
+        let stream = fd_transfer::wrap_duped_fd(fd);
+        let bus = Self::clone(self);
+        let ws_config = bus.config().ws_config;
+        let handle = compio::runtime::spawn(async move {
+            match compio_ws::accept_async_with_config(stream, ws_config).await {
+                Ok(ws) => {
+                    if !bus.is_shutting_down() {
+                        install_client_ws_stream(&bus, meta, ws, on_request);
+                    }
+                }
+                Err(e) => {
+                    warn!(client_id = meta.client_id, "WS upgrade failed: {e}");
+                }
+            }
+        });
+        self.track_background(handle);
+    }
+
+    fn set_shard_mapping(&self, replica: u8, owning_shard: u16) {
+        IggyMessageBus::set_shard_mapping(self, replica, owning_shard);
+    }
+
+    fn remove_shard_mapping(&self, replica: u8) {
+        IggyMessageBus::remove_shard_mapping(self, replica);
+    }
+}
diff --git a/core/message_bus/src/installer/quic.rs b/core/message_bus/src/installer/quic.rs
new file mode 100644
index 000000000..164f4a35c
--- /dev/null
+++ b/core/message_bus/src/installer/quic.rs
@@ -0,0 +1,57 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! QUIC client install path.
+
+use super::conn_info::ClientConnMeta;
+use super::tcp::install_client_conn;
+use crate::IggyMessageBus;
+use crate::client_listener::RequestHandler;
+use crate::transports::quic::QuicTransportConn;
+use std::rc::Rc;
+
+/// QUIC entry point for client installs.
+///
+/// Wraps the [`compio_quic::Connection`] and its first bidirectional
+/// `(SendStream, RecvStream)` pair (already accepted by shard 0 via
+/// `Connection::accept_bi().await` so the install path never
+/// re-handshakes) in a [`QuicTransportConn`] and delegates to the
+/// existing generic [`install_client_conn`].
+///
+/// No socket-options analog runs here: QUIC keepalive lives in
+/// [`compio_quic::TransportConfig::keep_alive_interval`] set at endpoint
+/// construction time. The connection is encrypted end-to-end and there
+/// is no plaintext fd to dup, which is why this never crosses an
+/// inter-shard channel: shard 0 owns the QUIC `Endpoint`, terminates
+/// every connection locally, and uses the existing
+/// `ForwardClientSend` / `Consensus` shard-frame variants for outbound
+/// + inbound traffic respectively.
+#[allow(clippy::future_not_send)]
+pub fn install_client_quic_conn(
+    bus: &Rc<IggyMessageBus>,
+    meta: ClientConnMeta,
+    connection: compio_quic::Connection,
+    streams: (compio_quic::SendStream, compio_quic::RecvStream),
+    on_request: RequestHandler,
+) {
+    install_client_conn(
+        bus,
+        meta,
+        QuicTransportConn::new(connection, streams),
+        on_request,
+    );
+}
diff --git a/core/message_bus/src/installer/replica.rs b/core/message_bus/src/installer/replica.rs
new file mode 100644
index 000000000..b30ef0944
--- /dev/null
+++ b/core/message_bus/src/installer/replica.rs
@@ -0,0 +1,311 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Replica plane install paths. TCP only by design.
+
+use super::common::drain_rejected_registration;
+use crate::lifecycle::{InstanceToken, Shutdown, ShutdownToken};
+use crate::replica::listener::MessageHandler;
+use crate::socket_opts::{apply_keepalive_for_connection, apply_nodelay_for_connection};
+use crate::transports::tcp::TcpTransportConn;
+use crate::transports::{ActorContext, TransportConn};
+use crate::{IggyMessageBus, lifecycle};
+use async_channel::Receiver;
+use compio::net::TcpStream;
+use futures::FutureExt;
+use iggy_binary_protocol::{GenericHeader, Message};
+use std::cell::Cell;
+use std::rc::Rc;
+use tracing::{debug, info, warn};
+
+/// TCP entry point: apply socket options (keepalive, `TCP_NODELAY`) on
+/// the raw stream and delegate to the transport-generic install path.
+///
+/// Socket options live here (not on the generic path) because they are
+/// TCP-specific; other transports (WSS via TLS terminator, QUIC) lack a
+/// raw-fd level at this layer. Kept as a separate function so the
+/// `install_replica_fd` fd-delegation entry and the accept callbacks
+/// (`AcceptedReplicaFn` in tests and shard-0 coordinator) converge on
+/// one place for those options.
+#[allow(clippy::future_not_send)]
+pub fn install_replica_stream(
+    bus: &Rc<IggyMessageBus>,
+    peer_id: u8,
+    stream: TcpStream,
+    on_message: MessageHandler,
+) {
+    let cfg = bus.config();
+    if let Err(e) = apply_keepalive_for_connection(
+        &stream,
+        cfg.keepalive_idle,
+        cfg.keepalive_interval,
+        cfg.keepalive_retries,
+    ) {
+        warn!(replica = peer_id, "keepalive failed on delegated fd: {e}");
+    }
+    if let Err(e) = apply_nodelay_for_connection(&stream) {
+        // Linux does not propagate TCP_NODELAY from the listener to the
+        // accepted fd, so we toggle it here on every installed stream.
+        // A miss means we stay Nagle-on for this peer, not a failure.
+        warn!(replica = peer_id, "nodelay failed on delegated fd: {e}");
+    }
+    install_replica_conn(bus, peer_id, TcpTransportConn::new(stream), on_message);
+}
+
+/// Install a pre-wrapped replica connection on the bus.
+///
+/// Generic over [`TransportConn`] so alternate transports (WS via
+/// shard-0 TLS terminator, QUIC via `compio-quic`) plug in behind the
+/// same registry-insert + instance-token fencing + install-race
+/// handling. TCP-specific socket options live in
+/// [`install_replica_stream`]; transports with no equivalent layer call
+/// this entry directly with their already-configured connection.
+#[allow(clippy::future_not_send, clippy::too_many_lines)]
+pub fn install_replica_conn<C: TransportConn>(
+    bus: &Rc<IggyMessageBus>,
+    peer_id: u8,
+    conn: C,
+    on_message: MessageHandler,
+) {
+    if bus.replicas().contains(peer_id) {
+        debug!(
+            replica = peer_id,
+            "replica already registered on this shard, dropping delegated fd"
+        );
+        drop(conn);
+        return;
+    }
+
+    let (tx, rx) = async_channel::bounded(bus.peer_queue_capacity());
+    let (in_tx, in_rx) =
+        async_channel::bounded::<Message<GenericHeader>>(bus.peer_queue_capacity());
+
+    // Writer and reader both observe abnormal close and used to fire
+    // `notify_connection_lost` twice per disconnect, causing shard 0 to
+    // broadcast two `ReplicaMappingClear` rounds and churn the mapping.
+    // Shared one-shot guard: whichever post-loop runs first wins.
+    let notified = Rc::new(Cell::new(false));
+    // If the registry insert below races with a concurrent install for
+    // the same peer id and loses, both spawned halves must skip their
+    // post-loop cleanup: the loser's `replicas().remove` /
+    // `close_peer_if_token_matches` calls would no-op against the winner's
+    // generation token (so they can't evict the live entry), but
+    // `notify_connection_lost` has no token guard and would still broadcast
+    // a spurious mapping-clear round. `compio::runtime::JoinHandle::drop`
+    // does not cancel the spawned task, so we have to tell the tasks to
+    // stand down in-band.
+    let install_aborted = Rc::new(Cell::new(false));
+
+    // Generation token published by the registry on a successful insert.
+    // Writer and reader post-loops release the slot only when the stored
+    // token matches; a stale-install exit that wakes up after a later
+    // reinstall would otherwise evict the new slot.
+    let install_token: Rc<Cell<Option<InstanceToken>>> = Rc::new(Cell::new(None));
+
+    // Per-connection shutdown used to kick the transport off its
+    // `io_uring` read SQE when the registry insert below loses a race.
+    // The bus-wide token cannot be triggered here (it would tear down
+    // every other connection); closing the bus-side outbound sender
+    // also does not reach a reader blocked on
+    // [`crate::framing::read_message`]. The `Shutdown` is moved into
+    // the registry entry on success so its `Sender` survives the
+    // connection's lifetime: dropping the `Shutdown` would close the
+    // broadcast channel and falsely wake the `ShutdownToken`'s
+    // listeners. On insert race the loser receives the `Shutdown` back
+    // via `RejectedRegistration` and triggers it before draining the
+    // orphan tasks.
+    let (conn_shutdown, conn_token) = Shutdown::new();
+
+    // The transport observes a single [`ShutdownToken`] in its
+    // `ActorContext`; bridge the bus-wide token and the per-connection
+    // token onto a fresh fused token so either firing wakes the
+    // transport's reader off `io_uring`. The bridge task self-terminates
+    // on first fire; on a clean run it parks until bus shutdown, when
+    // the bus-wide token guarantees a fire.
+    let bus_token_for_bridge = bus.token();
+    let conn_token_for_bridge = conn_token.clone();
+    let (transport_shutdown, transport_token) = lifecycle::Shutdown::new();
+    let bridge_handle = compio::runtime::spawn(async move {
+        futures::select! {
+            () = bus_token_for_bridge.wait().fuse() => {}
+            () = conn_token_for_bridge.wait().fuse() => {}
+        }
+        transport_shutdown.trigger();
+    });
+    bus.track_background(bridge_handle);
+
+    let label: &'static str = "replica";
+    let peer_fmt = format!("{peer_id}");
+    let ctx = ActorContext {
+        in_tx,
+        rx,
+        shutdown: transport_token,
+        max_batch: bus.config().max_batch,
+        max_message_size: bus.config().max_message_size,
+        label,
+        peer: peer_fmt,
+    };
+
+    let bus_for_transport = Rc::clone(bus);
+    let aborted_transport = Rc::clone(&install_aborted);
+    let token_for_transport = Rc::clone(&install_token);
+    let notified_transport = Rc::clone(&notified);
+    let transport_handle = compio::runtime::spawn(async move {
+        // Scopeguard so registry eviction + connection-lost notification fire
+        // on PANIC as well as clean exit. compio's `spawn` wraps the future
+        // with `AssertUnwindSafe(future).catch_unwind()` which silently
+        // swallows panics; without this guard a panicking transport would
+        // leak its registry slot and `notify_connection_lost` would never
+        // fire, leaving the bus convinced the peer is still up until a
+        // higher-layer timeout fallback notices.
+        let _cleanup = scopeguard::guard((), |()| {
+            if aborted_transport.get() || bus_for_transport.is_shutting_down() {
+                return;
+            }
+            let Some(token) = token_for_transport.get() else {
+                return;
+            };
+            if !bus_for_transport
+                .replicas()
+                .remove_if_token_matches(peer_id, token)
+            {
+                return;
+            }
+            if !notified_transport.replace(true) {
+                bus_for_transport.notify_connection_lost(peer_id);
+            }
+        });
+        conn.run(ctx).await;
+    });
+
+    let bus_for_dispatch = Rc::clone(bus);
+    let bus_token_dispatch = bus.token();
+    let conn_token_dispatch = conn_token;
+    let aborted_dispatch = Rc::clone(&install_aborted);
+    let token_for_dispatch = Rc::clone(&install_token);
+    let notified_dispatch = Rc::clone(&notified);
+    let close_peer_timeout = bus.config().close_peer_timeout;
+    let dispatch_handle = compio::runtime::spawn(async move {
+        replica_dispatch_loop(
+            peer_id,
+            in_rx,
+            &on_message,
+            &bus_token_dispatch,
+            &conn_token_dispatch,
+            &aborted_dispatch,
+        )
+        .await;
+        if aborted_dispatch.get() {
+            debug!(
+                replica = peer_id,
+                "aborted replica install: skipping post-loop cleanup"
+            );
+            return;
+        }
+        if !bus_token_dispatch.is_triggered() {
+            let Some(token) = token_for_dispatch.get() else {
+                return;
+            };
+            let closed = bus_for_dispatch
+                .replicas()
+                .close_peer_if_token_matches(peer_id, token, close_peer_timeout)
+                .await;
+            if closed && !notified_dispatch.replace(true) {
+                bus_for_dispatch.notify_connection_lost(peer_id);
+            }
+        }
+        info!(replica = peer_id, "peer replica disconnected");
+    });
+
+    match bus.replicas().insert(
+        peer_id,
+        tx,
+        transport_handle,
+        dispatch_handle,
+        conn_shutdown,
+    ) {
+        Ok(token) => {
+            install_token.set(Some(token));
+        }
+        Err(rejected) => {
+            // Tell both halves to stand down: the winner's entry is live and
+            // must not be touched by this losing install.
+            install_aborted.set(true);
+            warn!(replica = peer_id, "replica registry insert raced");
+            // `drain_rejected_registration` triggers the per-connection
+            // shutdown (returned with `rejected`) to wake the transport
+            // off its `io_uring` read SQE, then awaits transport /
+            // dispatch handles. `compio::runtime::JoinHandle::drop` only
+            // detaches, so without this drain the reader would outlive
+            // the race on a half-open socket until peer EOF. Hand the
+            // handle to `track_background` so `IggyMessageBus::shutdown`
+            // awaits the drain before returning - `.detach()` would
+            // orphan it and leak the half-closed socket across shutdown.
+            let drain_handle =
+                compio::runtime::spawn(drain_rejected_registration(rejected, close_peer_timeout));
+            bus.track_background(drain_handle);
+        }
+    }
+}
+
+/// Dispatch loop for a delegated replica connection. Pulls inbound
+/// consensus messages from the transport's per-connection inbound
+/// channel and hands each off to the bus-installed handler.
+///
+/// `aborted` is set by the installer when the registry insert loses a
+/// duplicate-replica-id race. The loop checks it before dispatching
+/// each message so the losing dispatcher can never invoke `on_message`
+/// with the replica id owned by the winning install — otherwise two
+/// physical peers would feed the same VSR slot and break replication
+/// safety.
+///
+/// `conn_token` is a per-connection shutdown the installer triggers
+/// from the insert-race path; alongside the bus-wide token, both wake
+/// this loop without waiting for the transport to drop its
+/// `Sender<Message>`.
+#[allow(clippy::future_not_send)]
+async fn replica_dispatch_loop(
+    replica_id: u8,
+    in_rx: Receiver<Message<GenericHeader>>,
+    on_message: &MessageHandler,
+    token: &ShutdownToken,
+    conn_token: &ShutdownToken,
+    aborted: &Cell<bool>,
+) {
+    loop {
+        futures::select! {
+            () = token.wait().fuse() => {
+                debug!(replica = replica_id, "replica dispatch loop shutting down");
+                return;
+            }
+            () = conn_token.wait().fuse() => {
+                debug!(replica = replica_id, "replica dispatch loop aborted by per-connection shutdown");
+                return;
+            }
+            result = in_rx.recv().fuse() => {
+                let Ok(msg) = result else {
+                    debug!(replica = replica_id, "replica dispatch: inbound queue closed");
+                    return;
+                };
+                if aborted.get() {
+                    return;
+                }
+                on_message(replica_id, msg);
+            }
+        }
+    }
+}
diff --git a/core/message_bus/src/installer/tcp.rs b/core/message_bus/src/installer/tcp.rs
new file mode 100644
index 000000000..9c0eec489
--- /dev/null
+++ b/core/message_bus/src/installer/tcp.rs
@@ -0,0 +1,271 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! TCP plaintext client install path + the transport-generic
+//! `install_client_conn` reused by every other client transport
+//! (TLS / WS / WSS / QUIC).
+
+use super::common::drain_rejected_registration;
+use super::conn_info::ClientConnMeta;
+use crate::client_listener::RequestHandler;
+use crate::lifecycle::{InstanceToken, Shutdown, ShutdownToken};
+use crate::socket_opts::{apply_keepalive_for_connection, apply_nodelay_for_connection};
+use crate::transports::tcp::TcpTransportConn;
+use crate::transports::{ActorContext, TransportConn};
+use crate::{IggyMessageBus, lifecycle};
+use async_channel::Receiver;
+use compio::net::TcpStream;
+use futures::FutureExt;
+use iggy_binary_protocol::{Command2, GenericHeader, Message};
+use std::cell::Cell;
+use std::rc::Rc;
+use tracing::{debug, info, warn};
+
+/// TCP entry point for client installs. Applies socket options and
+/// delegates to [`install_client_conn`]. See
+/// [`super::replica::install_replica_stream`] for the plane-symmetric docs.
+#[allow(clippy::future_not_send)]
+pub fn install_client_stream(
+    bus: &Rc<IggyMessageBus>,
+    meta: ClientConnMeta,
+    stream: TcpStream,
+    on_request: RequestHandler,
+) {
+    let cfg = bus.config();
+    let client_id = meta.client_id;
+    if let Err(e) = apply_keepalive_for_connection(
+        &stream,
+        cfg.keepalive_idle,
+        cfg.keepalive_interval,
+        cfg.keepalive_retries,
+    ) {
+        warn!(
+            client = client_id,
+            "keepalive failed on delegated client fd: {e}"
+        );
+    }
+    if let Err(e) = apply_nodelay_for_connection(&stream) {
+        warn!(
+            client = client_id,
+            "nodelay failed on delegated client fd: {e}"
+        );
+    }
+    install_client_conn(bus, meta, TcpTransportConn::new(stream), on_request);
+}
+
+/// Install a pre-wrapped client connection on the bus. Generic over
+/// [`TransportConn`]; plane-symmetric with
+/// [`super::replica::install_replica_conn`].
+#[allow(clippy::future_not_send, clippy::too_many_lines)]
+pub fn install_client_conn<C: TransportConn>(
+    bus: &Rc<IggyMessageBus>,
+    meta: ClientConnMeta,
+    conn: C,
+    on_request: RequestHandler,
+) {
+    let client_id = meta.client_id;
+    let (tx, rx) = async_channel::bounded(bus.peer_queue_capacity());
+    let (in_tx, in_rx) =
+        async_channel::bounded::<Message<GenericHeader>>(bus.peer_queue_capacity());
+
+    // If the registry insert below loses a race for `client_id`, the
+    // losing reader must NOT invoke `on_request` (it would route
+    // responses through the wrong registry entry) and must NOT call
+    // `close_peer` (it would evict the winner). See the replica path
+    // for the same pattern.
+    let install_aborted = Rc::new(Cell::new(false));
+
+    // See replica path for the rationale on instance-token fencing.
+    let install_token: Rc<Cell<Option<InstanceToken>>> = Rc::new(Cell::new(None));
+
+    // Per-connection shutdown for fast reader wake on insert race; see
+    // the replica installer for the full rationale.
+    let (conn_shutdown, conn_token) = Shutdown::new();
+
+    // Bridge bus-wide + per-connection shutdown into the single
+    // `ActorContext::shutdown` token consumed by the transport.
+    let bus_token_for_bridge = bus.token();
+    let conn_token_for_bridge = conn_token.clone();
+    let (transport_shutdown, transport_token) = lifecycle::Shutdown::new();
+    let bridge_handle = compio::runtime::spawn(async move {
+        futures::select! {
+            () = bus_token_for_bridge.wait().fuse() => {}
+            () = conn_token_for_bridge.wait().fuse() => {}
+        }
+        transport_shutdown.trigger();
+    });
+    bus.track_background(bridge_handle);
+
+    let label: &'static str = "client";
+    let peer_fmt = format!("{client_id:#034x}");
+    let ctx = ActorContext {
+        in_tx,
+        rx,
+        shutdown: transport_token,
+        max_batch: bus.config().max_batch,
+        max_message_size: bus.config().max_message_size,
+        label,
+        peer: peer_fmt,
+    };
+
+    let bus_for_transport = Rc::clone(bus);
+    let aborted_transport = Rc::clone(&install_aborted);
+    let token_for_transport = Rc::clone(&install_token);
+    let transport_handle = compio::runtime::spawn(async move {
+        // Scopeguard so the registry slot is evicted on PANIC as well as
+        // clean exit. compio's `spawn` wraps the future with
+        // `AssertUnwindSafe(future).catch_unwind()` which silently swallows
+        // panics; without this guard a panicking client transport would
+        // leak its slot and the next reconnect from the same client id
+        // would be rejected as a duplicate.
+        let _cleanup = scopeguard::guard((), |()| {
+            if aborted_transport.get() || bus_for_transport.is_shutting_down() {
+                return;
+            }
+            let Some(token) = token_for_transport.get() else {
+                return;
+            };
+            bus_for_transport
+                .clients()
+                .remove_if_token_matches(client_id, token);
+            bus_for_transport.remove_client_meta(client_id);
+        });
+        conn.run(ctx).await;
+    });
+
+    let bus_for_dispatch = Rc::clone(bus);
+    let bus_token_dispatch = bus.token();
+    let conn_token_dispatch = conn_token;
+    let aborted_dispatch = Rc::clone(&install_aborted);
+    let token_for_dispatch = Rc::clone(&install_token);
+    let close_peer_timeout = bus.config().close_peer_timeout;
+    let dispatch_handle = compio::runtime::spawn(async move {
+        client_dispatch_loop(
+            client_id,
+            in_rx,
+            &on_request,
+            &bus_token_dispatch,
+            &conn_token_dispatch,
+            &aborted_dispatch,
+        )
+        .await;
+        if aborted_dispatch.get() {
+            debug!(
+                client = client_id,
+                "aborted client install: skipping post-loop cleanup"
+            );
+            return;
+        }
+        if !bus_token_dispatch.is_triggered() {
+            let Some(token) = token_for_dispatch.get() else {
+                return;
+            };
+            bus_for_dispatch
+                .clients()
+                .close_peer_if_token_matches(client_id, token, close_peer_timeout)
+                .await;
+        }
+        info!(client = client_id, "consensus client disconnected");
+    });
+
+    match bus.clients().insert(
+        client_id,
+        tx,
+        transport_handle,
+        dispatch_handle,
+        conn_shutdown,
+    ) {
+        Ok(token) => {
+            install_token.set(Some(token));
+            bus.insert_client_meta(Rc::new(meta));
+        }
+        Err(rejected) => {
+            // Shard 0 mints client ids as `(target_shard << 112) | seq` with a
+            // monotonic `seq` starting at 1, so wrap requires 2^112 mints and
+            // a collision here is a bootstrap bug or a foreign id leaking
+            // into the setup path. Flip `install_aborted` so the orphan
+            // reader drops inbound frames instead of forwarding them via
+            // `on_request` (which would route responses through the winner's
+            // entry and silently misroute).
+            install_aborted.set(true);
+            warn!(
+                client_id,
+                "duplicate client id in registry, dropping delegated fd \
+                 (shard 0 counter invariant violated)"
+            );
+            // See replica installer for the track_background rationale.
+            let drain_handle =
+                compio::runtime::spawn(drain_rejected_registration(rejected, close_peer_timeout));
+            bus.track_background(drain_handle);
+        }
+    }
+}
+
+/// Dispatch loop for a delegated client connection. Rejects any command
+/// other than `Request` (the client side of the consensus protocol only
+/// speaks request/reply).
+///
+/// `aborted` is set by the installer when the registry insert loses a
+/// duplicate-client-id race. The loop checks it before dispatching each
+/// request so the losing dispatcher can never invoke `on_request` with
+/// the client id owned by the winning install.
+///
+/// `conn_token` is a per-connection shutdown the installer triggers
+/// from the insert-race path; alongside the bus-wide token, both wake
+/// this loop without waiting for the transport to drop its
+/// `Sender<Message>`.
+#[allow(clippy::future_not_send)]
+async fn client_dispatch_loop(
+    client_id: u128,
+    in_rx: Receiver<Message<GenericHeader>>,
+    on_request: &RequestHandler,
+    token: &ShutdownToken,
+    conn_token: &ShutdownToken,
+    aborted: &Cell<bool>,
+) {
+    loop {
+        futures::select! {
+            () = token.wait().fuse() => {
+                debug!(client = client_id, "client dispatch loop shutting down");
+                return;
+            }
+            () = conn_token.wait().fuse() => {
+                debug!(client = client_id, "client dispatch loop aborted by per-connection shutdown");
+                return;
+            }
+            result = in_rx.recv().fuse() => {
+                let Ok(msg) = result else {
+                    debug!(client = client_id, "client dispatch: inbound queue closed");
+                    return;
+                };
+                if aborted.get() {
+                    return;
+                }
+                let cmd = msg.header().command;
+                if cmd != Command2::Request {
+                    warn!(
+                        client = client_id,
+                        ?cmd,
+                        "unexpected command from client, expected Request"
+                    );
+                    continue;
+                }
+                on_request(client_id, msg);
+            }
+        }
+    }
+}
diff --git a/core/message_bus/src/installer/tcp_tls.rs b/core/message_bus/src/installer/tcp_tls.rs
new file mode 100644
index 000000000..45b95542d
--- /dev/null
+++ b/core/message_bus/src/installer/tcp_tls.rs
@@ -0,0 +1,85 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! TCP-TLS client install path.
+
+use super::conn_info::ClientConnMeta;
+use super::tcp::install_client_conn;
+use crate::IggyMessageBus;
+use crate::client_listener::RequestHandler;
+use crate::socket_opts::{apply_keepalive_for_connection, apply_nodelay_for_connection};
+use crate::transports::tcp_tls::TcpTlsTransportConn;
+use compio::net::TcpStream;
+use std::rc::Rc;
+use std::sync::Arc;
+use tracing::warn;
+
+/// TCP-TLS entry point for client installs.
+///
+/// Wraps a freshly accepted plaintext [`TcpStream`] (TLS handshake has
+/// NOT run yet) plus the shared [`Arc<rustls::ServerConfig>`] in a
+/// [`TcpTlsTransportConn`] and delegates to the existing generic
+/// [`install_client_conn`]. The rustls handshake then runs inside the
+/// transport's `run` body on the per-connection install task, so a slow
+/// or malicious peer never blocks the listener accept loop.
+///
+/// `TCP_NODELAY` + `SO_KEEPALIVE` apply to the underlying TCP socket
+/// before the handshake starts, matching
+/// [`super::tcp::install_client_stream`]'s plaintext behaviour. Linux
+/// does not propagate these from the listener to accepted sockets, so
+/// toggling here is required.
+///
+/// TCP-TLS is shard-0 terminal: the rustls connection state machine
+/// is non-serialisable and tied to the local task; pre-handshake the
+/// fd is plain TCP and could in principle be dup'd to another shard,
+/// but the receiving shard would then have to re-handshake against
+/// shard-0-resident key material — losing the point of the cross-shard
+/// handover.
+#[allow(clippy::future_not_send)]
+pub fn install_client_tls_stream(
+    bus: &Rc<IggyMessageBus>,
+    meta: ClientConnMeta,
+    stream: TcpStream,
+    config: Arc<rustls::ServerConfig>,
+    on_request: RequestHandler,
+) {
+    let cfg = bus.config();
+    let client_id = meta.client_id;
+    if let Err(e) = apply_keepalive_for_connection(
+        &stream,
+        cfg.keepalive_idle,
+        cfg.keepalive_interval,
+        cfg.keepalive_retries,
+    ) {
+        warn!(
+            client = client_id,
+            "keepalive failed on accepted TLS client fd: {e}"
+        );
+    }
+    if let Err(e) = apply_nodelay_for_connection(&stream) {
+        warn!(
+            client = client_id,
+            "nodelay failed on accepted TLS client fd: {e}"
+        );
+    }
+    install_client_conn(
+        bus,
+        meta,
+        TcpTlsTransportConn::new_server(stream, config).with_close_grace(cfg.close_grace),
+        on_request,
+    );
+}
diff --git a/core/message_bus/src/installer/ws.rs b/core/message_bus/src/installer/ws.rs
new file mode 100644
index 000000000..67759ea22
--- /dev/null
+++ b/core/message_bus/src/installer/ws.rs
@@ -0,0 +1,56 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! WebSocket client install path (post HTTP-Upgrade).
+
+use super::conn_info::ClientConnMeta;
+use super::tcp::install_client_conn;
+use crate::IggyMessageBus;
+use crate::client_listener::RequestHandler;
+use crate::transports::ws::WsTransportConn;
+use compio::net::TcpStream;
+use std::rc::Rc;
+
+/// WebSocket entry point for client installs.
+///
+/// Wraps a post-upgrade [`compio_ws::WebSocketStream`] in a
+/// [`WsTransportConn`] and delegates to the existing generic
+/// [`install_client_conn`]. The HTTP-Upgrade handshake has already been
+/// driven on shard 0; the install path never re-runs it.
+///
+/// Like QUIC, this never crosses an inter-shard channel:
+/// `WebSocketStream<TcpStream>` is `!Send` (it holds compio `Rc<...>`
+/// driver state) so shard 0 terminates locally and uses the existing
+/// `ForwardClientSend` / `Consensus` variants. The post-upgrade socket
+/// IS still a raw TCP fd, but compio-ws has no "rebuild from fd" API
+/// and re-attaching the tungstenite state machine across shards would
+/// lose protocol invariants the dispatcher already enforced.
+#[allow(clippy::future_not_send)]
+pub fn install_client_ws_stream(
+    bus: &Rc<IggyMessageBus>,
+    meta: ClientConnMeta,
+    stream: compio_ws::WebSocketStream<TcpStream>,
+    on_request: RequestHandler,
+) {
+    let cfg = bus.config();
+    install_client_conn(
+        bus,
+        meta,
+        WsTransportConn::new_server(stream).with_close_grace(cfg.close_grace),
+        on_request,
+    );
+}
diff --git a/core/message_bus/src/installer/wss.rs b/core/message_bus/src/installer/wss.rs
new file mode 100644
index 000000000..d6151fb61
--- /dev/null
+++ b/core/message_bus/src/installer/wss.rs
@@ -0,0 +1,83 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! WSS (WebSocket-over-TLS) client install path.
+
+use super::conn_info::ClientConnMeta;
+use super::tcp::install_client_conn;
+use crate::IggyMessageBus;
+use crate::client_listener::RequestHandler;
+use crate::socket_opts::{apply_keepalive_for_connection, apply_nodelay_for_connection};
+use crate::transports::wss::WssTransportConn;
+use compio::net::TcpStream;
+use std::rc::Rc;
+use std::sync::Arc;
+use tracing::warn;
+
+/// WSS entry point for client installs.
+///
+/// Wraps a freshly accepted plaintext [`TcpStream`] (neither the TLS
+/// handshake nor the WS HTTP-Upgrade has run yet) plus the shared
+/// [`Arc<rustls::ServerConfig>`] in a [`WssTransportConn`] and delegates
+/// to the existing generic [`install_client_conn`]. Both handshakes
+/// then run inside the transport's `run` body on the per-connection
+/// install task; the install path stays thin. No subprotocol
+/// negotiation: client identity is established post-handshake by the
+/// LOGIN command on the caller (server-ng).
+///
+/// Socket options (`TCP_NODELAY` + `SO_KEEPALIVE`) apply pre-handshake
+/// for symmetry with [`super::tcp_tls::install_client_tls_stream`].
+///
+/// WSS is shard-0 terminal for the same reasons as the TCP-TLS plane;
+/// see [`super::tcp_tls::install_client_tls_stream`] for the rustls
+/// non-serialisability argument.
+#[allow(clippy::future_not_send)]
+pub fn install_client_wss_stream(
+    bus: &Rc<IggyMessageBus>,
+    meta: ClientConnMeta,
+    stream: TcpStream,
+    config: Arc<rustls::ServerConfig>,
+    on_request: RequestHandler,
+) {
+    let cfg = bus.config();
+    let client_id = meta.client_id;
+    if let Err(e) = apply_keepalive_for_connection(
+        &stream,
+        cfg.keepalive_idle,
+        cfg.keepalive_interval,
+        cfg.keepalive_retries,
+    ) {
+        warn!(
+            client = client_id,
+            "keepalive failed on accepted WSS client fd: {e}"
+        );
+    }
+    if let Err(e) = apply_nodelay_for_connection(&stream) {
+        warn!(
+            client = client_id,
+            "nodelay failed on accepted WSS client fd: {e}"
+        );
+    }
+    install_client_conn(
+        bus,
+        meta,
+        WssTransportConn::new_server(stream, config)
+            .with_close_grace(cfg.close_grace)
+            .with_ws_config(cfg.ws_config),
+        on_request,
+    );
+}
diff --git a/core/message_bus/src/lib.rs b/core/message_bus/src/lib.rs
index bb993fc8c..141de5c70 100644
--- a/core/message_bus/src/lib.rs
+++ b/core/message_bus/src/lib.rs
@@ -15,6 +15,65 @@
 // specific language governing permissions and limitations
 // under the License.
 
+//! Shard-local message bus with two wire planes.
+//!
+//! # Plane split
+//!
+//! - **Replica plane (TCP forever)**: VSR consensus traffic between
+//!   replicas. Implemented in [`replica::listener`], [`connector`], and
+//!   [`replica::io`]. Datagram or gateway-terminated transports are NOT
+//!   supported here and never will be — see
+//!   `replica::listener`'s module docs for the rationale.
+//! - **SDK-client plane**: ephemeral client connections. Available
+//!   transports: TCP, TCP-TLS, WebSocket, WSS, QUIC. At-most-once
+//!   semantics come from the `(client: u128, request: u64)` pair in
+//!   `RequestHeader`; the dedup store lives in
+//!   `core/server-ng/src/dedup.rs` on each shard.
+//!
+//! # Auth
+//!
+//! Neither plane is authenticated at the bus layer. Both connect first
+//! and let the caller (`core/server-ng`) gate command access via
+//! application-level LOGIN commands:
+//!
+//! - SDK-client plane: `LOGIN_USER` / `LOGIN_WITH_PERSONAL_ACCESS_TOKEN`,
+//!   pre-LOGIN allowlist `PING`, `LOGIN_USER`, `LOGIN_WITH_PAT`.
+//! - Replica plane: a future `LOGIN_REPLICA` command carries the
+//!   cluster's shared secret. Until that command succeeds the caller
+//!   MUST NOT honor consensus messages from the connection. The
+//!   `Ping` frame at connect time announces `replica_id` and
+//!   `cluster_id` (the listener checks `cluster_id` matches the local
+//!   cluster and uses `replica_id` to key its registry); it carries
+//!   no MAC.
+//!
+//! # Invariants worth naming
+//!
+//! - [`send_to_client`](IggyMessageBus::send_to_client) and
+//!   [`send_to_replica`](IggyMessageBus::send_to_replica) return
+//!   `Ready` on first poll. Consensus code relies on this for
+//!   reentrancy reasoning; any `.await` in the body breaks it.
+//! - The TCP transport's writer task coalesces up to
+//!   `MessageBusConfig::max_batch` (default 256) `Frozen<MESSAGE_ALIGN>`
+//!   into one `write_vectored_all`. Don't introduce per-message
+//!   syscalls or per-message encryption on the plaintext TCP plane.
+//! - fd-delegation ([`fd_transfer`]) is TCP-only. TLS / QUIC
+//!   connections have no dupable plaintext fd, so shard 0 terminates
+//!   and forwards `Frozen<MESSAGE_ALIGN>` over the existing
+//!   inter-shard flume.
+//! - 0-RTT stays disabled by default on any future QUIC path. Per-
+//!   command opt-in requires a checked-in idempotence audit.
+//!
+//! # Transport abstraction
+//!
+//! [`transports`] defines the trait surface every wire plane sits
+//! behind: [`transports::TransportListener`] and
+//! [`transports::TransportConn`] with its single
+//! [`transports::TransportConn::run`] entry point.
+//! [`installer::install_replica_conn`] /
+//! [`installer::install_client_conn`] are generic over it so every
+//! transport (TCP, TCP-TLS, WS, WSS, QUIC) plugs in behind the same
+//! registry, fencing, and dispatch logic.
+
 pub mod cache;
 pub mod client_listener;
 pub mod config;
@@ -24,19 +83,22 @@ pub mod fd_transfer;
 pub mod framing;
 pub mod installer;
 pub mod lifecycle;
-pub mod replica_io;
-pub mod replica_listener;
+pub mod replica;
 pub(crate) mod socket_opts;
 pub mod transports;
-pub mod writer_task;
 
-pub use config::MessageBusConfig;
+pub use compio::ws::tungstenite::protocol::WebSocketConfig;
+pub use config::{IOV_MAX_LIMIT, MessageBusConfig};
 pub use error::SendError;
 pub use installer::ConnectionInstaller;
+pub use installer::conn_info::{
+    ClientConnMeta, ClientTransportKind, QuicConnectionInfo, TlsConnectionInfo, WsUpgradeInfo,
+};
 pub use lifecycle::{
     BusMessage, BusReceiver, BusSender, ConnectionRegistry, DrainOutcome, RejectedRegistration,
     ReplicaRegistry, Shutdown, ShutdownToken,
 };
+pub use transports::tls::TlsServerCredentials;
 
 use compio::runtime::JoinHandle;
 use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
@@ -44,6 +106,7 @@ use iggy_binary_protocol::consensus::iobuf::Frozen;
 use iggy_binary_protocol::{GenericHeader, Message};
 use std::cell::RefCell;
 use std::collections::HashMap;
+use std::rc::Rc;
 use std::time::Duration;
 
 /// Callback for forwarding a consensus message to a remote shard.
@@ -66,8 +129,11 @@ pub type ShardForwardFn = Box<dyn Fn(u16, Frozen<MESSAGE_ALIGN>) -> Result<(), S
 /// Fired by the replica listener / outbound connector. The callback decides
 /// whether to install the stream locally or ship it to another shard; this
 /// crate does not need to care about that policy. Takes ownership of the
-/// `TcpStream`; the replica id has already been validated against the
-/// cluster config.
+/// `TcpStream`. On the inbound (listener) path the replica id has been
+/// validated against `replica_count`, the local `cluster_id`, and the
+/// directional rule (peer id strictly less than this replica's id). On
+/// the outbound (connector) path the dialer trusts the configured peer
+/// list and the callback receives the pre-configured peer id.
 pub type AcceptedReplicaFn = std::rc::Rc<dyn Fn(compio::net::TcpStream, u8)>;
 
 /// Callback invoked on every accepted SDK client connection.
@@ -76,6 +142,72 @@ pub type AcceptedReplicaFn = std::rc::Rc<dyn Fn(compio::net::TcpStream, u8)>;
 /// assigning the client id as part of its delegation policy.
 pub type AcceptedClientFn = std::rc::Rc<dyn Fn(compio::net::TcpStream)>;
 
+/// Callback invoked on every accepted SDK QUIC client connection.
+///
+/// Fires after shard 0's QUIC listener drives the handshake to
+/// completion AND accepts the first bidirectional stream pair, so the
+/// callback receives a ready-for-traffic [`compio_quic::Connection`]
+/// plus its `(SendStream, RecvStream)` pair. The callback mints a
+/// client id, then calls [`installer::install_client_quic_conn`] on
+/// the local bus.
+///
+/// QUIC stays shard-0 terminal: shard 0 owns the
+/// `compio_quic::Endpoint`, which demuxes incoming UDP packets to
+/// in-flight connections by Connection ID, and `quinn-proto`
+/// per-connection TLS / packet-number / congestion state is not
+/// serialisable. No cross-shard handover analog exists for this plane.
+pub type AcceptedQuicClientFn = std::rc::Rc<
+    dyn Fn(compio_quic::Connection, (compio_quic::SendStream, compio_quic::RecvStream)),
+>;
+
+/// Callback invoked on every accepted SDK WebSocket client connection.
+///
+/// Fires after shard 0's WS listener accepts a raw TCP socket. The
+/// HTTP-Upgrade handshake has NOT run yet; the callback hands the
+/// raw stream off to the owning shard via inter-shard fd-shipping
+/// (`ShardFramePayload::ClientWsConnectionSetup`). The owning shard
+/// runs the upgrade locally; no subprotocol is negotiated. The
+/// shipped fd is plain TCP at ship-time, so fd-delegation (which
+/// requires a dupable plaintext fd) stays well-defined.
+pub type AcceptedWsClientFn = std::rc::Rc<dyn Fn(compio::net::TcpStream)>;
+
+/// Callback invoked on every accepted SDK TCP-TLS client connection.
+///
+/// Fires after shard 0's TCP-TLS listener accepts a raw TCP socket.
+/// Neither the rustls handshake nor any application-layer work has run
+/// yet — the listener stays cheap so a slow handshake on one peer cannot
+/// block subsequent accepts. The callback receives the raw stream plus
+/// a clone of the shared [`std::sync::Arc<rustls::ServerConfig>`] built
+/// at bind time, mints a `client_id`, and calls
+/// [`installer::install_client_tls_stream`]; the install path drives the
+/// rustls handshake on its own task before forwarding the connection to
+/// [`installer::install_client_conn`].
+///
+/// TCP-TLS stays shard-0 terminal: rustls's connection state machine
+/// is tied to the local task and not serialisable, and the
+/// pre-handshake fd would have to re-handshake on the receiving shard,
+/// losing the point of fd-delegation.
+pub type AcceptedTlsClientFn =
+    std::rc::Rc<dyn Fn(compio::net::TcpStream, std::sync::Arc<rustls::ServerConfig>)>;
+
+/// Callback invoked on every accepted SDK WSS (WebSocket-over-TLS)
+/// client connection.
+///
+/// Fires after shard 0's WSS listener accepts a raw TCP socket. Neither
+/// the rustls handshake nor the WebSocket HTTP-Upgrade has run yet — the
+/// listener stays cheap so neither handshake on one peer can block
+/// subsequent accepts. The callback receives the raw stream plus a clone
+/// of the shared [`std::sync::Arc<rustls::ServerConfig>`] built at bind
+/// time, mints a `client_id`, and calls
+/// [`installer::install_client_wss_stream`]; the install path drives
+/// both handshakes on its own task before forwarding the connection to
+/// [`installer::install_client_conn`].
+///
+/// No subprotocol negotiation is performed. WSS stays shard-0 terminal
+/// for the same reasons as the TCP-TLS plane.
+pub type AcceptedWssClientFn =
+    std::rc::Rc<dyn Fn(compio::net::TcpStream, std::sync::Arc<rustls::ServerConfig>)>;
+
 /// Notifier fired when a delegated replica connection dies.
 ///
 /// The delegated replica connection's writer / reader tasks invoke this on
@@ -145,7 +277,8 @@ pub trait MessageBus {
 ///   on `Full` (returned as [`SendError::Backpressure`]) are recovered by
 ///   VSR retransmission.
 /// - The per-connection writer task batches up to `config.max_batch` messages into
-///   a single `writev` syscall via [`writer_task::run`].
+///   a single `writev` syscall on the plaintext TCP plane (see
+///   [`transports::tcp`]).
 ///
 /// Interior mutability via `RefCell` / `Cell` is sound because compio is a
 /// single-threaded runtime: no other task can execute while we hold a borrow.
@@ -172,6 +305,10 @@ pub struct IggyMessageBus {
     /// when they exit abnormally. `None` when running without a shard-0
     /// coordinator (single-shard deployments and tests).
     connection_lost_fn: RefCell<Option<ConnectionLostFn>>,
+    /// Per-connection metadata exposed to the caller via
+    /// [`Self::client_meta`]. Populated by the install path on
+    /// successful registry insert; removed on connection teardown.
+    client_meta: RefCell<HashMap<u128, Rc<ClientConnMeta>>>,
 }
 
 impl IggyMessageBus {
@@ -196,8 +333,20 @@ impl IggyMessageBus {
     }
 
     /// Construct a bus with the given [`MessageBusConfig`].
+    ///
+    /// # Panics
+    ///
+    /// Panics if `config.max_batch == 0` or
+    /// `config.max_batch > IOV_MAX_LIMIT`. Boot-time validation; surfaces
+    /// operator misconfiguration loudly rather than letting every writev
+    /// fail silently with `EMSGSIZE` once traffic starts.
     #[must_use]
     pub fn with_config(shard_id: u16, config: MessageBusConfig) -> Self {
+        assert!(
+            config.max_batch > 0 && config.max_batch <= IOV_MAX_LIMIT,
+            "MessageBusConfig::max_batch must be in 1..={IOV_MAX_LIMIT} (writev IOV_MAX/2 cap); got {}",
+            config.max_batch,
+        );
         let (shutdown, token) = Shutdown::new();
         Self {
             shard_id,
@@ -211,9 +360,27 @@ impl IggyMessageBus {
             replica_forward_fn: RefCell::new(None),
             client_forward_fn: RefCell::new(None),
             connection_lost_fn: RefCell::new(None),
+            client_meta: RefCell::new(HashMap::new()),
         }
     }
 
+    /// Look up the per-connection metadata recorded for `client_id`.
+    ///
+    /// Returns `None` if the client never connected on this bus or if
+    /// its connection has already been torn down.
+    #[must_use]
+    pub fn client_meta(&self, client_id: u128) -> Option<Rc<ClientConnMeta>> {
+        self.client_meta.borrow().get(&client_id).map(Rc::clone)
+    }
+
+    pub(crate) fn insert_client_meta(&self, meta: Rc<ClientConnMeta>) {
+        self.client_meta.borrow_mut().insert(meta.client_id, meta);
+    }
+
+    pub(crate) fn remove_client_meta(&self, client_id: u128) {
+        self.client_meta.borrow_mut().remove(&client_id);
+    }
+
     /// Install the notifier used by delegated replica connections to tell
     /// shard 0 that a connection died. Single place to inject in tests too.
     pub fn set_connection_lost_fn(&self, f: ConnectionLostFn) {
@@ -221,8 +388,18 @@ impl IggyMessageBus {
     }
 
     /// Invoke the registered connection-lost notifier, if any.
+    ///
+    /// Clones the `Rc` out of the `RefCell` borrow before invoking the
+    /// closure so the closure body is free to call
+    /// [`Self::set_connection_lost_fn`] (which takes a `borrow_mut`)
+    /// without tripping the runtime borrow check.
     pub(crate) fn notify_connection_lost(&self, replica_id: u8) {
-        if let Some(f) = self.connection_lost_fn.borrow().as_ref() {
+        let cb = self
+            .connection_lost_fn
+            .borrow()
+            .as_ref()
+            .map(std::rc::Rc::clone);
+        if let Some(f) = cb {
             f(replica_id);
         }
     }
@@ -631,6 +808,53 @@ mod tests {
         assert_eq!(bus.background_tasks.borrow().len(), 2);
     }
 
+    #[test]
+    #[should_panic(expected = "MessageBusConfig::max_batch must be in")]
+    fn max_batch_oversize_rejected() {
+        let cfg = MessageBusConfig {
+            max_batch: 4096,
+            ..MessageBusConfig::default()
+        };
+        let _ = IggyMessageBus::with_config(0, cfg);
+    }
+
+    #[test]
+    #[should_panic(expected = "MessageBusConfig::max_batch must be in")]
+    fn max_batch_zero_rejected() {
+        let cfg = MessageBusConfig {
+            max_batch: 0,
+            ..MessageBusConfig::default()
+        };
+        let _ = IggyMessageBus::with_config(0, cfg);
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn notify_connection_lost_handles_reentrant_install() {
+        // Closure swaps itself out via `set_connection_lost_fn`, which
+        // calls `borrow_mut`. The pre-fix code held a `Ref` across the
+        // closure invocation and panicked here.
+        let bus = std::rc::Rc::new(IggyMessageBus::new(0));
+        let bus_for_closure = bus.clone();
+        let counter: std::rc::Rc<std::cell::Cell<u8>> = std::rc::Rc::new(std::cell::Cell::new(0));
+        let counter_inner = counter.clone();
+        let cb: ConnectionLostFn = std::rc::Rc::new(move |_replica: u8| {
+            counter_inner.set(counter_inner.get() + 1);
+            // Reentrant install: replace the closure mid-invoke.
+            let counter_replacement = counter_inner.clone();
+            bus_for_closure.set_connection_lost_fn(std::rc::Rc::new(move |_| {
+                counter_replacement.set(counter_replacement.get() + 10);
+            }));
+        });
+        bus.set_connection_lost_fn(cb);
+
+        bus.notify_connection_lost(1); // first closure runs (+1) and swaps
+        assert_eq!(counter.get(), 1);
+
+        bus.notify_connection_lost(1); // second closure runs (+10)
+        assert_eq!(counter.get(), 11);
+    }
+
     #[compio::test]
     #[allow(clippy::future_not_send)]
     async fn shutdown_loop_drains_tasks_added_during_shutdown() {
diff --git a/core/message_bus/src/lifecycle/connection_registry.rs b/core/message_bus/src/lifecycle/connection_registry.rs
index 5fd6d982f..ceba97226 100644
--- a/core/message_bus/src/lifecycle/connection_registry.rs
+++ b/core/message_bus/src/lifecycle/connection_registry.rs
@@ -492,8 +492,8 @@ async fn drain_handle<K: Debug>(
 /// the right trade-off. Replicas are capped at 256 by the keyspace, and
 /// in practice the cluster has 3-7 replicas, so a fixed
 /// `[Option<Entry>; 256]` avoids every send-path lookup paying hash +
-/// bucket indirection. Storage is ~10 KB per bus, paid once at
-/// construction.
+/// bucket indirection. Storage is on the order of tens of KB per bus,
+/// paid once at construction.
 ///
 /// The API mirrors [`ConnectionRegistry`] exactly; `IggyMessageBus`
 /// swaps the backing type transparently to every existing call site.
diff --git a/core/message_bus/src/lifecycle/mod.rs b/core/message_bus/src/lifecycle/mod.rs
index 36dbacf4c..bfc1f9294 100644
--- a/core/message_bus/src/lifecycle/mod.rs
+++ b/core/message_bus/src/lifecycle/mod.rs
@@ -19,7 +19,7 @@
 //!
 //! A root cancellation token and a keyed connection registry. Intentionally
 //! narrow: these are the only two abstractions we need to drive graceful
-//! shutdown for the TCP path (IGGY-99).
+//! shutdown across every transport plane (TCP, TCP-TLS, WS, WSS, QUIC).
 
 pub mod connection_registry;
 pub mod shutdown;
diff --git a/core/message_bus/src/replica/io.rs b/core/message_bus/src/replica/io.rs
new file mode 100644
index 000000000..d8318ab96
--- /dev/null
+++ b/core/message_bus/src/replica/io.rs
@@ -0,0 +1,502 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Shard-0 listener bootstrap.
+//!
+//! Every shard on a node instantiates its own `Rc<IggyMessageBus>` on its
+//! own compio runtime, but only shard 0 terminates inbound traffic: it
+//! binds the replica listener, the plain-TCP client listener, and
+//! optionally the WS, QUIC, TCP-TLS, and WSS client listeners. It also
+//! dials higher-id peers for the replica plane. Each accepted or
+//! dialed connection is handed to the delegate callback supplied by
+//! the caller (typically wrapping `shard::coordinator::ShardZeroCoordinator`).
+//! TCP and pre-upgrade WS callbacks duplicate the fd and ship it to
+//! the owning shard via the inter-shard `ShardFrame` channel; TCP-TLS,
+//! WSS, and QUIC callbacks install locally on shard 0 (the connection
+//! state is not serialisable so cross-shard handover is not possible).
+//!
+//! Non-zero shards early-return `Ok(None)`; the launcher calls this helper
+//! unconditionally per shard, so non-zero shards just have no listener
+//! binding and rely on `send_to_*` slow-path forwarding to reach the
+//! owning shard.
+
+use std::net::SocketAddr;
+use std::rc::Rc;
+use std::time::Duration;
+
+use iggy_common::IggyError;
+use rustls::pki_types::{CertificateDer, PrivateKeyDer};
+
+use crate::connector::start as start_connector;
+use crate::replica::listener::{bind as bind_replica_listener, run as run_replica_listener};
+use crate::transports::quic::server_config_with_cert;
+use crate::transports::tls::TlsServerCredentials;
+use crate::{
+    AcceptedClientFn, AcceptedQuicClientFn, AcceptedReplicaFn, AcceptedTlsClientFn,
+    AcceptedWsClientFn, AcceptedWssClientFn, IggyMessageBus, client_listener,
+};
+
+/// QUIC server credentials passed by the bootstrap layer.
+///
+/// The cert chain is the leaf-first sequence rustls expects; the key
+/// is the server's private key in DER form. Tests use rcgen to mint a
+/// throwaway pair; production callers load real PKI material via
+/// `core/server-ng`'s `[quic.certificate]` config section.
+pub struct QuicServerCredentials {
+    pub cert_chain: Vec<CertificateDer<'static>>,
+    pub key_der: PrivateKeyDer<'static>,
+}
+
+/// Bound addresses returned to shard 0 after the listeners come up.
+///
+/// `ws`, `quic`, `tcp_tls`, and `wss` are populated only when the
+/// corresponding `start_on_shard_zero` parameters are `Some`; an
+/// unconfigured plane stays `None`.
+#[derive(Debug, Clone)]
+pub struct BoundPlanes {
+    pub replica: SocketAddr,
+    pub client: SocketAddr,
+    pub ws: Option<SocketAddr>,
+    pub quic: Option<SocketAddr>,
+    pub tcp_tls: Option<SocketAddr>,
+    pub wss: Option<SocketAddr>,
+}
+
+/// Boot-time check: every TCP-family listener address must occupy a
+/// distinct `(ip, port)` slot.
+///
+/// TCP-family listeners (`replica`, `client`, `ws`, `tcp_tls`, `wss`)
+/// share the TCP port space, so any pair sharing `(ip, port)` is a
+/// bind-time conflict. The QUIC listener binds a UDP socket: it
+/// occupies a separate port namespace and is allowed to share a port
+/// with any TCP-family listener (a common operator choice for "QUIC
+/// on 443 over UDP, HTTPS on 443 over TCP"). UDP-vs-UDP conflicts are
+/// not possible today because QUIC is the only UDP listener.
+///
+/// # Panics
+///
+/// Panics with a message naming the conflicting pair. Boot-time
+/// validation; surfaces operator misconfiguration loudly rather than
+/// letting one listener silently lose a `EADDRINUSE` race against
+/// another.
+pub fn assert_listen_addrs_distinct(
+    replica: SocketAddr,
+    client: SocketAddr,
+    ws: Option<SocketAddr>,
+    _quic: Option<SocketAddr>,
+    tcp_tls: Option<SocketAddr>,
+    wss: Option<SocketAddr>,
+) {
+    let tcp_slots: [(&str, Option<SocketAddr>); 5] = [
+        ("replica", Some(replica)),
+        ("client", Some(client)),
+        ("ws", ws),
+        ("tcp_tls", tcp_tls),
+        ("wss", wss),
+    ];
+    for i in 0..tcp_slots.len() {
+        for j in (i + 1)..tcp_slots.len() {
+            let (a_name, a) = tcp_slots[i];
+            let (b_name, b) = tcp_slots[j];
+            // Port 0 means "OS-assigned"; two slots at port 0 receive
+            // distinct kernel ports at bind time, so they don't
+            // conflict. Tests rely on this for loopback fixtures.
+            if let (Some(a), Some(b)) = (a, b)
+                && a == b
+                && a.port() != 0
+            {
+                panic!("listener address conflict: {a_name} and {b_name} both bind to {a}");
+            }
+        }
+    }
+}
+
+/// Bind the replica + client listeners on shard 0 and start the
+/// outbound replica connector.
+///
+/// Optionally bind WS, QUIC, TCP-TLS, and WSS client listeners
+/// alongside. Non-zero shards early-return `Ok(None)`.
+///
+/// Each accepted / dialed connection is handed to the supplied
+/// delegate callback. The TCP callbacks (`on_accepted_replica`,
+/// `on_accepted_client`) and the WS callback (`on_accepted_ws_client`)
+/// are responsible for the dup-fd + inter-shard send. The QUIC,
+/// TCP-TLS, and WSS callbacks
+/// (`on_accepted_quic_client` / `on_accepted_tcp_tls_client` /
+/// `on_accepted_wss_client`) install locally on shard 0; those planes
+/// have no cross-shard handover (QUIC by transport design, TLS-family
+/// because the rustls connection state is not serialisable).
+///
+/// `ws_listen_addr` / `on_accepted_ws_client` are paired: if either is
+/// `Some`, both must be. Same for the QUIC trio
+/// (`quic_listen_addr` / `quic_credentials` / `on_accepted_quic_client`)
+/// and for the TCP-TLS / WSS trios
+/// (`*_listen_addr` / `*_credentials` / `on_accepted_*_client`).
+///
+/// # Panics
+///
+/// Panics on TCP-family `(ip, port)` overlap among the populated
+/// `replica`, `client`, `ws`, `tcp_tls`, `wss` slots — see
+/// [`assert_listen_addrs_distinct`].
+///
+/// # Errors
+///
+/// Returns `IggyError::CannotBindToSocket` if any listener bind fails;
+/// `IggyError::InvalidConfiguration` if any of the
+/// listen-addr / credentials / callback trios is partially populated;
+/// `IggyError::IoError` if a TLS-family server config cannot be built
+/// from the supplied credentials.
+#[allow(
+    clippy::future_not_send,
+    clippy::too_many_arguments,
+    clippy::too_many_lines,
+    clippy::similar_names
+)]
+pub async fn start_on_shard_zero(
+    bus: &Rc<IggyMessageBus>,
+    replica_listen_addr: SocketAddr,
+    client_listen_addr: SocketAddr,
+    ws_listen_addr: Option<SocketAddr>,
+    quic_listen_addr: Option<SocketAddr>,
+    quic_credentials: Option<QuicServerCredentials>,
+    tcp_tls_listen_addr: Option<SocketAddr>,
+    tcp_tls_credentials: Option<TlsServerCredentials>,
+    wss_listen_addr: Option<SocketAddr>,
+    wss_credentials: Option<TlsServerCredentials>,
+    cluster_id: u128,
+    self_id: u8,
+    replica_count: u8,
+    peers: Vec<(u8, SocketAddr)>,
+    on_accepted_replica: AcceptedReplicaFn,
+    on_accepted_client: AcceptedClientFn,
+    on_accepted_ws_client: Option<AcceptedWsClientFn>,
+    on_accepted_quic_client: Option<AcceptedQuicClientFn>,
+    on_accepted_tcp_tls_client: Option<AcceptedTlsClientFn>,
+    on_accepted_wss_client: Option<AcceptedWssClientFn>,
+    reconnect_period: Duration,
+) -> Result<Option<BoundPlanes>, IggyError> {
+    if bus.shard_id() != 0 {
+        return Ok(None);
+    }
+
+    assert_listen_addrs_distinct(
+        replica_listen_addr,
+        client_listen_addr,
+        ws_listen_addr,
+        quic_listen_addr,
+        tcp_tls_listen_addr,
+        wss_listen_addr,
+    );
+
+    let (replica_listener, replica_bound) = bind_replica_listener(replica_listen_addr).await?;
+    let (clients_listener, client_bound) = client_listener::tcp::bind(client_listen_addr).await?;
+
+    let token_for_replica = bus.token();
+    let on_accepted_replica_for_listener = on_accepted_replica.clone();
+    let listener_max_message_size = bus.config().max_message_size;
+    let replica_handle = compio::runtime::spawn(async move {
+        run_replica_listener(
+            replica_listener,
+            token_for_replica,
+            cluster_id,
+            self_id,
+            replica_count,
+            on_accepted_replica_for_listener,
+            listener_max_message_size,
+        )
+        .await;
+    });
+    bus.track_background(replica_handle);
+
+    let token_for_client = bus.token();
+    let client_handle = compio::runtime::spawn(async move {
+        client_listener::tcp::run(clients_listener, token_for_client, on_accepted_client).await;
+    });
+    bus.track_background(client_handle);
+
+    let ws_bound = match (ws_listen_addr, on_accepted_ws_client) {
+        (Some(addr), Some(on_accepted_ws)) => {
+            let (ws_listener, ws_bound) = client_listener::ws::bind(addr).await?;
+            let token_for_ws = bus.token();
+            let ws_handle = compio::runtime::spawn(async move {
+                client_listener::ws::run(ws_listener, token_for_ws, on_accepted_ws).await;
+            });
+            bus.track_background(ws_handle);
+            Some(ws_bound)
+        }
+        (None, None) => None,
+        _ => {
+            return Err(IggyError::InvalidConfiguration);
+        }
+    };
+
+    let quic_bound = match (quic_listen_addr, quic_credentials, on_accepted_quic_client) {
+        (Some(addr), Some(creds), Some(on_accepted_quic)) => {
+            let server_config = server_config_with_cert(creds.cert_chain, creds.key_der)
+                .map_err(|e| IggyError::IoError(format!("QUIC server config build failed: {e}")))?;
+            let (endpoint, quic_bound) = client_listener::quic::bind(addr, server_config).await?;
+            let token_for_quic = bus.token();
+            let quic_handle = compio::runtime::spawn(async move {
+                client_listener::quic::run(endpoint, token_for_quic, on_accepted_quic).await;
+            });
+            bus.track_background(quic_handle);
+            Some(quic_bound)
+        }
+        (None, None, None) => None,
+        _ => {
+            return Err(IggyError::InvalidConfiguration);
+        }
+    };
+
+    let tcp_tls_bound = match (
+        tcp_tls_listen_addr,
+        tcp_tls_credentials,
+        on_accepted_tcp_tls_client,
+    ) {
+        (Some(addr), Some(creds), Some(on_accepted_tls)) => {
+            let (listener, server_config, tls_bound) =
+                client_listener::tcp_tls::bind(addr, creds).await?;
+            let token_for_tls = bus.token();
+            let tls_handle = compio::runtime::spawn(async move {
+                client_listener::tcp_tls::run(
+                    listener,
+                    server_config,
+                    token_for_tls,
+                    on_accepted_tls,
+                )
+                .await;
+            });
+            bus.track_background(tls_handle);
+            Some(tls_bound)
+        }
+        (None, None, None) => None,
+        _ => {
+            return Err(IggyError::InvalidConfiguration);
+        }
+    };
+
+    let wss_bound = match (wss_listen_addr, wss_credentials, on_accepted_wss_client) {
+        (Some(addr), Some(creds), Some(on_accepted_wss)) => {
+            let (listener, server_config, wss_bound) =
+                client_listener::wss::bind(addr, creds).await?;
+            let token_for_wss = bus.token();
+            let wss_handle = compio::runtime::spawn(async move {
+                client_listener::wss::run(listener, server_config, token_for_wss, on_accepted_wss)
+                    .await;
+            });
+            bus.track_background(wss_handle);
+            Some(wss_bound)
+        }
+        (None, None, None) => None,
+        _ => {
+            return Err(IggyError::InvalidConfiguration);
+        }
+    };
+
+    start_connector(
+        bus,
+        cluster_id,
+        self_id,
+        peers,
+        on_accepted_replica,
+        reconnect_period,
+    )
+    .await;
+
+    Ok(Some(BoundPlanes {
+        replica: replica_bound,
+        client: client_bound,
+        ws: ws_bound,
+        quic: quic_bound,
+        tcp_tls: tcp_tls_bound,
+        wss: wss_bound,
+    }))
+}
+
+/// [`start_on_shard_zero`] defaulting `reconnect_period` to the bus's
+/// [`crate::MessageBusConfig::reconnect_period`].
+///
+/// Leaves the WS, QUIC, TCP-TLS, and WSS listener slots unconfigured
+/// (`None`). Convenience entry for TCP-only deployments and existing
+/// tests; prefer the full [`start_on_shard_zero`] in production where
+/// the optional planes come from `core/server-ng`'s config.
+///
+/// # Errors
+///
+/// Returns `IggyError::CannotBindToSocket` if either listener bind fails.
+#[allow(clippy::future_not_send)]
+#[allow(clippy::too_many_arguments)]
+pub async fn start_on_shard_zero_default(
+    bus: &Rc<IggyMessageBus>,
+    replica_listen_addr: SocketAddr,
+    client_listen_addr: SocketAddr,
+    cluster_id: u128,
+    self_id: u8,
+    replica_count: u8,
+    peers: Vec<(u8, SocketAddr)>,
+    on_accepted_replica: AcceptedReplicaFn,
+    on_accepted_client: AcceptedClientFn,
+) -> Result<Option<BoundPlanes>, IggyError> {
+    let reconnect_period = bus.config().reconnect_period;
+    start_on_shard_zero(
+        bus,
+        replica_listen_addr,
+        client_listen_addr,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        cluster_id,
+        self_id,
+        replica_count,
+        peers,
+        on_accepted_replica,
+        on_accepted_client,
+        None,
+        None,
+        None,
+        None,
+        reconnect_period,
+    )
+    .await
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn addr(port: u16) -> SocketAddr {
+        format!("127.0.0.1:{port}").parse().unwrap()
+    }
+
+    #[test]
+    fn distinct_addrs_pass() {
+        assert_listen_addrs_distinct(
+            addr(9090),
+            addr(8090),
+            Some(addr(8092)),
+            Some(addr(8080)),
+            Some(addr(8093)),
+            Some(addr(8094)),
+        );
+    }
+
+    #[test]
+    fn ws_unset_passes() {
+        assert_listen_addrs_distinct(addr(9090), addr(8090), None, Some(addr(8080)), None, None);
+    }
+
+    #[test]
+    fn quic_shares_port_with_tcp_replica_ok() {
+        // QUIC on UDP and replica on TCP can share a port (separate
+        // kernel namespaces). Operator choice "443 on TCP and UDP".
+        assert_listen_addrs_distinct(
+            addr(443),
+            addr(8090),
+            Some(addr(8092)),
+            Some(addr(443)),
+            None,
+            None,
+        );
+    }
+
+    #[test]
+    fn quic_shares_port_with_tcp_client_ok() {
+        assert_listen_addrs_distinct(
+            addr(9090),
+            addr(443),
+            Some(addr(8092)),
+            Some(addr(443)),
+            None,
+            None,
+        );
+    }
+
+    #[test]
+    fn quic_shares_port_with_tcp_tls_ok() {
+        // QUIC's UDP namespace is also disjoint from the TLS-family
+        // TCP listeners; "443 over UDP for QUIC + 443 over TCP for
+        // TCP-TLS / WSS" is a valid co-location.
+        assert_listen_addrs_distinct(
+            addr(9090),
+            addr(8090),
+            None,
+            Some(addr(443)),
+            Some(addr(443)),
+            None,
+        );
+    }
+
+    #[test]
+    #[should_panic(expected = "listener address conflict: replica and client")]
+    fn replica_client_overlap_panics() {
+        assert_listen_addrs_distinct(addr(8090), addr(8090), Some(addr(8092)), None, None, None);
+    }
+
+    #[test]
+    #[should_panic(expected = "listener address conflict: replica and ws")]
+    fn replica_ws_overlap_panics() {
+        assert_listen_addrs_distinct(addr(9090), addr(8090), Some(addr(9090)), None, None, None);
+    }
+
+    #[test]
+    #[should_panic(expected = "listener address conflict: client and ws")]
+    fn client_ws_overlap_panics() {
+        assert_listen_addrs_distinct(addr(9090), addr(8090), Some(addr(8090)), None, None, None);
+    }
+
+    #[test]
+    #[should_panic(expected = "listener address conflict: client and tcp_tls")]
+    fn client_tcp_tls_overlap_panics() {
+        assert_listen_addrs_distinct(addr(9090), addr(8090), None, None, Some(addr(8090)), None);
+    }
+
+    #[test]
+    #[should_panic(expected = "listener address conflict: ws and wss")]
+    fn ws_wss_overlap_panics() {
+        assert_listen_addrs_distinct(
+            addr(9090),
+            addr(8090),
+            Some(addr(8092)),
+            None,
+            None,
+            Some(addr(8092)),
+        );
+    }
+
+    #[test]
+    #[should_panic(expected = "listener address conflict: tcp_tls and wss")]
+    fn tcp_tls_wss_overlap_panics() {
+        assert_listen_addrs_distinct(
+            addr(9090),
+            addr(8090),
+            None,
+            None,
+            Some(addr(8093)),
+            Some(addr(8093)),
+        );
+    }
+
+    #[test]
+    #[should_panic(expected = "listener address conflict: replica and tcp_tls")]
+    fn replica_tcp_tls_overlap_panics() {
+        assert_listen_addrs_distinct(addr(9090), addr(8090), None, None, Some(addr(9090)), None);
+    }
+}
diff --git a/core/message_bus/src/replica_listener.rs b/core/message_bus/src/replica/listener.rs
similarity index 69%
rename from core/message_bus/src/replica_listener.rs
rename to core/message_bus/src/replica/listener.rs
index 9d9db9192..4b2993cd5 100644
--- a/core/message_bus/src/replica_listener.rs
+++ b/core/message_bus/src/replica/listener.rs
@@ -17,24 +17,44 @@
 
 //! Inbound TCP listener for replica-to-replica consensus traffic.
 //!
-//! Runs only on shard 0. On every successful `Ping` handshake the listener
-//! hands the accepted `TcpStream` to an `on_accepted` callback provided by
-//! the shard bootstrap, which dup-and-ships the fd to the owning shard via
-//! the inter-shard channel (see `shard::coordinator::ShardZeroCoordinator`).
-//! This module no longer installs writer / reader tasks itself.
+//! # Replica plane = TCP only
+//!
+//! This module is TCP-only by design, not by omission. Do NOT add WS,
+//! QUIC, or HTTP paths here. The VSR chain-hash safety proof
+//! (`last_prepare_checksum` in `core/consensus/src/plane_helpers.rs`),
+//! the fd-delegation path (`F_DUPFD_CLOEXEC` needs a dupable plaintext
+//! fd), the `writev` batching inside the TCP transport, and the single-digit
+//! RTT assumptions used by the view-change timer all rest on a FIFO
+//! byte stream between a bounded set of replicas on a trusted LAN. A
+//! datagram-oriented or gateway-terminated transport violates one or
+//! more of those assumptions.
+//!
+//! Runs only on shard 0. On every accepted `Ping` frame the listener
+//! hands the accepted `TcpStream` to an `on_accepted` callback provided
+//! by the shard bootstrap, which dup-and-ships the fd to the owning
+//! shard via the inter-shard channel
+//! (see `shard::coordinator::ShardZeroCoordinator`). This module no
+//! longer installs writer / reader tasks itself.
 //!
 //! Duplicate connections are eliminated by directionality: each replica
 //! only dials peers with strictly greater ids and only accepts inbound
 //! from peers with strictly lower ids. No race, no tiebreaker.
 //!
-//! # Security
+//! # Auth
 //!
-//! The `Ping` handshake validates `cluster_id`, the directional bound, and
-//! `replica_count`. There is NO shared secret, no mTLS, no version
-//! negotiation. Deploy the replica listener on a trusted network boundary
-//! (cluster-local VPC, private subnet, overlay, or equivalent) - NEVER
-//! expose the replica port directly to the public internet. Follow-up work
-//! on an authenticated handshake is tracked under IGGY-112.
+//! The `Ping` frame announces the dialing replica's id in plaintext;
+//! the bus uses it to key the registry but does NOT validate replica
+//! identity at the transport layer. Mirrors the SDK-client plane: the
+//! connection always succeeds, identity is established post-handshake
+//! by the caller (`server-ng`) via the future `LOGIN_REPLICA` command
+//! that carries the cluster's shared secret. Until that command
+//! succeeds the caller MUST treat the replica as unauthenticated and
+//! refuse to honor consensus messages from it.
+//!
+//! TLS / encryption is NOT provided here: operators still need to
+//! deploy the replica port on a trusted network boundary
+//! (cluster-local VPC, private subnet, overlay) if wire confidentiality
+//! is required.
 
 use crate::framing;
 use crate::lifecycle::ShutdownToken;
@@ -74,11 +94,12 @@ pub async fn bind(addr: SocketAddr) -> Result<(TcpListener, SocketAddr), IggyErr
     Ok((listener, actual))
 }
 
-/// Run the inbound replica listener accept loop until the shutdown token
-/// fires. Every successful handshake fires the `on_accepted` callback; the
-/// callback owns the accepted stream from that point on.
+/// Run the inbound replica listener accept loop until the shutdown token fires.
+///
+/// Every accepted connection that passes the plaintext `Ping` frame
+/// check fires the `on_accepted` callback; the callback owns the
+/// accepted stream from that point on.
 #[allow(clippy::future_not_send)]
-#[allow(clippy::too_many_arguments)]
 pub async fn run(
     listener: TcpListener,
     token: ShutdownToken,
@@ -101,14 +122,15 @@ pub async fn run(
             result = listener.accept().fuse() => {
                 match result {
                     Ok((mut stream, peer_addr)) => {
-                        match handshake(
+                        match handshake_read(
                             &mut stream,
                             cluster_id,
                             self_id,
                             replica_count,
                             max_message_size,
                         )
-                        .await {
+                        .await
+                        {
                             Ok(peer_id) => {
                                 on_accepted(stream, peer_id);
                             }
@@ -126,20 +148,11 @@ pub async fn run(
     }
 }
 
-/// Read and validate the `Ping` handshake from an inbound peer connection.
-///
-/// Returns the peer replica id on success.
-//
-// TODO(IGGY-112): authenticate the replica handshake.
-//
-// Current validation covers `cluster_id` and the directional id bound
-// only: no shared secret, no mTLS, no protocol version negotiation.
-// Any peer that reaches the replica port and knows the `cluster_id`
-// can pose as a replica. Acceptable on the assumed trusted network
-// boundary; hardening is tracked under IGGY-112 (authenticated
-// handshake + version negotiation).
+/// Read the 256 B `Ping` frame, enforce command + cluster match and the
+/// directional rule, return the announced replica id. No transport-level
+/// authentication.
 #[allow(clippy::future_not_send)]
-async fn handshake(
+async fn handshake_read(
     stream: &mut TcpStream,
     our_cluster: u128,
     self_id: u8,
diff --git a/core/message_bus/src/transports/mod.rs b/core/message_bus/src/replica/mod.rs
similarity index 69%
copy from core/message_bus/src/transports/mod.rs
copy to core/message_bus/src/replica/mod.rs
index aa0c7c27b..a9dd182aa 100644
--- a/core/message_bus/src/transports/mod.rs
+++ b/core/message_bus/src/replica/mod.rs
@@ -15,8 +15,13 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Transport abstraction - placeholder.
+//! Replica plane: shard-0 bootstrap orchestrator (`io`) and inbound
+//! TCP listener (`listener`).
 //!
-//! Tracked by IGGY-112. This module is deliberately empty until the
-//! `Transport` / `TransportListener` / `TransportConn` trait family lands;
-//! do not grow speculative scaffolding here.
+//! The replica plane is TCP-only by design — see [`listener`] for the
+//! load-bearing invariants. Outbound dialing and the per-process
+//! shard-0 bootstrap (which also binds the SDK client listeners) live
+//! in [`io`].
+
+pub mod io;
+pub mod listener;
diff --git a/core/message_bus/src/replica_io.rs b/core/message_bus/src/replica_io.rs
deleted file mode 100644
index 5ef866ff3..000000000
--- a/core/message_bus/src/replica_io.rs
+++ /dev/null
@@ -1,154 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-//! Shard-0 TCP plane bootstrap.
-//!
-//! Every shard on a node instantiates its own `Rc<IggyMessageBus>` on its
-//! own compio runtime, but only shard 0 terminates TCP: it binds the
-//! replica listener, binds the client listener, and dials higher-id peers.
-//! Each accepted or dialed connection is handed to the delegate callbacks
-//! supplied by the caller (typically wrapping `shard::coordinator::ShardZeroCoordinator`),
-//! which duplicate the fd and ship it to the owning shard via the
-//! inter-shard `ShardFrame` channel.
-//!
-//! Non-zero shards early-return `Ok(None)`; the launcher calls this helper
-//! unconditionally per shard, so non-zero shards just have no listener
-//! binding and rely on `send_to_*` slow-path forwarding to reach the
-//! owning shard.
-
-use std::net::SocketAddr;
-use std::rc::Rc;
-use std::time::Duration;
-
-use iggy_common::IggyError;
-
-use crate::connector::start as start_connector;
-use crate::replica_listener::{bind as bind_replica_listener, run as run_replica_listener};
-use crate::{AcceptedClientFn, AcceptedReplicaFn, IggyMessageBus, client_listener};
-
-/// Bound addresses returned to shard 0 after the listeners come up.
-#[derive(Debug, Clone, Copy)]
-pub struct BoundPlanes {
-    pub replica: SocketAddr,
-    pub client: SocketAddr,
-}
-
-/// Bind the replica + client listeners on shard 0 and start the outbound
-/// replica connector. Non-zero shards early-return `Ok(None)`.
-///
-/// Each accepted / dialed TCP stream is handed to the supplied delegate
-/// callback: `on_accepted_replica` for inbound replicas and for freshly
-/// dialed higher-id peers; `on_accepted_client` for SDK client accepts.
-/// The callbacks are responsible for the dup-fd + inter-shard send.
-///
-/// # Errors
-///
-/// Returns `IggyError::CannotBindToSocket` if either listener bind fails.
-#[allow(clippy::future_not_send)]
-#[allow(clippy::too_many_arguments)]
-pub async fn start_on_shard_zero(
-    bus: &Rc<IggyMessageBus>,
-    replica_listen_addr: SocketAddr,
-    client_listen_addr: SocketAddr,
-    cluster_id: u128,
-    self_id: u8,
-    replica_count: u8,
-    peers: Vec<(u8, SocketAddr)>,
-    on_accepted_replica: AcceptedReplicaFn,
-    on_accepted_client: AcceptedClientFn,
-    reconnect_period: Duration,
-) -> Result<Option<BoundPlanes>, IggyError> {
-    if bus.shard_id() != 0 {
-        return Ok(None);
-    }
-
-    let (replica_listener, replica_bound) = bind_replica_listener(replica_listen_addr).await?;
-    let (clients_listener, client_bound) = client_listener::bind(client_listen_addr).await?;
-
-    let token_for_replica = bus.token();
-    let on_accepted_replica_for_listener = on_accepted_replica.clone();
-    let listener_max_message_size = bus.config().max_message_size;
-    let replica_handle = compio::runtime::spawn(async move {
-        run_replica_listener(
-            replica_listener,
-            token_for_replica,
-            cluster_id,
-            self_id,
-            replica_count,
-            on_accepted_replica_for_listener,
-            listener_max_message_size,
-        )
-        .await;
-    });
-    bus.track_background(replica_handle);
-
-    let token_for_client = bus.token();
-    let client_handle = compio::runtime::spawn(async move {
-        client_listener::run(clients_listener, token_for_client, on_accepted_client).await;
-    });
-    bus.track_background(client_handle);
-
-    start_connector(
-        bus,
-        cluster_id,
-        self_id,
-        peers,
-        on_accepted_replica,
-        reconnect_period,
-    )
-    .await;
-
-    Ok(Some(BoundPlanes {
-        replica: replica_bound,
-        client: client_bound,
-    }))
-}
-
-/// [`start_on_shard_zero`] defaulting `reconnect_period` to the bus's
-/// [`crate::MessageBusConfig::reconnect_period`].
-///
-/// # Errors
-///
-/// Returns `IggyError::CannotBindToSocket` if either listener bind fails.
-#[allow(clippy::future_not_send)]
-#[allow(clippy::too_many_arguments)]
-pub async fn start_on_shard_zero_default(
-    bus: &Rc<IggyMessageBus>,
-    replica_listen_addr: SocketAddr,
-    client_listen_addr: SocketAddr,
-    cluster_id: u128,
-    self_id: u8,
-    replica_count: u8,
-    peers: Vec<(u8, SocketAddr)>,
-    on_accepted_replica: AcceptedReplicaFn,
-    on_accepted_client: AcceptedClientFn,
-) -> Result<Option<BoundPlanes>, IggyError> {
-    let reconnect_period = bus.config().reconnect_period;
-    start_on_shard_zero(
-        bus,
-        replica_listen_addr,
-        client_listen_addr,
-        cluster_id,
-        self_id,
-        replica_count,
-        peers,
-        on_accepted_replica,
-        on_accepted_client,
-        reconnect_period,
-    )
-    .await
-}
diff --git a/core/message_bus/src/transports/mod.rs b/core/message_bus/src/transports/mod.rs
index aa0c7c27b..ce46f9b6b 100644
--- a/core/message_bus/src/transports/mod.rs
+++ b/core/message_bus/src/transports/mod.rs
@@ -15,8 +15,148 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Transport abstraction - placeholder.
+//! Transport abstraction for the `message_bus` wire planes.
 //!
-//! Tracked by IGGY-112. This module is deliberately empty until the
-//! `Transport` / `TransportListener` / `TransportConn` trait family lands;
-//! do not grow speculative scaffolding here.
+//! # Trait family
+//!
+//! - [`TransportListener`] binds a local address and yields inbound
+//!   [`TransportConn`]s on `accept`.
+//! - [`TransportConn`] is a single byte-oriented connection. Each
+//!   transport owns its own internal task topology behind a single
+//!   [`TransportConn::run`] entry point that consumes the connection,
+//!   pushes inbound consensus frames into [`ActorContext::in_tx`], and
+//!   drains outbound frames from [`ActorContext::rx`] until the bus tears
+//!   the connection down.
+//!
+//! # Invariants the trait surface must preserve
+//!
+//! - **No-yield on caller-side**: producers call
+//!   [`MessageBus::send_to_client`](crate::MessageBus::send_to_client) /
+//!   [`send_to_replica`](crate::MessageBus::send_to_replica), which push
+//!   onto a bounded `async_channel` and return `Ready` on the first
+//!   poll. The single yield point in the wire path lives inside the
+//!   transport's `run` body, invoked by the per-peer task.
+//! - **Batch atomicity**: a TCP-plane writer drains the per-peer
+//!   `BusReceiver` into one `writev` per batch. Per-frame transports
+//!   (WS, QUIC, TLS) do NOT batch: each iteration of their pump pulls
+//!   one `BusMessage` from the receiver and writes it independently;
+//!   any write failure tears the connection down.
+//! - **Zero-copy `Frozen` ownership**: outbound frames are handed to
+//!   the kernel without intermediate copies on plaintext TCP. WS / QUIC
+//!   / TLS planes pay structural copies in their record layers; see the
+//!   per-transport module rustdoc.
+//!
+//! # Design notes
+//!
+//! - `'static` bound on [`TransportConn`]: the conn is moved into a
+//!   `compio::runtime::spawn`'d task by the installer, which requires
+//!   owned data.
+//! - fd-delegation (`F_DUPFD_CLOEXEC`) stays TCP-only and lives outside
+//!   this trait (see [`crate::fd_transfer`]). Other transports terminate
+//!   on shard 0 and forward `Frozen<MESSAGE_ALIGN>` over the inter-shard
+//!   flume via [`crate::ShardForwardFn`].
+//!
+//! Per-transport impls live in
+//! `transports/{tcp,tcp_tls,ws,wss,quic}.rs`.
+
+pub mod quic;
+pub mod tcp;
+pub mod tcp_tls;
+pub mod tls;
+pub mod ws;
+pub mod wss;
+
+use crate::lifecycle::{BusReceiver, ShutdownToken};
+use async_channel::Sender;
+use iggy_binary_protocol::{GenericHeader, Message};
+use std::io;
+use std::net::SocketAddr;
+
+/// Inbound listener. The accept loops in [`crate::replica::listener`]
+/// and [`crate::client_listener`] invoke `accept` inside a `select!`
+/// against the bus-wide shutdown token.
+#[allow(async_fn_in_trait)]
+pub trait TransportListener {
+    /// Connection type produced by this listener.
+    type Conn: TransportConn;
+
+    /// Accept the next inbound connection.
+    ///
+    /// Returns once a peer has completed the transport-level handshake
+    /// (TCP SYN/ACK; TLS; QUIC handshake). On the replica plane a
+    /// plaintext `Ping` frame announcing `replica_id` runs on the
+    /// returned connection, after this call.
+    ///
+    /// # Errors
+    ///
+    /// Returns [`io::Error`] on socket faults. The caller logs and
+    /// continues; a fatal error must be surfaced by dropping the
+    /// listener rather than through this return value.
+    async fn accept(&self) -> io::Result<(Self::Conn, SocketAddr)>;
+}
+
+/// Per-connection runtime context the bus hands to [`TransportConn::run`].
+///
+/// The transport is the single producer for `in_tx` (inbound framed
+/// messages) and the single consumer of `rx` (outbound consensus frames
+/// scheduled by the bus's `send_to_*` path). `shutdown` is a fused
+/// signal merged from the bus-wide shutdown and the per-connection
+/// shutdown that the installer triggers on insert-race; either firing
+/// must wake any blocked I/O the transport holds.
+pub struct ActorContext {
+    /// Inbound channel: the transport pushes one decoded
+    /// [`Message<GenericHeader>`] per received frame. The receiver side
+    /// is owned by the installer's dispatch task, which forwards each
+    /// message to the bus's per-plane handler (`MessageHandler` /
+    /// `RequestHandler`).
+    pub in_tx: Sender<Message<GenericHeader>>,
+    /// Outbound channel: the bus's `send_to_*` path pushes `Frozen`
+    /// frames into the matching `Sender`; the transport drains here.
+    pub rx: BusReceiver,
+    /// Cooperative cancellation. Fires on bus shutdown OR on
+    /// per-connection shutdown (insert race); the transport must
+    /// observe it on every blocking await on the wire.
+    pub shutdown: ShutdownToken,
+    /// Maximum number of `BusMessage` entries coalesced into a single
+    /// transport-level write. Capped at `IOV_MAX / 2` on plaintext TCP
+    /// so the worst-case writev stays inside the syscall limit.
+    pub max_batch: usize,
+    /// Wire-level cap on a single framed message, in bytes. The
+    /// transport rejects undersize / oversize frames and tears the
+    /// connection down.
+    pub max_message_size: usize,
+    /// Plane label for tracing (`"replica"` or `"client"`). Static so
+    /// tracing macros can format without an allocation.
+    pub label: &'static str,
+    /// Peer identifier for tracing (replica id as decimal, client id as
+    /// `0x..` hex). Owned `String` because client ids serialize to 34
+    /// chars and replicas to ≤ 3 chars; the cost is one allocation per
+    /// installed connection.
+    pub peer: String,
+}
+
+/// Single-connection handle produced by [`TransportListener::accept`]
+/// or by a transport-specific dialer (`TcpStream::connect`, etc.).
+///
+/// `'static` so the conn can move into a `compio::runtime::spawn`'d
+/// task.
+#[allow(async_fn_in_trait)]
+pub trait TransportConn: 'static {
+    /// Take ownership of the connection, drive its internal reader and
+    /// writer tasks until shutdown, EOF, or an unrecoverable I/O error.
+    ///
+    /// The transport must:
+    ///
+    /// - Push every decoded inbound frame into [`ActorContext::in_tx`]
+    ///   in order.
+    /// - Drain [`ActorContext::rx`] FIFO and write each batch to the
+    ///   wire atomically (or fail the batch and exit).
+    /// - Observe [`ActorContext::shutdown`] on every blocking await so
+    ///   the bus's tear-down completes within the configured drain
+    ///   budget.
+    ///
+    /// Returns when the connection has terminated. Errors are logged
+    /// internally; the join handle's `Result<()>` is the bus's only
+    /// signal that the conn ended.
+    async fn run(self, ctx: ActorContext);
+}
diff --git a/core/message_bus/src/transports/quic.rs b/core/message_bus/src/transports/quic.rs
new file mode 100644
index 000000000..2cfdf473d
--- /dev/null
+++ b/core/message_bus/src/transports/quic.rs
@@ -0,0 +1,554 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! QUIC impls of the [`super`] transport traits.
+//!
+//! SDK-client plane only; replica plane stays TCP-only.
+//! `compio-quic` binds `quinn-proto` natively to the compio runtime,
+//! so no tokio bridge.
+//!
+//! # Connection model
+//!
+//! One bidirectional stream per peer (no multiplexing). The connection's
+//! first `accept_bi` pair carries every consensus frame. The
+//! [`QuicTransportListener`] accept loop drives the QUIC handshake to
+//! completion AND accepts the first bidi pair before yielding the
+//! [`QuicTransportConn`], so the owning shard's install path doesn't
+//! need an additional `await` boundary before data flows. The accept
+//! step only obtains the `(SendStream, RecvStream)` handles; no bytes
+//! are read off the streams.
+//!
+//! # Zero-copy
+//!
+//! `compio_quic::SendStream::write<T: IoBuf>` accepts
+//! `Frozen<MESSAGE_ALIGN>` directly; the writer task loops one `write`
+//! per frame (QUIC has no `sendmmsg` analog). Per-message syscalls are
+//! the documented trade-off versus the TCP `writev` path; small
+//! high-RPS workloads stay on TCP.
+//!
+//! # 0-RTT
+//!
+//! 0-RTT is off by default at the rustls layer (`max_early_data_size = 0`).
+//! `RecvStream::is_0rtt()` exists for defense-in-depth; the listener
+//! treats a `true` here as a misconfiguration and refuses the
+//! connection. Per-command 0-RTT enablement requires a per-command
+//! idempotence audit before being turned on.
+
+use super::{ActorContext, TransportConn, TransportListener};
+use crate::framing;
+use crate::lifecycle::{BusReceiver, ShutdownToken};
+use compio::BufResult;
+use compio::io::AsyncWrite;
+use compio_quic::{
+    Connection, Endpoint, Incoming, RecvStream, SendStream, VarInt, congestion,
+    crypto::rustls::QuicServerConfig,
+};
+use futures::FutureExt;
+use iggy_binary_protocol::{GenericHeader, Message};
+use std::io;
+use std::net::SocketAddr;
+use std::sync::Arc;
+use std::time::Duration;
+use tracing::{debug, warn};
+
+/// QUIC application close codes. Sized so the table fits a single cache
+/// line.
+pub const QUIC_HANDSHAKE_FAILED: u32 = 0x1001;
+pub const QUIC_AUTH_FAILED: u32 = 0x1002;
+pub const QUIC_SHUTDOWN: u32 = 0x1003;
+pub const QUIC_PROTOCOL_VIOLATION: u32 = 0x1004;
+
+/// Idle-timeout default for client-plane connections. Override at
+/// endpoint construction time if the operator needs a tighter or looser
+/// liveness budget.
+const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// Keepalive interval default. One third of [`DEFAULT_IDLE_TIMEOUT`] so
+/// up to two consecutive keepalives can be lost before the idle timer
+/// closes the connection.
+const DEFAULT_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
+
+/// Stream / receive / send window default. Matches
+/// `MessageBusConfig::default().max_message_size = 64 MiB` so a single
+/// in-flight max-size frame fits without head-of-line wait.
+const DEFAULT_WINDOW: u32 = 64 * 1024 * 1024;
+
+/// Build a [`compio_quic::TransportConfig`] tuned for the SDK-client plane.
+///
+/// Defaults: 1 bidi stream per peer, 30 s idle timeout, CUBIC congestion,
+/// 64 MiB send/receive windows.
+///
+/// # Panics
+///
+/// Panics only if `DEFAULT_IDLE_TIMEOUT` is changed to a value outside
+/// quinn-proto's `IdleTimeout` range (its `TryFrom<Duration>` rejects
+/// values above `VarInt::MAX_U64` ms). 30 s is well inside the range.
+#[must_use]
+pub fn default_transport_config() -> compio_quic::TransportConfig {
+    let mut cfg = compio_quic::TransportConfig::default();
+    cfg.max_concurrent_bidi_streams(VarInt::from_u32(1))
+        .max_concurrent_uni_streams(VarInt::from_u32(0))
+        .stream_receive_window(VarInt::from_u32(DEFAULT_WINDOW))
+        .receive_window(VarInt::from_u32(DEFAULT_WINDOW))
+        .send_window(u64::from(DEFAULT_WINDOW))
+        .max_idle_timeout(Some(
+            DEFAULT_IDLE_TIMEOUT
+                .try_into()
+                .expect("30 s fits in IdleTimeout"),
+        ))
+        .keep_alive_interval(Some(DEFAULT_KEEP_ALIVE_INTERVAL))
+        .congestion_controller_factory(Arc::new(congestion::CubicConfig::default()));
+    cfg
+}
+
+/// Inbound QUIC listener.
+///
+/// Wraps a bound [`Endpoint`]. `accept` drives the QUIC handshake and the
+/// first `accept_bi` pair to completion before yielding a
+/// [`QuicTransportConn`], so the caller (the QUIC client listener)
+/// hands a fully-set-up data plane to the install path.
+pub struct QuicTransportListener {
+    endpoint: Endpoint,
+}
+
+impl QuicTransportListener {
+    /// Wrap a pre-bound [`Endpoint`].
+    ///
+    /// Caller is responsible for the [`Endpoint`]'s
+    /// [`compio_quic::ServerConfig`] crypto material and
+    /// [`default_transport_config`] hookup.
+    #[must_use]
+    pub const fn new(endpoint: Endpoint) -> Self {
+        Self { endpoint }
+    }
+
+    /// Borrow the underlying endpoint for `local_addr`, `set_server_config`,
+    /// `close`, or `shutdown` calls.
+    #[must_use]
+    pub const fn endpoint(&self) -> &Endpoint {
+        &self.endpoint
+    }
+}
+
+impl TransportListener for QuicTransportListener {
+    type Conn = QuicTransportConn;
+
+    #[allow(clippy::future_not_send)]
+    async fn accept(&self) -> io::Result<(Self::Conn, SocketAddr)> {
+        loop {
+            let Some(incoming) = self.endpoint.wait_incoming().await else {
+                return Err(io::Error::new(
+                    io::ErrorKind::ConnectionAborted,
+                    "QUIC endpoint closed",
+                ));
+            };
+            match accept_one(incoming).await {
+                Ok(Some((conn, addr))) => return Ok((conn, addr)),
+                Ok(None) => {}
+                Err(e) => return Err(e),
+            }
+        }
+    }
+}
+
+#[allow(clippy::future_not_send)]
+async fn accept_one(incoming: Incoming) -> io::Result<Option<(QuicTransportConn, SocketAddr)>> {
+    let connecting = match incoming.accept() {
+        Ok(c) => c,
+        Err(e) => {
+            warn!("QUIC incoming.accept failed: {e}");
+            return Ok(None);
+        }
+    };
+    let connection = match connecting.await {
+        Ok(c) => c,
+        Err(e) => {
+            warn!("QUIC handshake failed: {e}");
+            return Ok(None);
+        }
+    };
+    let addr = connection.remote_address();
+
+    let (send, recv) = match connection.accept_bi().await {
+        Ok(streams) => streams,
+        Err(e) => {
+            warn!(%addr, "QUIC accept_bi failed: {e}");
+            connection.close(VarInt::from_u32(QUIC_HANDSHAKE_FAILED), b"accept_bi failed");
+            return Ok(None);
+        }
+    };
+
+    if recv.is_0rtt() {
+        warn!(
+            %addr,
+            "QUIC stream accepted in 0-RTT window; refusing"
+        );
+        connection.close(
+            VarInt::from_u32(QUIC_PROTOCOL_VIOLATION),
+            b"0-RTT not permitted",
+        );
+        return Ok(None);
+    }
+
+    debug!(%addr, "QUIC connection accepted, first bidi stream ready");
+    Ok(Some((
+        QuicTransportConn::new(connection, (send, recv)),
+        addr,
+    )))
+}
+
+/// A single QUIC connection plus its first bidirectional stream.
+///
+/// The owned `Connection` handle is retained so graceful shutdown can
+/// fire `Connection::close(QUIC_SHUTDOWN, _)` after the writer drains.
+pub struct QuicTransportConn {
+    connection: Connection,
+    streams: (SendStream, RecvStream),
+}
+
+impl QuicTransportConn {
+    /// Construct from an already-established connection + bidi pair.
+    #[must_use]
+    pub const fn new(connection: Connection, streams: (SendStream, RecvStream)) -> Self {
+        Self {
+            connection,
+            streams,
+        }
+    }
+
+    /// Deconstruct into the raw `(Connection, (SendStream, RecvStream))`
+    /// tuple, mirror of [`Self::new`].
+    ///
+    /// `client_listener::quic::run` uses this to hand the
+    /// already-accepted connection + first bidi pair to
+    /// [`crate::installer::install_client_quic_conn`] (which then wraps
+    /// them in a fresh `QuicTransportConn` and dispatches via the
+    /// generic install path).
+    #[must_use]
+    pub fn into_parts(self) -> (Connection, (SendStream, RecvStream)) {
+        (self.connection, self.streams)
+    }
+}
+
+impl TransportConn for QuicTransportConn {
+    #[allow(clippy::future_not_send)]
+    async fn run(self, ctx: ActorContext) {
+        let (send, recv) = self.streams;
+        let connection = self.connection;
+        let ActorContext {
+            in_tx,
+            rx,
+            shutdown,
+            max_batch: _,
+            max_message_size,
+            label,
+            peer,
+        } = ctx;
+        let reader_shutdown = shutdown.clone();
+        let writer_shutdown = shutdown;
+        let reader_peer = peer.clone();
+        let reader_label = label;
+        let reader_handle = compio::runtime::spawn(reader_task(
+            recv,
+            in_tx,
+            reader_shutdown,
+            max_message_size,
+            reader_label,
+            reader_peer,
+        ));
+        let writer_handle =
+            compio::runtime::spawn(writer_task(send, rx, writer_shutdown, label, peer));
+        let _ = reader_handle.await;
+        let _ = writer_handle.await;
+        connection.close(VarInt::from_u32(QUIC_SHUTDOWN), b"shutdown");
+    }
+}
+
+#[allow(clippy::future_not_send)]
+async fn reader_task(
+    mut recv: RecvStream,
+    in_tx: async_channel::Sender<Message<GenericHeader>>,
+    shutdown: ShutdownToken,
+    max_message_size: usize,
+    label: &'static str,
+    peer: String,
+) {
+    let mut shutdown_fut = Box::pin(shutdown.wait().fuse());
+    loop {
+        let read_fut = framing::read_message(&mut recv, max_message_size);
+        let result = futures::select! {
+            () = shutdown_fut.as_mut() => {
+                debug!(%label, %peer, "quic reader: shutdown observed");
+                return;
+            }
+            res = read_fut.fuse() => res,
+        };
+        match result {
+            Ok(msg) => {
+                if in_tx.send(msg).await.is_err() {
+                    debug!(%label, %peer, "quic reader: inbound queue dropped");
+                    return;
+                }
+            }
+            Err(e) => {
+                debug!(%label, %peer, "quic reader: read error: {e:?}");
+                return;
+            }
+        }
+    }
+}
+
+#[allow(clippy::future_not_send)]
+async fn writer_task(
+    mut send: SendStream,
+    rx: BusReceiver,
+    shutdown: ShutdownToken,
+    label: &'static str,
+    peer: String,
+) {
+    let mut shutdown_fut = Box::pin(shutdown.wait().fuse());
+    loop {
+        let frozen = futures::select! {
+            () = shutdown_fut.as_mut() => {
+                debug!(%label, %peer, "quic writer: shutdown observed");
+                return;
+            }
+            msg = rx.recv().fuse() => {
+                let Ok(m) = msg else {
+                    debug!(%label, %peer, "quic writer: channel closed");
+                    return;
+                };
+                m
+            }
+        };
+
+        let BufResult(result, _frozen) = send.write(frozen).await;
+        if let Err(e) = result {
+            debug!(%label, %peer, "quic writer: write failed: {e}");
+            return;
+        }
+        if let Err(e) = send.flush().await {
+            debug!(%label, %peer, "quic writer: flush failed: {e}");
+            return;
+        }
+    }
+}
+
+/// Build a [`compio_quic::ServerConfig`] from a single cert + key pair.
+///
+/// Applies the bus-tuned [`default_transport_config`] and disables
+/// 0-RTT; otherwise inherits upstream defaults including
+/// `migration: true`. No ALPN is advertised; protocol-version
+/// validation lives in the LOGIN command on the caller (server-ng).
+///
+/// # Errors
+///
+/// Returns the underlying `rustls::Error` on cert/key import failure.
+pub fn server_config_with_cert(
+    cert_chain: Vec<rustls::pki_types::CertificateDer<'static>>,
+    key_der: rustls::pki_types::PrivateKeyDer<'static>,
+) -> Result<compio_quic::ServerConfig, rustls::Error> {
+    let mut rustls_cfg = rustls::ServerConfig::builder()
+        .with_no_client_auth()
+        .with_single_cert(cert_chain, key_der)?;
+    rustls_cfg.max_early_data_size = 0; // 0-RTT off by default
+    let crypto = QuicServerConfig::try_from(rustls_cfg)
+        .map_err(|e| rustls::Error::General(format!("QUIC crypto: {e}")))?;
+    let mut server_cfg = compio_quic::ServerConfig::with_crypto(Arc::new(crypto));
+    server_cfg.transport_config(Arc::new(default_transport_config()));
+    Ok(server_cfg)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::lifecycle::Shutdown;
+    use async_channel::bounded;
+    use compio_quic::ClientBuilder;
+    use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
+    use iggy_binary_protocol::consensus::iobuf::Frozen;
+    use iggy_binary_protocol::{Command2, HEADER_SIZE};
+    use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
+    use std::time::Duration;
+
+    fn install_crypto_provider() {
+        // Idempotent. Tests in the same process race on
+        // `install_default`, so swallow the second-installer error.
+        let _ = rustls::crypto::ring::default_provider().install_default();
+    }
+
+    fn header_only(command: Command2) -> Frozen<MESSAGE_ALIGN> {
+        #[allow(clippy::cast_possible_truncation)]
+        Message::<GenericHeader>::new(HEADER_SIZE)
+            .transmute_header(|_, h: &mut GenericHeader| {
+                h.command = command;
+                h.size = HEADER_SIZE as u32;
+            })
+            .into_frozen()
+    }
+
+    fn self_signed() -> (CertificateDer<'static>, PrivateKeyDer<'static>) {
+        let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).expect("rcgen");
+        let cert_der = CertificateDer::from(cert.cert);
+        let key_der: PrivateKeyDer<'static> =
+            PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()).into();
+        (cert_der, key_der)
+    }
+
+    #[allow(clippy::future_not_send)]
+    async fn server_endpoint(
+        cert: CertificateDer<'static>,
+        key: PrivateKeyDer<'static>,
+    ) -> Endpoint {
+        let server_cfg = server_config_with_cert(vec![cert], key).expect("server config");
+        Endpoint::server("127.0.0.1:0", server_cfg)
+            .await
+            .expect("bind")
+    }
+
+    #[allow(clippy::future_not_send)]
+    async fn client_endpoint(server_cert: CertificateDer<'static>) -> Endpoint {
+        let builder = ClientBuilder::new_with_empty_roots()
+            .with_custom_certificate(server_cert)
+            .expect("trust cert")
+            .with_no_crls();
+        builder.bind("127.0.0.1:0").await.expect("client bind")
+    }
+
+    /// Spawn `conn.run(ctx)` with fresh channels; return the test-side
+    /// handles.
+    #[allow(clippy::future_not_send)]
+    fn drive(
+        conn: QuicTransportConn,
+    ) -> (
+        async_channel::Sender<Frozen<MESSAGE_ALIGN>>,
+        async_channel::Receiver<Message<GenericHeader>>,
+        Shutdown,
+        compio::runtime::JoinHandle<()>,
+    ) {
+        let (out_tx, out_rx) = bounded::<Frozen<MESSAGE_ALIGN>>(16);
+        let (in_tx, in_rx) = bounded::<Message<GenericHeader>>(16);
+        let (shutdown, token) = Shutdown::new();
+        let ctx = ActorContext {
+            in_tx,
+            rx: out_rx,
+            shutdown: token,
+            max_batch: 16,
+            max_message_size: framing::MAX_MESSAGE_SIZE,
+            label: "test",
+            peer: "test".to_owned(),
+        };
+        let handle = compio::runtime::spawn(async move { conn.run(ctx).await });
+        (out_tx, in_rx, shutdown, handle)
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn loopback_round_trip_three_frames() {
+        install_crypto_provider();
+        let (cert, key) = self_signed();
+        let server = server_endpoint(cert.clone(), key).await;
+        let server_addr = server.local_addr().unwrap();
+
+        let listener = QuicTransportListener::new(server);
+        let server_task = compio::runtime::spawn(async move {
+            let (conn, _peer) = listener.accept().await.expect("accept");
+            let (_out_tx, in_rx, shutdown, handle) = drive(conn);
+            let a = in_rx.recv().await.unwrap();
+            let b = in_rx.recv().await.unwrap();
+            let c = in_rx.recv().await.unwrap();
+            shutdown.trigger();
+            let _ = handle.await;
+            (a.header().command, b.header().command, c.header().command)
+        });
+
+        let client = client_endpoint(cert).await;
+        let connecting = client
+            .connect(server_addr, "localhost", None)
+            .expect("connect");
+        let connection = connecting.await.expect("client handshake");
+        let (send, recv) = connection.open_bi_wait().await.expect("open_bi");
+        let conn = QuicTransportConn::new(connection, (send, recv));
+        let (out_tx, _in_rx, shutdown, handle) = drive(conn);
+
+        out_tx.send(header_only(Command2::Ping)).await.unwrap();
+        out_tx.send(header_only(Command2::Prepare)).await.unwrap();
+        out_tx.send(header_only(Command2::Request)).await.unwrap();
+
+        let (a, b, c) = compio::time::timeout(Duration::from_secs(5), server_task)
+            .await
+            .expect("server task within 5s")
+            .unwrap();
+        assert_eq!(a, Command2::Ping);
+        assert_eq!(b, Command2::Prepare);
+        assert_eq!(c, Command2::Request);
+
+        shutdown.trigger();
+        let _ = handle.await;
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn read_message_reports_oversize_via_run() {
+        install_crypto_provider();
+        let (cert, key) = self_signed();
+        let server = server_endpoint(cert.clone(), key).await;
+        let server_addr = server.local_addr().unwrap();
+
+        let listener = QuicTransportListener::new(server);
+        let server_task = compio::runtime::spawn(async move {
+            let (conn, _peer) = listener.accept().await.expect("accept");
+            let (_out_tx, in_rx, shutdown, handle) = drive(conn);
+            let res = in_rx.recv().await;
+            shutdown.trigger();
+            let _ = handle.await;
+            res
+        });
+
+        let client = client_endpoint(cert).await;
+        let connecting = client
+            .connect(server_addr, "localhost", None)
+            .expect("connect");
+        let connection = connecting.await.expect("handshake");
+        let (mut send, recv) = connection.open_bi_wait().await.expect("open_bi");
+        // Bogus oversize size field at offset 48.
+        let mut buf = vec![0u8; HEADER_SIZE];
+        let bogus = u32::try_from(framing::MAX_MESSAGE_SIZE + 1)
+            .unwrap_or(u32::MAX)
+            .to_le_bytes();
+        buf[48..52].copy_from_slice(&bogus);
+        let BufResult(result, _) = send.write(buf).await;
+        result.expect("write");
+        send.flush().await.expect("flush");
+        drop(send); // signal end-of-stream so reader sees the framed bytes
+        drop(recv);
+
+        let res = compio::time::timeout(Duration::from_secs(5), server_task)
+            .await
+            .expect("server task within 5s")
+            .unwrap();
+        // Framing error inside reader_task closes in_tx; recv resolves to Err.
+        assert!(res.is_err());
+    }
+
+    #[test]
+    fn default_transport_config_caps_streams_at_one() {
+        // Lightweight smoke: confirm the helper builds without panicking
+        // and the underlying defaults are reachable.
+        let _cfg = default_transport_config();
+    }
+}
diff --git a/core/message_bus/src/transports/tcp.rs b/core/message_bus/src/transports/tcp.rs
new file mode 100644
index 000000000..b97ddbfab
--- /dev/null
+++ b/core/message_bus/src/transports/tcp.rs
@@ -0,0 +1,417 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! TCP impl of the [`super::TransportConn`] / [`super::TransportListener`]
+//! traits.
+//!
+//! Behavior-preserving wrappers around `compio::net::TcpListener` and
+//! `compio::net::TcpStream`. `TcpTransportConn::run` splits the stream
+//! into owned read / write halves, spawns a reader task that calls
+//! [`framing::read_message`] and forwards each decoded frame to
+//! `ActorContext::in_tx`, and a writer task that drains
+//! `ActorContext::rx`, batches up to `max_batch` frames per submission,
+//! and emits them via `compio::io::AsyncWriteExt::write_vectored_all`
+//! (one writev in the common case, looping on short writes; zero
+//! intermediate copies of `Frozen`).
+
+use super::{ActorContext, TransportConn, TransportListener};
+use crate::framing;
+use crate::lifecycle::BusMessage;
+use compio::driver::{SharedFd, ToSharedFd};
+use compio::io::AsyncWriteExt;
+use compio::net::{OwnedReadHalf, OwnedWriteHalf, TcpListener, TcpStream};
+use futures::FutureExt;
+use std::io;
+use std::mem;
+use std::net::SocketAddr;
+use std::os::fd::AsRawFd;
+use tracing::{debug, error, trace};
+
+/// Inbound TCP listener wrapper.
+///
+/// Constructed from an already-bound [`TcpListener`] so the caller
+/// keeps control over socket options (`SO_REUSEPORT`, `nodelay`,
+/// `keepalive`) via `compio::net::SocketOpts`. See
+/// [`crate::replica::listener::bind`] and
+/// [`crate::client_listener::tcp::bind`] for the canonical construction.
+///
+/// Currently only used by trait-conformance tests; production `accept`
+/// loops in [`crate::replica::listener`] / [`crate::client_listener`]
+/// drive a raw `TcpListener` directly. Kept `pub(crate)` so the
+/// `TransportListener` impl below is actually exercisable; the
+/// `dead_code` allow is intentional and will fall away when the listener
+/// loops migrate behind the trait.
+#[allow(dead_code)]
+pub(crate) struct TcpTransportListener {
+    inner: TcpListener,
+}
+
+impl TcpTransportListener {
+    #[must_use]
+    #[allow(dead_code)]
+    pub(crate) const fn new(inner: TcpListener) -> Self {
+        Self { inner }
+    }
+}
+
+impl TransportListener for TcpTransportListener {
+    type Conn = TcpTransportConn;
+
+    #[allow(clippy::future_not_send)]
+    async fn accept(&self) -> io::Result<(Self::Conn, SocketAddr)> {
+        let (stream, addr) = self.inner.accept().await?;
+        Ok((TcpTransportConn::new(stream), addr))
+    }
+}
+
+/// Single TCP connection.
+///
+/// Produced by [`TcpTransportListener::accept`] or by wrapping the
+/// result of a `TcpStream::connect` on the dialer path. Takes ownership
+/// of the stream; [`TransportConn::run`] consumes it and drives both
+/// reader and writer tasks internally.
+pub(crate) struct TcpTransportConn {
+    stream: TcpStream,
+}
+
+impl TcpTransportConn {
+    #[must_use]
+    pub(crate) const fn new(stream: TcpStream) -> Self {
+        Self { stream }
+    }
+}
+
+impl TransportConn for TcpTransportConn {
+    #[allow(clippy::future_not_send)]
+    async fn run(self, ctx: ActorContext) {
+        // Capture a refcounted fd handle BEFORE `into_split` so the
+        // shutdown watchdog can wake the reader's parked io_uring read
+        // SQE via `libc::shutdown(SHUT_RD)`. No `select!` over a TCP
+        // read on a still-alive connection: the in-flight read returns
+        // `Ok(0)` naturally rather than being cancelled (compio's
+        // io_uring read is not cancel-safe in the protocol sense).
+        let shared_fd = self.stream.to_shared_fd();
+        let (read_half, write_half) = self.stream.into_split();
+        let ActorContext {
+            in_tx,
+            rx,
+            shutdown,
+            max_batch,
+            max_message_size,
+            label,
+            peer,
+        } = ctx;
+
+        spawn_shutdown_watchdog(shared_fd, shutdown.clone(), label, peer.clone());
+
+        let writer_shutdown = shutdown;
+        let reader_peer = peer.clone();
+        let reader_handle = compio::runtime::spawn(reader_loop(
+            read_half,
+            in_tx,
+            max_message_size,
+            label,
+            reader_peer,
+        ));
+        let writer_handle = compio::runtime::spawn(writer_loop(
+            write_half,
+            rx,
+            writer_shutdown,
+            max_batch,
+            label,
+            peer,
+        ));
+        let _ = reader_handle.await;
+        let _ = writer_handle.await;
+    }
+}
+
+/// Spawn a detached watchdog that calls `libc::shutdown(fd, SHUT_RD)`
+/// when the shutdown token fires. The reader's pending `io_uring`
+/// read SQE then completes with `Ok(0)`, the reader observes EOF, and the
+/// reader loop exits without ever sitting inside a `select!` over a
+/// TCP read.
+///
+/// On a natural-EOF run (peer closed first) the watchdog stays parked
+/// until the bus-wide shutdown token ultimately fires; the resulting
+/// `libc::shutdown` on a fd whose socket is already closed is benign
+/// (returns ENOTCONN). All TCP-family transports converge on this
+/// shutdown model: the reader never sits inside a `select!` against the
+/// shutdown token; the watchdog wakes the parked `io_uring` read instead.
+#[allow(clippy::future_not_send)]
+fn spawn_shutdown_watchdog(
+    shared_fd: SharedFd<socket2::Socket>,
+    shutdown: crate::lifecycle::ShutdownToken,
+    label: &'static str,
+    peer: String,
+) {
+    compio::runtime::spawn(async move {
+        shutdown.wait().await;
+        // SAFETY: `shared_fd` is a refcounted clone obtained from the
+        // still-live `TcpStream` before `into_split`; the matching
+        // clones in both owned halves keep the kernel fd open. The
+        // watchdog's clone independently keeps it open across this
+        // syscall.
+        let raw_fd = shared_fd.as_raw_fd();
+        let rc = unsafe { libc::shutdown(raw_fd, libc::SHUT_RD) };
+        if rc != 0 {
+            let err = io::Error::last_os_error();
+            // ENOTCONN is expected when the peer closed first.
+            if err.raw_os_error() != Some(libc::ENOTCONN) {
+                debug!(%label, %peer, error = ?err, "tcp watchdog: SHUT_RD returned");
+            }
+        }
+    })
+    .detach();
+}
+
+/// Read framed consensus messages off the wire and forward each to
+/// [`ActorContext::in_tx`]. Exits on EOF, framing error, or send-side
+/// closure.
+///
+/// No `select!` over the TCP read. Cooperative shutdown is delivered
+/// via [`spawn_shutdown_watchdog`], which calls `libc::shutdown(SHUT_RD)`
+/// when the bus token fires; the in-flight read returns `Ok(0)` and
+/// `framing::read_message` surfaces it as an EOF error on the next
+/// iteration.
+#[allow(clippy::future_not_send)]
+async fn reader_loop(
+    mut read_half: OwnedReadHalf<TcpStream>,
+    in_tx: async_channel::Sender<
+        iggy_binary_protocol::Message<iggy_binary_protocol::GenericHeader>,
+    >,
+    max_message_size: usize,
+    label: &'static str,
+    peer: String,
+) {
+    loop {
+        match framing::read_message(&mut read_half, max_message_size).await {
+            Ok(msg) => {
+                if in_tx.send(msg).await.is_err() {
+                    debug!(%label, %peer, "tcp reader: inbound queue dropped");
+                    return;
+                }
+            }
+            Err(e) => {
+                debug!(%label, %peer, "tcp reader: read error: {e:?}");
+                return;
+            }
+        }
+    }
+}
+
+/// Drain [`ActorContext::rx`], coalesce up to `max_batch` frames into a
+/// single `writev_all` syscall, exit on shutdown, channel close, or
+/// write error.
+#[allow(clippy::future_not_send)]
+async fn writer_loop(
+    mut write_half: OwnedWriteHalf<TcpStream>,
+    rx: crate::lifecycle::BusReceiver,
+    shutdown: crate::lifecycle::ShutdownToken,
+    max_batch: usize,
+    label: &'static str,
+    peer: String,
+) {
+    let mut batch: Vec<BusMessage> = Vec::with_capacity(max_batch);
+    let mut shutdown_fut = Box::pin(shutdown.wait().fuse());
+
+    loop {
+        let first = futures::select! {
+            () = shutdown_fut.as_mut() => {
+                debug!(%label, %peer, "tcp writer: shutdown observed");
+                return;
+            }
+            msg = rx.recv().fuse() => {
+                if let Ok(m) = msg {
+                    m
+                } else {
+                    debug!(%label, %peer, "tcp writer: channel closed");
+                    return;
+                }
+            }
+        };
+
+        batch.push(first);
+        while batch.len() < max_batch {
+            match rx.try_recv() {
+                Ok(m) => batch.push(m),
+                Err(_) => break,
+            }
+        }
+
+        let drained = batch.len();
+        trace!(%label, %peer, batch = drained, "writev batch");
+
+        let owned = mem::take(&mut batch);
+        let compio::BufResult(result, mut returned) = write_half.write_vectored_all(owned).await;
+        returned.clear();
+        batch = returned;
+
+        if let Err(e) = result {
+            error!(
+                %label,
+                %peer,
+                error = ?e,
+                batch_len = drained,
+                "tcp writer: writev failed, dropping batch"
+            );
+            return;
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::lifecycle::Shutdown;
+    use async_channel::bounded;
+    use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
+    use iggy_binary_protocol::consensus::iobuf::Frozen;
+    use iggy_binary_protocol::{Command2, GenericHeader, HEADER_SIZE, Message};
+    use std::time::Duration;
+
+    #[allow(clippy::cast_possible_truncation)]
+    fn header_only(command: Command2) -> Frozen<MESSAGE_ALIGN> {
+        Message::<GenericHeader>::new(HEADER_SIZE)
+            .transmute_header(|_, h: &mut GenericHeader| {
+                h.command = command;
+                h.size = HEADER_SIZE as u32;
+            })
+            .into_frozen()
+    }
+
+    #[allow(clippy::future_not_send)]
+    async fn local_pair() -> (TcpStream, TcpStream) {
+        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let addr = listener.local_addr().unwrap();
+        let connect = TcpStream::connect(addr);
+        let accept = listener.accept();
+        let (client_res, accept_res) = futures::join!(connect, accept);
+        let (server, _) = accept_res.unwrap();
+        (client_res.unwrap(), server)
+    }
+
+    #[allow(clippy::future_not_send)]
+    fn drive(
+        conn: TcpTransportConn,
+    ) -> (
+        async_channel::Sender<Frozen<MESSAGE_ALIGN>>,
+        async_channel::Receiver<Message<GenericHeader>>,
+        Shutdown,
+        compio::runtime::JoinHandle<()>,
+    ) {
+        let (out_tx, out_rx) = bounded::<Frozen<MESSAGE_ALIGN>>(16);
+        let (in_tx, in_rx) = bounded::<Message<GenericHeader>>(16);
+        let (shutdown, token) = Shutdown::new();
+        let ctx = ActorContext {
+            in_tx,
+            rx: out_rx,
+            shutdown: token,
+            max_batch: 16,
+            max_message_size: framing::MAX_MESSAGE_SIZE,
+            label: "test",
+            peer: "test".to_owned(),
+        };
+        let handle = compio::runtime::spawn(async move { conn.run(ctx).await });
+        (out_tx, in_rx, shutdown, handle)
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn listener_accept_yields_conn() {
+        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let addr = listener.local_addr().unwrap();
+        let wrapped = TcpTransportListener::new(listener);
+
+        let connect = TcpStream::connect(addr);
+        let accept = wrapped.accept();
+        let (_client, accept_res) = futures::join!(connect, accept);
+        let (_conn, _peer_addr) = accept_res.expect("accept via trait");
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn run_pumps_three_frames_through_writev() {
+        let (client, server) = local_pair().await;
+        let (client_out, _client_in, client_shutdown, client_handle) =
+            drive(TcpTransportConn::new(client));
+        let (_server_out, server_in, server_shutdown, server_handle) =
+            drive(TcpTransportConn::new(server));
+
+        for cmd in [Command2::Ping, Command2::Prepare, Command2::Request] {
+            client_out.send(header_only(cmd)).await.unwrap();
+        }
+
+        let recv_with_timeout = |rx: &async_channel::Receiver<Message<GenericHeader>>| {
+            let rx = rx.clone();
+            async move {
+                compio::time::timeout(Duration::from_secs(2), rx.recv())
+                    .await
+                    .expect("recv within 2s")
+                    .expect("ok")
+            }
+        };
+        let a = recv_with_timeout(&server_in).await;
+        let b = recv_with_timeout(&server_in).await;
+        let c = recv_with_timeout(&server_in).await;
+        assert_eq!(a.header().command, Command2::Ping);
+        assert_eq!(b.header().command, Command2::Prepare);
+        assert_eq!(c.header().command, Command2::Request);
+
+        client_shutdown.trigger();
+        server_shutdown.trigger();
+        let _ = client_handle.await;
+        let _ = server_handle.await;
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn run_exits_on_shutdown_signal() {
+        let (client, _server) = local_pair().await;
+        let (_out_tx, _in_rx, shutdown, handle) = drive(TcpTransportConn::new(client));
+        shutdown.trigger();
+        let res = compio::time::timeout(Duration::from_secs(2), handle).await;
+        assert!(res.is_ok(), "run must exit within 2s of shutdown");
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn run_reports_oversize_frame_and_exits() {
+        let (mut client, server) = local_pair().await;
+        let (_out, server_in, server_shutdown, server_handle) =
+            drive(TcpTransportConn::new(server));
+
+        let mut buf = vec![0u8; HEADER_SIZE];
+        let bogus = u32::try_from(framing::MAX_MESSAGE_SIZE + 1)
+            .unwrap_or(u32::MAX)
+            .to_le_bytes();
+        buf[48..52].copy_from_slice(&bogus);
+        client.write_all(buf).await.0.unwrap();
+
+        let recv = compio::time::timeout(Duration::from_secs(2), server_in.recv()).await;
+        assert!(
+            recv.is_ok(),
+            "in_rx should close within 2s on framing error"
+        );
+        assert!(
+            recv.unwrap().is_err(),
+            "framing error tears the reader down and closes in_tx"
+        );
+        server_shutdown.trigger();
+        let _ = server_handle.await;
+    }
+}
diff --git a/core/message_bus/src/transports/tcp_tls.rs b/core/message_bus/src/transports/tcp_tls.rs
new file mode 100644
index 000000000..61b0ec47a
--- /dev/null
+++ b/core/message_bus/src/transports/tcp_tls.rs
@@ -0,0 +1,519 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! TCP-TLS transport: `compio_tls::TlsStream<TcpStream>` driven by a
+//! single connection task per peer.
+//!
+//! # Design
+//!
+//! `compio-tls` wraps `futures-rustls` over compio's `AsyncStream<S>`
+//! adapter. That adapter buffers the in-flight kernel read
+//! (`AsyncReadStream::read_future`) and decrypted-but-undelivered
+//! plaintext (`SyncStreamReadHalf`) inside the stream itself, so any
+//! bytes still resident in those buffers at the moment a single
+//! `TlsStream::read` future is dropped survive into the next `read()`.
+//!
+//! Single-task model: one task owns the `TlsStream` and `select!`s over
+//! the bus shutdown token, the outbound mailbox, and the inbound
+//! `framing::read_message`. The select is between-frame: the mailbox
+//! arm typically wins only when [`framing::read_message`] is parked
+//! waiting for the next frame's first byte; in that window dropping
+//! the read future loses no application data because the per-call
+//! `Owned<MESSAGE_ALIGN>` is empty. If the mailbox arm wins mid-frame
+//! (some bytes already copied into the per-call `Owned`), those bytes
+//! are dropped with the future and the connection is torn down by the
+//! next iteration's `read_exact` observing a malformed remainder. The
+//! window for that race is bounded by [`framing::read_message`]'s own
+//! latency. Writes are run to completion outside any `select!`; the
+//! shutdown token signals the *start* of close, never cancels a write
+//! that is already in flight.
+//!
+//! On bus shutdown the task exits the loop and drives the shutdown
+//! sequence: `flush()` + `compio::time::timeout(close_grace,
+//! stream.shutdown())`. `TlsStream::shutdown` sends `close_notify` and
+//! the underlying TCP `SHUT_WR`; the peer reciprocates and our reader
+//! observes EOF on the next `read` (which never fires because we exited
+//! the loop already).
+//!
+//! # Frozen ownership
+//!
+//! Plaintext TCP preserves `Frozen<MESSAGE_ALIGN>` ownership end-to-end.
+//! The TLS plane structurally cannot: rustls's encrypt step copies
+//! plaintext into outbound ciphertext along with per-record AEAD tags.
+//! `Frozen` is consumed by `write_all`; the wire bytes are a distinct
+//! allocation owned by rustls.
+
+use super::tls::TlsRole;
+use super::{ActorContext, TransportConn};
+use crate::framing;
+use crate::lifecycle::BusMessage;
+use compio::buf::IoBuf;
+use compio::io::{AsyncWrite, AsyncWriteExt};
+use compio::net::TcpStream;
+use compio::tls::{TlsAcceptor, TlsConnector, TlsStream};
+use futures::FutureExt;
+use rustls::pki_types::ServerName;
+use std::sync::Arc;
+use std::time::Duration;
+use tracing::{debug, warn};
+
+/// Default wall-clock bound on `TlsStream::shutdown` invocation.
+const DEFAULT_CLOSE_GRACE: Duration = Duration::from_secs(2);
+
+/// In-process TCP-TLS transport: a [`TcpStream`] paired with a
+/// role-specific rustls configuration.
+///
+/// Construct with [`Self::new_server`] or [`Self::new_client`]; drive
+/// with [`TransportConn::run`]. Override the close grace via
+/// [`Self::with_close_grace`].
+pub struct TcpTlsTransportConn {
+    stream: TcpStream,
+    role: TlsRole,
+    close_grace: Duration,
+}
+
+impl TcpTlsTransportConn {
+    /// Server-side connection: drive the rustls server state machine
+    /// against `config` and the bytes flowing on `stream`.
+    #[must_use]
+    pub const fn new_server(stream: TcpStream, config: Arc<rustls::ServerConfig>) -> Self {
+        Self {
+            stream,
+            role: TlsRole::Server(config),
+            close_grace: DEFAULT_CLOSE_GRACE,
+        }
+    }
+
+    /// Client-side connection: drive the rustls client state machine
+    /// against `config` and the bytes flowing on `stream`, presenting
+    /// `server_name` in SNI.
+    #[must_use]
+    pub const fn new_client(
+        stream: TcpStream,
+        config: Arc<rustls::ClientConfig>,
+        server_name: ServerName<'static>,
+    ) -> Self {
+        Self {
+            stream,
+            role: TlsRole::Client {
+                config,
+                server_name,
+            },
+            close_grace: DEFAULT_CLOSE_GRACE,
+        }
+    }
+
+    /// Override the wall-clock bound on `TlsStream::shutdown` (the
+    /// `close_notify` + TCP `SHUT_WR` step). Intended for tests; the
+    /// installer plumbs `MessageBusConfig::close_grace` in production.
+    #[must_use]
+    pub const fn with_close_grace(mut self, close_grace: Duration) -> Self {
+        self.close_grace = close_grace;
+        self
+    }
+}
+
+impl TransportConn for TcpTlsTransportConn {
+    #[allow(clippy::future_not_send)]
+    async fn run(self, ctx: ActorContext) {
+        let label = ctx.label;
+        let peer = ctx.peer.clone();
+
+        let mut tls = match handshake(self.role, self.stream).await {
+            Ok(s) => s,
+            Err(e) => {
+                warn!(
+                    %label,
+                    %peer,
+                    error = ?e,
+                    "TLS handshake failed; closing connection"
+                );
+                return;
+            }
+        };
+
+        run_pump(&mut tls, ctx).await;
+        drive_close(&mut tls, self.close_grace, label, &peer).await;
+    }
+}
+
+/// Perform the role-specific rustls handshake on `stream` and return
+/// the encrypted [`TlsStream`].
+#[allow(clippy::future_not_send)]
+async fn handshake(role: TlsRole, stream: TcpStream) -> std::io::Result<TlsStream<TcpStream>> {
+    match role {
+        TlsRole::Server(cfg) => TlsAcceptor::from(cfg).accept(stream).await,
+        TlsRole::Client {
+            config,
+            server_name,
+        } => {
+            let domain = server_name.to_str();
+            TlsConnector::from(config).connect(&domain, stream).await
+        }
+    }
+}
+
+/// Per-iteration outcome of the single-task select.
+enum PumpAction {
+    /// Bus shutdown observed; exit the loop.
+    Shutdown,
+    /// Outbound message ready; write it to the wire.
+    Send(BusMessage),
+    /// Inbound frame decoded; forward to the dispatcher.
+    Recv(Result<iggy_binary_protocol::Message<iggy_binary_protocol::GenericHeader>, ()>),
+    /// Mailbox closed: nothing more to send.
+    MailboxClosed,
+}
+
+/// Drive the TLS connection until shutdown, peer EOF, or unrecoverable
+/// I/O error.
+///
+/// The select arms are: bus shutdown (cancel-safe channel), outbound
+/// mailbox (cancel-safe channel), and inbound `framing::read_message`.
+/// When the outbound arm wins, the in-flight `read_message` future is
+/// dropped together with its per-call `Owned<MESSAGE_ALIGN>` accumulator;
+/// any bytes still buffered inside `compio-tls` (`AsyncReadStream::read_future`,
+/// `SyncStreamReadHalf`) survive into the next iteration. Bytes already
+/// copied into the dropped `Owned` are lost, which surfaces as a framing
+/// error on the next iteration and tears the connection down. The
+/// mailbox arm is expected to win between frames in steady state.
+///
+/// Writes are run to completion outside any `select!`. Token signals
+/// the *start* of close, never cancels an in-flight write.
+#[allow(clippy::future_not_send)]
+async fn run_pump(tls: &mut TlsStream<TcpStream>, ctx: ActorContext) {
+    let ActorContext {
+        in_tx,
+        rx,
+        shutdown,
+        max_message_size,
+        label,
+        peer,
+        ..
+    } = ctx;
+
+    let mut shutdown_fut = Box::pin(shutdown.wait().fuse());
+
+    loop {
+        let action = {
+            let read_fut = framing::read_message(tls, max_message_size);
+            let recv_fut = rx.recv();
+            futures::pin_mut!(read_fut);
+            futures::pin_mut!(recv_fut);
+
+            futures::select_biased! {
+                () = shutdown_fut.as_mut() => PumpAction::Shutdown,
+                msg = recv_fut.fuse() => msg.map_or(PumpAction::MailboxClosed, PumpAction::Send),
+                res = read_fut.fuse() => PumpAction::Recv(res.map_err(|e| {
+                    debug!(%label, %peer, error = ?e, "tls reader: read error");
+                })),
+            }
+        };
+
+        match action {
+            PumpAction::Shutdown => {
+                debug!(%label, %peer, "tls pump: shutdown observed");
+                return;
+            }
+            PumpAction::MailboxClosed => {
+                debug!(%label, %peer, "tls pump: mailbox closed");
+                return;
+            }
+            PumpAction::Send(msg) => {
+                let len = msg.buf_len();
+                let compio::BufResult(result, _) = tls.write_all(msg).await;
+                if let Err(e) = result {
+                    warn!(
+                        %label,
+                        %peer,
+                        error = ?e,
+                        bytes = len,
+                        "tls writer: write_all failed"
+                    );
+                    return;
+                }
+                if let Err(e) = tls.flush().await {
+                    warn!(
+                        %label,
+                        %peer,
+                        error = ?e,
+                        bytes = len,
+                        "tls writer: flush failed"
+                    );
+                    return;
+                }
+            }
+            PumpAction::Recv(Ok(frame)) => {
+                if in_tx.send(frame).await.is_err() {
+                    debug!(%label, %peer, "tls reader: inbound queue dropped");
+                    return;
+                }
+            }
+            PumpAction::Recv(Err(())) => return,
+        }
+    }
+}
+
+/// Bounded-grace cooperative close: flush any rustls-buffered ciphertext,
+/// then issue a single `TlsStream::shutdown` capped by `close_grace`.
+///
+/// Shutdown is a transaction - no `select!` racing against the token.
+/// On timeout we drop the stream regardless; the OS sends RST and the peer
+/// sees an unclean close - accepted degraded close.
+#[allow(clippy::future_not_send)]
+async fn drive_close(
+    tls: &mut TlsStream<TcpStream>,
+    close_grace: Duration,
+    label: &'static str,
+    peer: &str,
+) {
+    if let Err(e) = tls.flush().await {
+        debug!(%label, %peer, error = ?e, "tls close: flush error");
+    }
+    match compio::time::timeout(close_grace, tls.shutdown()).await {
+        Ok(Ok(())) => {}
+        Ok(Err(e)) => debug!(%label, %peer, error = ?e, "tls close: shutdown error"),
+        Err(_) => warn!(
+            %label,
+            %peer,
+            grace_ms = close_grace.as_millis(),
+            "tls close: shutdown timed out"
+        ),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::framing;
+    use crate::lifecycle::Shutdown;
+    use crate::transports::tls::{install_default_crypto_provider, self_signed_for_loopback};
+    use async_channel::{Receiver, Sender, bounded};
+    use compio::net::TcpListener;
+    use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
+    use iggy_binary_protocol::consensus::iobuf::Frozen;
+    use iggy_binary_protocol::{Command2, GenericHeader, HEADER_SIZE, Message};
+    use rustls::RootCertStore;
+    use std::net::SocketAddr;
+    use std::sync::OnceLock;
+
+    fn ensure_provider() {
+        static GUARD: OnceLock<()> = OnceLock::new();
+        GUARD.get_or_init(install_default_crypto_provider);
+    }
+
+    fn server_cfg() -> (
+        Arc<rustls::ServerConfig>,
+        Vec<rustls::pki_types::CertificateDer<'static>>,
+    ) {
+        ensure_provider();
+        let creds = self_signed_for_loopback();
+        let cert_chain = creds.cert_chain.clone();
+        let mut cfg = rustls::ServerConfig::builder()
+            .with_no_client_auth()
+            .with_single_cert(cert_chain.clone(), creds.key_der)
+            .expect("server config");
+        cfg.max_early_data_size = 0;
+        (Arc::new(cfg), cert_chain)
+    }
+
+    fn client_cfg_trusting(
+        server_cert_chain: &[rustls::pki_types::CertificateDer<'static>],
+    ) -> Arc<rustls::ClientConfig> {
+        ensure_provider();
+        let mut roots = RootCertStore::empty();
+        for cert in server_cert_chain {
+            roots.add(cert.clone()).expect("add cert");
+        }
+        Arc::new(
+            rustls::ClientConfig::builder()
+                .with_root_certificates(Arc::new(roots))
+                .with_no_client_auth(),
+        )
+    }
+
+    fn client_cfg_with_empty_roots() -> Arc<rustls::ClientConfig> {
+        ensure_provider();
+        Arc::new(
+            rustls::ClientConfig::builder()
+                .with_root_certificates(Arc::new(RootCertStore::empty()))
+                .with_no_client_auth(),
+        )
+    }
+
+    #[allow(clippy::future_not_send)]
+    async fn connected_pair() -> (TcpStream, TcpStream) {
+        let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
+        let server_addr: SocketAddr = listener.local_addr().expect("local_addr");
+        let connect = TcpStream::connect(server_addr);
+        let accept = listener.accept();
+        let (client_res, accept_res) = futures::join!(connect, accept);
+        let (server, _peer) = accept_res.expect("accept");
+        (client_res.expect("connect"), server)
+    }
+
+    #[allow(clippy::cast_possible_truncation)]
+    fn header_only(command: Command2) -> Frozen<MESSAGE_ALIGN> {
+        Message::<GenericHeader>::new(HEADER_SIZE)
+            .transmute_header(|_, h: &mut GenericHeader| {
+                h.command = command;
+                h.size = HEADER_SIZE as u32;
+            })
+            .into_frozen()
+    }
+
+    #[allow(clippy::cast_possible_truncation)]
+    fn padded(command: Command2, total_size: usize) -> Frozen<MESSAGE_ALIGN> {
+        Message::<GenericHeader>::new(total_size)
+            .transmute_header(|_, h: &mut GenericHeader| {
+                h.command = command;
+                h.size = total_size as u32;
+            })
+            .into_frozen()
+    }
+
+    #[allow(clippy::future_not_send)]
+    fn drive(
+        conn: TcpTlsTransportConn,
+    ) -> (
+        Sender<Frozen<MESSAGE_ALIGN>>,
+        Receiver<Message<GenericHeader>>,
+        Shutdown,
+        compio::runtime::JoinHandle<()>,
+    ) {
+        let (out_tx, out_rx) = bounded::<Frozen<MESSAGE_ALIGN>>(16);
+        let (in_tx, in_rx) = bounded::<Message<GenericHeader>>(16);
+        let (shutdown, token) = Shutdown::new();
+        let ctx = ActorContext {
+            in_tx,
+            rx: out_rx,
+            shutdown: token,
+            max_batch: 16,
+            max_message_size: framing::MAX_MESSAGE_SIZE,
+            label: "test",
+            peer: "test".to_owned(),
+        };
+        let handle = compio::runtime::spawn(async move { conn.run(ctx).await });
+        (out_tx, in_rx, shutdown, handle)
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn loopback_round_trip_with_self_signed_cert() {
+        let (server_config, cert_chain) = server_cfg();
+        let client_config = client_cfg_trusting(&cert_chain);
+        let server_name: ServerName<'static> = "localhost".try_into().expect("server name");
+
+        let (client_stream, server_stream) = connected_pair().await;
+        let server_conn = TcpTlsTransportConn::new_server(server_stream, server_config);
+        let client_conn =
+            TcpTlsTransportConn::new_client(client_stream, client_config, server_name);
+
+        let (server_out, server_in, server_shutdown, server_handle) = drive(server_conn);
+        let (client_out, client_in, client_shutdown, client_handle) = drive(client_conn);
+
+        client_out
+            .send(header_only(Command2::Request))
+            .await
+            .expect("client send");
+        let received = compio::time::timeout(Duration::from_secs(5), server_in.recv())
+            .await
+            .expect("server recv within 5 s")
+            .expect("server frame");
+        assert_eq!(received.header().command, Command2::Request);
+
+        server_out
+            .send(header_only(Command2::Reply))
+            .await
+            .expect("server send");
+        let reply = compio::time::timeout(Duration::from_secs(5), client_in.recv())
+            .await
+            .expect("client recv within 5 s")
+            .expect("client frame");
+        assert_eq!(reply.header().command, Command2::Reply);
+
+        server_shutdown.trigger();
+        client_shutdown.trigger();
+        let _ = compio::time::timeout(Duration::from_secs(5), server_handle).await;
+        let _ = compio::time::timeout(Duration::from_secs(5), client_handle).await;
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn wrong_cert_handshake_fails_cleanly() {
+        let (server_config, _cert_chain) = server_cfg();
+        let client_config = client_cfg_with_empty_roots();
+        let server_name: ServerName<'static> = "localhost".try_into().expect("server name");
+
+        let (client_stream, server_stream) = connected_pair().await;
+        let server_conn = TcpTlsTransportConn::new_server(server_stream, server_config);
+        let client_conn =
+            TcpTlsTransportConn::new_client(client_stream, client_config, server_name);
+
+        let (_server_out, server_in, _server_shutdown, server_handle) = drive(server_conn);
+        let (_client_out, client_in, _client_shutdown, client_handle) = drive(client_conn);
+
+        let server_recv = compio::time::timeout(Duration::from_secs(5), server_in.recv()).await;
+        let client_recv = compio::time::timeout(Duration::from_secs(5), client_in.recv()).await;
+        assert!(
+            matches!(server_recv, Ok(Err(_))),
+            "server in_rx must close cleanly on handshake reject, got {server_recv:?}"
+        );
+        assert!(
+            matches!(client_recv, Ok(Err(_))),
+            "client in_rx must close cleanly on handshake reject, got {client_recv:?}"
+        );
+
+        let _ = compio::time::timeout(Duration::from_secs(5), server_handle).await;
+        let _ = compio::time::timeout(Duration::from_secs(5), client_handle).await;
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn large_frame_round_trip_through_tls() {
+        const BODY_SIZE: usize = 1024 * 1024;
+        let total = HEADER_SIZE + BODY_SIZE;
+
+        let (server_config, cert_chain) = server_cfg();
+        let client_config = client_cfg_trusting(&cert_chain);
+        let server_name: ServerName<'static> = "localhost".try_into().expect("server name");
+
+        let (client_stream, server_stream) = connected_pair().await;
+        let server_conn = TcpTlsTransportConn::new_server(server_stream, server_config);
+        let client_conn =
+            TcpTlsTransportConn::new_client(client_stream, client_config, server_name);
+
+        let (_server_out, server_in, server_shutdown, server_handle) = drive(server_conn);
+        let (client_out, _client_in, client_shutdown, client_handle) = drive(client_conn);
+
+        client_out
+            .send(padded(Command2::Request, total))
+            .await
+            .expect("client send 1 MiB");
+        let received = compio::time::timeout(Duration::from_secs(10), server_in.recv())
+            .await
+            .expect("server recv within 10 s")
+            .expect("server frame");
+        assert_eq!(received.header().command, Command2::Request);
+        assert_eq!(received.header().size as usize, total);
+
+        server_shutdown.trigger();
+        client_shutdown.trigger();
+        let _ = compio::time::timeout(Duration::from_secs(10), server_handle).await;
+        let _ = compio::time::timeout(Duration::from_secs(10), client_handle).await;
+    }
+}
diff --git a/core/message_bus/src/transports/tls/mod.rs b/core/message_bus/src/transports/tls/mod.rs
new file mode 100644
index 000000000..0b93ac6ea
--- /dev/null
+++ b/core/message_bus/src/transports/tls/mod.rs
@@ -0,0 +1,257 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Shared TLS plumbing for the in-process TCP-TLS and WSS transports.
+//!
+//! Hosts the credentials newtypes, PEM loaders, and the idempotent
+//! rustls/ring crypto provider initializer. The hand-rolled
+//! `UnbufferedConnection` driver was retired in favour of `compio-tls`;
+//! the per-transport handshake + steady-state pump now lives directly
+//! in `transports/tcp_tls.rs` and `transports/wss.rs`.
+//!
+//! # Why an explicit `install_default_crypto_provider`
+//!
+//! `rustls` 0.23 requires a crypto provider to be installed before any
+//! `ServerConfig::builder()` / `ClientConfig::builder()` call. The
+//! workspace pins `rustls = { features = ["ring"] }`, so the
+//! provider is `rustls::crypto::ring::default_provider()`. Multiple
+//! call sites (transports, tests, server-ng bootstrap) may attempt to
+//! install it; the wrapper here is idempotent and safe under races
+//! between concurrent first-use sites.
+
+use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
+use std::io::{self, BufReader};
+use std::path::Path;
+use std::sync::Arc;
+
+/// Local-end role on a TLS connection. Selects which rustls state
+/// machine the per-transport handshake helper drives and is shared by
+/// the in-process TCP-TLS and WSS transports.
+pub enum TlsRole {
+    /// Server side: drives the rustls server state machine against the
+    /// supplied [`rustls::ServerConfig`].
+    Server(Arc<rustls::ServerConfig>),
+    /// Client side: drives the rustls client state machine against the
+    /// supplied [`rustls::ClientConfig`] + a pre-validated server name.
+    Client {
+        /// Client-role rustls configuration.
+        config: Arc<rustls::ClientConfig>,
+        /// Server name presented in SNI and validated against the leaf
+        /// certificate's SANs / CN. Owned `'static` because the
+        /// `compio_tls::TlsConnector::connect` path retains it for the
+        /// lifetime of the underlying rustls `ClientConnection`.
+        server_name: ServerName<'static>,
+    },
+}
+
+/// Server-side TLS credentials: a certificate chain and the matching
+/// private key, both in DER form.
+///
+/// Construct via [`load_pem`] for production deployments or
+/// [`self_signed_for_loopback`] for tests / local development.
+pub struct TlsServerCredentials {
+    /// Leaf certificate first, intermediates in order, root LAST or
+    /// omitted (matches the on-wire order rustls expects).
+    pub cert_chain: Vec<CertificateDer<'static>>,
+    /// PKCS#8, RSA, or SEC1-encoded private key matching the leaf
+    /// certificate's public key.
+    pub key_der: PrivateKeyDer<'static>,
+}
+
+impl std::fmt::Debug for TlsServerCredentials {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        // Don't print key bytes. cert_chain is public material so
+        // length is fine to disclose.
+        f.debug_struct("TlsServerCredentials")
+            .field("cert_chain_len", &self.cert_chain.len())
+            .field("key_der", &"<redacted>")
+            .finish()
+    }
+}
+
+/// Load a server certificate chain + private key from on-disk PEM
+/// files.
+///
+/// `cert_path` must contain one or more `BEGIN CERTIFICATE` blocks in
+/// chain order (leaf first). `key_path` must contain a single
+/// `BEGIN ... PRIVATE KEY` block (PKCS#8, RSA, or SEC1).
+///
+/// # Errors
+///
+/// Returns [`io::Error`] when either file cannot be read, the
+/// certificate file contains no certificates, or the key file
+/// contains no recognized private key.
+pub fn load_pem(cert_path: &Path, key_path: &Path) -> io::Result<TlsServerCredentials> {
+    let cert_file = std::fs::File::open(cert_path)?;
+    let mut cert_reader = BufReader::new(cert_file);
+    let cert_chain: Vec<CertificateDer<'static>> =
+        rustls_pemfile::certs(&mut cert_reader).collect::<Result<_, _>>()?;
+    if cert_chain.is_empty() {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidData,
+            format!("no certificates found in {}", cert_path.display()),
+        ));
+    }
+
+    let key_file = std::fs::File::open(key_path)?;
+    let mut key_reader = BufReader::new(key_file);
+    let key_der = rustls_pemfile::private_key(&mut key_reader)?.ok_or_else(|| {
+        io::Error::new(
+            io::ErrorKind::InvalidData,
+            format!("no private key found in {}", key_path.display()),
+        )
+    })?;
+
+    Ok(TlsServerCredentials {
+        cert_chain,
+        key_der,
+    })
+}
+
+/// Generate a fresh self-signed certificate valid for `localhost`.
+///
+/// Intended for tests and local-only development. Production
+/// deployments must call [`load_pem`] with credentials provisioned by
+/// the operator's PKI.
+///
+/// # Panics
+///
+/// Panics if `rcgen` fails to generate a key pair. This indicates a
+/// platform-level RNG failure and would not be a recoverable error
+/// in any caller this helper is intended for (tests, dev loops).
+#[must_use]
+pub fn self_signed_for_loopback() -> TlsServerCredentials {
+    let (cert_chain, key_der) = iggy_common::generate_self_signed_certificate("localhost")
+        .expect("rcgen self-signed certificate generation must not fail in test/dev paths");
+    TlsServerCredentials {
+        cert_chain,
+        key_der,
+    }
+}
+
+/// Install the rustls/ring crypto provider as the process-wide
+/// default. Idempotent and safe to call from multiple sites.
+///
+/// rustls 0.23 requires a default provider before any
+/// `ServerConfig::builder()` or `ClientConfig::builder()` call. This
+/// helper centralises the install so transports, tests, and
+/// server-ng bootstrap converge on the same provider without
+/// per-call-site `let _ = ...install_default()`.
+pub fn install_default_crypto_provider() {
+    // Race-safe: rustls's `install_default` returns Err if a provider
+    // is already installed (either by us on a prior call or by another
+    // crate in the workspace). Either outcome is a successful no-op
+    // for the caller.
+    let _ = rustls::crypto::ring::default_provider().install_default();
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use rustls::RootCertStore;
+    use std::io::Write;
+    use std::sync::Arc;
+
+    #[test]
+    fn pem_roundtrip_load_yields_usable_server_config() {
+        install_default_crypto_provider();
+
+        // Generate a real cert/key pair via rcgen and serialise to PEM,
+        // then verify load_pem reconstitutes credentials that build a
+        // valid rustls::ServerConfig.
+        let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).expect("rcgen");
+        let cert_pem = cert.cert.pem();
+        let key_pem = cert.signing_key.serialize_pem();
+
+        let cert_file = tempfile::NamedTempFile::new().expect("tempfile cert");
+        let key_file = tempfile::NamedTempFile::new().expect("tempfile key");
+        std::fs::File::create(cert_file.path())
+            .unwrap()
+            .write_all(cert_pem.as_bytes())
+            .unwrap();
+        std::fs::File::create(key_file.path())
+            .unwrap()
+            .write_all(key_pem.as_bytes())
+            .unwrap();
+
+        let loaded = load_pem(cert_file.path(), key_file.path()).expect("load_pem");
+        assert_eq!(loaded.cert_chain.len(), 1);
+
+        let _server_cfg = rustls::ServerConfig::builder()
+            .with_no_client_auth()
+            .with_single_cert(loaded.cert_chain, loaded.key_der)
+            .expect("ServerConfig::with_single_cert");
+    }
+
+    #[test]
+    fn self_signed_validates_via_rustls_server_config() {
+        install_default_crypto_provider();
+
+        let creds = self_signed_for_loopback();
+        let _server_cfg = rustls::ServerConfig::builder()
+            .with_no_client_auth()
+            .with_single_cert(creds.cert_chain.clone(), creds.key_der)
+            .expect("ServerConfig builds from self-signed cert");
+
+        // Same cert must be installable into a client RootCertStore for
+        // the test client side to dial in.
+        let mut roots = RootCertStore::empty();
+        roots
+            .add(creds.cert_chain[0].clone())
+            .expect("RootCertStore::add");
+        let _client_cfg = rustls::ClientConfig::builder()
+            .with_root_certificates(Arc::new(roots))
+            .with_no_client_auth();
+    }
+
+    #[test]
+    fn crypto_provider_install_is_idempotent() {
+        // Multiple calls must not panic. First call may install, all
+        // subsequent calls are no-ops.
+        install_default_crypto_provider();
+        install_default_crypto_provider();
+        install_default_crypto_provider();
+
+        // Sanity: a builder call after install must succeed (proves
+        // the provider is actually present).
+        let _ = rustls::ServerConfig::builder().with_no_client_auth();
+    }
+
+    #[test]
+    fn load_pem_rejects_empty_cert_file() {
+        let cert_file = tempfile::NamedTempFile::new().expect("tempfile cert");
+        let key_file = tempfile::NamedTempFile::new().expect("tempfile key");
+        // Both files exist but are empty.
+        let err = load_pem(cert_file.path(), key_file.path()).expect_err("must fail");
+        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
+    }
+
+    #[test]
+    fn load_pem_rejects_missing_key() {
+        let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).expect("rcgen");
+        let cert_pem = cert.cert.pem();
+        let cert_file = tempfile::NamedTempFile::new().expect("tempfile cert");
+        let key_file = tempfile::NamedTempFile::new().expect("tempfile key");
+        std::fs::File::create(cert_file.path())
+            .unwrap()
+            .write_all(cert_pem.as_bytes())
+            .unwrap();
+        // Key file is empty.
+        let err = load_pem(cert_file.path(), key_file.path()).expect_err("must fail");
+        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
+    }
+}
diff --git a/core/message_bus/src/transports/ws.rs b/core/message_bus/src/transports/ws.rs
new file mode 100644
index 000000000..985d30915
--- /dev/null
+++ b/core/message_bus/src/transports/ws.rs
@@ -0,0 +1,427 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Plaintext WS transport: `compio_ws::WebSocketStream<TcpStream>`
+//! driven by a single connection task per peer.
+//!
+//! # Architecture
+//!
+//! Replaces the prior post-handshake unwrap-to-bare-TcpStream + manual
+//! RFC 6455 frame parser with the compio-native stack:
+//!
+//! 1. The HTTP-Upgrade handshake runs externally
+//!    (`installer::install_client_ws_fd` calls
+//!    `compio_ws::accept_async`); the resulting
+//!    `WebSocketStream<TcpStream>` is handed to [`WsTransportConn::new_server`].
+//! 2. A single connection task owns the `WebSocketStream` and
+//!    `select!`s over the bus shutdown token, the outbound mailbox,
+//!    and `ws.read()`.
+//! 3. On shutdown the task sends a WS Close frame, flushes, and drops.
+//!
+//! # Cancel safety
+//!
+//! `compio_ws::WebSocketStream::read` (lib.rs:208-229) loops calling
+//! `inner.read()` against a buffered `SyncStream`. The await points are
+//! at `fill_read_buf` on the underlying stream; bytes already in the
+//! buffer survive a future drop. Tungstenite's frame parser state lives
+//! inside the WebSocket struct, also preserved across drops.
+//!
+//! Sends are run to completion outside any `select!`. The token signals
+//! the *start* of close, never cancels an in-flight send.
+//!
+//! # Pings
+//!
+//! Inbound `Message::Ping` is queued by tungstenite as an auto-Pong
+//! reply; the `WebSocketStream::read` flush before delivery drains the
+//! queue, so the application code only needs to ignore Pings. No
+//! explicit Pong send required.
+
+use super::{ActorContext, TransportConn};
+use crate::framing;
+use crate::lifecycle::BusMessage;
+use compio::net::TcpStream;
+use compio::ws::WebSocketStream;
+use compio::ws::tungstenite::{self, Message as WsMessage};
+use futures::FutureExt;
+use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
+use iggy_binary_protocol::{GenericHeader, Message};
+use std::time::Duration;
+use tracing::{debug, warn};
+
+/// Default wall-clock bound on the WS Close + drop sequence.
+const DEFAULT_CLOSE_GRACE: Duration = Duration::from_secs(2);
+
+/// Errors decoded from a WS `Message::Binary` payload.
+#[derive(Debug)]
+pub(in crate::transports) enum FrameDecodeError {
+    BadHeader,
+    BadSize,
+}
+
+/// Decode one consensus `Message<GenericHeader>` from a raw WS Binary
+/// payload. Reused by [`super::wss`] over the TLS-protected channel.
+pub(in crate::transports) fn decode_consensus_frame(
+    body: &[u8],
+) -> Result<Message<GenericHeader>, FrameDecodeError> {
+    if body.len() < iggy_binary_protocol::HEADER_SIZE {
+        return Err(FrameDecodeError::BadHeader);
+    }
+    let total_size = u32::from_le_bytes(
+        body[48..52]
+            .try_into()
+            .map_err(|_| FrameDecodeError::BadHeader)?,
+    ) as usize;
+    if !(iggy_binary_protocol::HEADER_SIZE..=framing::MAX_MESSAGE_SIZE).contains(&total_size)
+        || total_size != body.len()
+    {
+        return Err(FrameDecodeError::BadSize);
+    }
+    // Aligned MESSAGE_ALIGN backing memory is required by the
+    // `Message<GenericHeader>` invariant; the WS payload is a plain byte
+    // slice with no alignment guarantee, so this is a one-time copy.
+    let owned =
+        iggy_binary_protocol::consensus::iobuf::Owned::<MESSAGE_ALIGN>::copy_from_slice(body);
+    Message::<GenericHeader>::try_from(owned).map_err(|_| FrameDecodeError::BadHeader)
+}
+
+/// A single WebSocket connection.
+///
+/// Holds an already-upgraded `WebSocketStream<TcpStream>`. Construct
+/// with [`Self::new_server`] for accepted server-role connections or
+/// [`Self::new_client`] for outbound dialer paths / tests.
+pub struct WsTransportConn {
+    stream: WebSocketStream<TcpStream>,
+    close_grace: Duration,
+}
+
+impl WsTransportConn {
+    #[must_use]
+    pub const fn new_server(stream: WebSocketStream<TcpStream>) -> Self {
+        Self {
+            stream,
+            close_grace: DEFAULT_CLOSE_GRACE,
+        }
+    }
+
+    #[must_use]
+    pub const fn new_client(stream: WebSocketStream<TcpStream>) -> Self {
+        Self {
+            stream,
+            close_grace: DEFAULT_CLOSE_GRACE,
+        }
+    }
+
+    /// Override the wall-clock bound on the close sequence.
+    #[must_use]
+    pub const fn with_close_grace(mut self, close_grace: Duration) -> Self {
+        self.close_grace = close_grace;
+        self
+    }
+}
+
+impl TransportConn for WsTransportConn {
+    #[allow(clippy::future_not_send)]
+    async fn run(self, ctx: ActorContext) {
+        let label = ctx.label;
+        let peer = ctx.peer.clone();
+        let mut ws = self.stream;
+        run_pump(&mut ws, ctx).await;
+        drive_close(&mut ws, self.close_grace, label, &peer).await;
+    }
+}
+
+/// Per-iteration outcome of the single-task select.
+enum PumpAction {
+    Shutdown,
+    Send(BusMessage),
+    Recv(Result<WsMessage, tungstenite::Error>),
+    MailboxClosed,
+}
+
+/// Drive the WS connection until shutdown, peer Close, or an
+/// unrecoverable error.
+#[allow(clippy::future_not_send)]
+async fn run_pump(ws: &mut WebSocketStream<TcpStream>, ctx: ActorContext) {
+    let ActorContext {
+        in_tx,
+        rx,
+        shutdown,
+        label,
+        peer,
+        ..
+    } = ctx;
+    let mut shutdown_fut = Box::pin(shutdown.wait().fuse());
+
+    loop {
+        let action = {
+            let read_fut = ws.read();
+            let recv_fut = rx.recv();
+            futures::pin_mut!(read_fut);
+            futures::pin_mut!(recv_fut);
+
+            futures::select_biased! {
+                () = shutdown_fut.as_mut() => PumpAction::Shutdown,
+                msg = recv_fut.fuse() => msg.map_or(PumpAction::MailboxClosed, PumpAction::Send),
+                res = read_fut.fuse() => PumpAction::Recv(res),
+            }
+        };
+
+        match action {
+            PumpAction::Shutdown => {
+                debug!(%label, %peer, "ws pump: shutdown observed");
+                return;
+            }
+            PumpAction::MailboxClosed => {
+                debug!(%label, %peer, "ws pump: mailbox closed");
+                return;
+            }
+            PumpAction::Send(msg) => {
+                let payload: Vec<u8> = msg.as_slice().to_vec();
+                if let Err(e) = ws.send(WsMessage::Binary(payload.into())).await {
+                    warn!(%label, %peer, error = ?e, "ws writer: send failed");
+                    return;
+                }
+                if let Err(e) = ws.flush().await {
+                    warn!(%label, %peer, error = ?e, "ws writer: flush failed");
+                    return;
+                }
+            }
+            PumpAction::Recv(Ok(msg)) => match msg {
+                WsMessage::Binary(bytes) => match decode_consensus_frame(&bytes) {
+                    Ok(frame) => {
+                        if in_tx.send(frame).await.is_err() {
+                            debug!(%label, %peer, "ws reader: inbound queue dropped");
+                            return;
+                        }
+                    }
+                    Err(e) => {
+                        warn!(%label, %peer, error = ?e, "ws reader: bad consensus frame");
+                        return;
+                    }
+                },
+                WsMessage::Ping(_) | WsMessage::Pong(_) => {
+                    // Tungstenite queues an auto-Pong for inbound Pings;
+                    // `compio_ws::WebSocketStream::read` flushes before
+                    // delivery so no explicit reply needed.
+                }
+                WsMessage::Close(_) => {
+                    debug!(%label, %peer, "ws reader: peer initiated close");
+                    return;
+                }
+                WsMessage::Text(_) | WsMessage::Frame(_) => {
+                    warn!(%label, %peer, "ws reader: unexpected text/raw frame, closing");
+                    return;
+                }
+            },
+            PumpAction::Recv(Err(e)) => {
+                debug!(%label, %peer, error = ?e, "ws reader: read error");
+                return;
+            }
+        }
+    }
+}
+
+/// Best-effort cooperative close: send WS Close frame, flush, then drop
+/// the stream. Bounded by `close_grace`; on timeout the OS sends RST.
+#[allow(clippy::future_not_send)]
+async fn drive_close(
+    ws: &mut WebSocketStream<TcpStream>,
+    close_grace: Duration,
+    label: &'static str,
+    peer: &str,
+) {
+    if compio::time::timeout(close_grace, async {
+        let _ = ws.close(None).await;
+        let _ = ws.flush().await;
+    })
+    .await
+    .is_err()
+    {
+        warn!(
+            %label,
+            %peer,
+            grace_ms = close_grace.as_millis(),
+            "ws close: grace exceeded"
+        );
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::framing;
+    use crate::lifecycle::Shutdown;
+    use async_channel::{Receiver, Sender, bounded};
+    use compio::net::TcpListener;
+    use iggy_binary_protocol::consensus::iobuf::Frozen;
+    use iggy_binary_protocol::{Command2, GenericHeader, HEADER_SIZE, Message};
+    use std::time::Duration;
+
+    #[allow(clippy::cast_possible_truncation)]
+    fn header_only(command: Command2) -> Frozen<MESSAGE_ALIGN> {
+        Message::<GenericHeader>::new(HEADER_SIZE)
+            .transmute_header(|_, h: &mut GenericHeader| {
+                h.command = command;
+                h.size = HEADER_SIZE as u32;
+            })
+            .into_frozen()
+    }
+
+    #[allow(clippy::cast_possible_truncation)]
+    fn padded(command: Command2, total_size: usize) -> Frozen<MESSAGE_ALIGN> {
+        Message::<GenericHeader>::new(total_size)
+            .transmute_header(|_, h: &mut GenericHeader| {
+                h.command = command;
+                h.size = total_size as u32;
+            })
+            .into_frozen()
+    }
+
+    #[allow(clippy::future_not_send)]
+    async fn ws_pair() -> (WebSocketStream<TcpStream>, WebSocketStream<TcpStream>) {
+        let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
+        let addr = listener.local_addr().expect("local_addr");
+        let connect = async {
+            let stream = TcpStream::connect(addr).await.expect("connect");
+            let (ws, _resp) = compio_ws::client_async("ws://127.0.0.1/", stream)
+                .await
+                .expect("client_async");
+            ws
+        };
+        let accept = async {
+            let (stream, _peer) = listener.accept().await.expect("accept");
+            compio_ws::accept_async(stream).await.expect("accept_async")
+        };
+        let (client, server) = futures::join!(connect, accept);
+        (client, server)
+    }
+
+    #[allow(clippy::future_not_send)]
+    fn drive(
+        conn: WsTransportConn,
+    ) -> (
+        Sender<Frozen<MESSAGE_ALIGN>>,
+        Receiver<Message<GenericHeader>>,
+        Shutdown,
+        compio::runtime::JoinHandle<()>,
+    ) {
+        let (out_tx, out_rx) = bounded::<Frozen<MESSAGE_ALIGN>>(16);
+        let (in_tx, in_rx) = bounded::<Message<GenericHeader>>(16);
+        let (shutdown, token) = Shutdown::new();
+        let ctx = ActorContext {
+            in_tx,
+            rx: out_rx,
+            shutdown: token,
+            max_batch: 16,
+            max_message_size: framing::MAX_MESSAGE_SIZE,
+            label: "test",
+            peer: "test".to_owned(),
+        };
+        let handle = compio::runtime::spawn(async move { conn.run(ctx).await });
+        (out_tx, in_rx, shutdown, handle)
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn ws_loopback_round_trip() {
+        let (client_ws, server_ws) = ws_pair().await;
+        let server_conn = WsTransportConn::new_server(server_ws);
+        let client_conn = WsTransportConn::new_client(client_ws);
+
+        let (server_out, server_in, server_shutdown, server_handle) = drive(server_conn);
+        let (client_out, client_in, client_shutdown, client_handle) = drive(client_conn);
+
+        client_out
+            .send(header_only(Command2::Request))
+            .await
+            .expect("client send");
+        let received = compio::time::timeout(Duration::from_secs(5), server_in.recv())
+            .await
+            .expect("server recv within 5 s")
+            .expect("server frame");
+        assert_eq!(received.header().command, Command2::Request);
+
+        server_out
+            .send(header_only(Command2::Reply))
+            .await
+            .expect("server send");
+        let reply = compio::time::timeout(Duration::from_secs(5), client_in.recv())
+            .await
+            .expect("client recv within 5 s")
+            .expect("client frame");
+        assert_eq!(reply.header().command, Command2::Reply);
+
+        server_shutdown.trigger();
+        client_shutdown.trigger();
+        let _ = compio::time::timeout(Duration::from_secs(5), server_handle).await;
+        let _ = compio::time::timeout(Duration::from_secs(5), client_handle).await;
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn ws_large_frame_round_trip() {
+        const BODY_SIZE: usize = 1024 * 1024;
+        let total = HEADER_SIZE + BODY_SIZE;
+
+        let (client_ws, server_ws) = ws_pair().await;
+        let server_conn = WsTransportConn::new_server(server_ws);
+        let client_conn = WsTransportConn::new_client(client_ws);
+
+        let (_server_out, server_in, server_shutdown, server_handle) = drive(server_conn);
+        let (client_out, _client_in, client_shutdown, client_handle) = drive(client_conn);
+
+        client_out
+            .send(padded(Command2::Request, total))
+            .await
+            .expect("client send 1 MiB");
+        let received = compio::time::timeout(Duration::from_secs(15), server_in.recv())
+            .await
+            .expect("server recv within 15 s")
+            .expect("server frame");
+        assert_eq!(received.header().command, Command2::Request);
+        assert_eq!(received.header().size as usize, total);
+
+        server_shutdown.trigger();
+        client_shutdown.trigger();
+        let _ = compio::time::timeout(Duration::from_secs(10), server_handle).await;
+        let _ = compio::time::timeout(Duration::from_secs(10), client_handle).await;
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn ws_shutdown_exits_promptly() {
+        let (client_ws, server_ws) = ws_pair().await;
+        let server_conn = WsTransportConn::new_server(server_ws);
+        let client_conn = WsTransportConn::new_client(client_ws);
+
+        let (_server_out, _server_in, server_shutdown, server_handle) = drive(server_conn);
+        let (_client_out, _client_in, client_shutdown, client_handle) = drive(client_conn);
+
+        server_shutdown.trigger();
+        client_shutdown.trigger();
+        let server_done = compio::time::timeout(Duration::from_secs(5), server_handle).await;
+        let client_done = compio::time::timeout(Duration::from_secs(5), client_handle).await;
+        assert!(
+            server_done.is_ok(),
+            "server must exit within 5 s of shutdown"
+        );
+        assert!(
+            client_done.is_ok(),
+            "client must exit within 5 s of shutdown"
+        );
+    }
+}
diff --git a/core/message_bus/src/transports/wss.rs b/core/message_bus/src/transports/wss.rs
new file mode 100644
index 000000000..02c1bcd0d
--- /dev/null
+++ b/core/message_bus/src/transports/wss.rs
@@ -0,0 +1,543 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! In-process WSS transport: `compio_ws::WebSocketStream` over a
+//! `compio_tls::TlsStream<TcpStream>`, driven by a single connection
+//! task per peer.
+//!
+//! # Architecture
+//!
+//! Replaces the prior hand-rolled rustls `UnbufferedConnection` driver +
+//! manual WebSocket frame parser with the compio-native stack:
+//!
+//! 1. TCP accept (already done by listener) gives us a `TcpStream`.
+//! 2. `compio_tls::TlsAcceptor::accept` (server) or `TlsConnector::connect`
+//!    (client) drives the rustls handshake.
+//! 3. `compio_ws::accept_async` (server) or `compio_ws::client_async`
+//!    (client) runs the WebSocket HTTP-Upgrade exchange over the
+//!    encrypted channel. No subprotocol negotiation: the bus accepts any
+//!    client and lets the post-handshake LOGIN command in the caller
+//!    establish identity.
+//! 4. The resulting `WebSocketStream<TcpStream>` (where the inner
+//!    `MaybeTlsStream<TcpStream>` is the `Tls` variant) is owned by a
+//!    single connection task that `select!`s over the bus shutdown
+//!    token, the outbound mailbox, and `ws.read()`.
+//!
+//! # Cancel safety
+//!
+//! `compio_ws::WebSocketStream::poll_next` (lib.rs:356-372) buffers a
+//! decoded but undelivered `Message` in `next_item: Option<...>`. If the
+//! outer `read()` future is dropped between the buffer-and-flush step
+//! and delivery, the message stays in `next_item` and is yielded by the
+//! next `read()`. Combined with the underlying compio-tls + `AsyncStream`
+//! `read_future` preservation across drops, mid-frame cancels lose no
+//! plaintext bytes. See `tcp_tls.rs` for the TLS-layer rationale.
+//!
+//! Sends are run to completion outside any `select!`. The token signals
+//! the *start* of close, never cancels an in-flight send.
+//!
+//! # Frozen ownership
+//!
+//! Each outbound consensus frame is wrapped in a single
+//! `Message::Binary(Bytes)`. Tungstenite copies the payload through its
+//! frame buffer, and rustls copies plaintext into ciphertext records:
+//! two structural copies on the WSS plane (the same as the prior
+//! hand-rolled implementation).
+
+use super::tls::TlsRole;
+use super::ws::decode_consensus_frame;
+use super::{ActorContext, TransportConn};
+use crate::lifecycle::BusMessage;
+use compio::net::TcpStream;
+use compio::tls::{TlsAcceptor, TlsConnector, TlsStream};
+use compio::ws::tungstenite::{self, Message as WsMessage, protocol::WebSocketConfig};
+use compio::ws::{WebSocketStream, accept_async_with_config, client_async_with_config};
+use futures::FutureExt;
+use rustls::pki_types::ServerName;
+use std::sync::Arc;
+use std::time::Duration;
+use tracing::{debug, warn};
+
+/// Default wall-clock bound on the WS Close + TLS shutdown sequence.
+const DEFAULT_CLOSE_GRACE: Duration = Duration::from_secs(2);
+
+/// In-process WSS transport: a [`TcpStream`] paired with a role-specific
+/// rustls configuration. The WebSocket layer rides on top of the TLS
+/// layer, both driven by `compio-tls` / `compio-ws`.
+pub struct WssTransportConn {
+    stream: TcpStream,
+    role: TlsRole,
+    close_grace: Duration,
+    ws_config: WebSocketConfig,
+}
+
+impl WssTransportConn {
+    #[must_use]
+    pub fn new_server(stream: TcpStream, config: Arc<rustls::ServerConfig>) -> Self {
+        Self {
+            stream,
+            role: TlsRole::Server(config),
+            close_grace: DEFAULT_CLOSE_GRACE,
+            ws_config: WebSocketConfig::default(),
+        }
+    }
+
+    #[must_use]
+    pub fn new_client(
+        stream: TcpStream,
+        config: Arc<rustls::ClientConfig>,
+        server_name: ServerName<'static>,
+    ) -> Self {
+        Self {
+            stream,
+            role: TlsRole::Client {
+                config,
+                server_name,
+            },
+            close_grace: DEFAULT_CLOSE_GRACE,
+            ws_config: WebSocketConfig::default(),
+        }
+    }
+
+    /// Override the wall-clock bound on the close sequence
+    /// (`ws.close` + bounded TLS shutdown).
+    #[must_use]
+    pub const fn with_close_grace(mut self, close_grace: Duration) -> Self {
+        self.close_grace = close_grace;
+        self
+    }
+
+    /// Override the WebSocket frame-layer config (read/write buffer
+    /// sizes, max frame / message size, accept-unmasked-frames flag).
+    #[must_use]
+    pub const fn with_ws_config(mut self, ws_config: WebSocketConfig) -> Self {
+        self.ws_config = ws_config;
+        self
+    }
+}
+
+impl TransportConn for WssTransportConn {
+    #[allow(clippy::future_not_send)]
+    async fn run(self, ctx: ActorContext) {
+        let label = ctx.label;
+        let peer = ctx.peer.clone();
+
+        let role = self.role;
+        let is_server = matches!(role, TlsRole::Server(_));
+        let ws_config = self.ws_config;
+
+        // 1. TLS handshake.
+        let tls_stream = match tls_handshake(role, self.stream).await {
+            Ok(s) => s,
+            Err(e) => {
+                warn!(
+                    %label,
+                    %peer,
+                    error = ?e,
+                    "WSS: TLS handshake failed"
+                );
+                return;
+            }
+        };
+
+        // 2. WebSocket HTTP-Upgrade over the encrypted channel.
+        let mut ws = match ws_handshake(tls_stream, is_server, &peer, ws_config).await {
+            Ok(s) => s,
+            Err(e) => {
+                warn!(
+                    %label,
+                    %peer,
+                    error = ?e,
+                    "WSS: WebSocket upgrade failed"
+                );
+                return;
+            }
+        };
+
+        // 3. Steady-state pump.
+        run_pump(&mut ws, ctx).await;
+
+        // 4. Cooperative close: WS Close frame + bounded TLS shutdown.
+        drive_close(&mut ws, self.close_grace, label, &peer).await;
+    }
+}
+
+/// Run the role-specific rustls handshake on `stream` and return the
+/// encrypted [`TlsStream`].
+#[allow(clippy::future_not_send)]
+async fn tls_handshake(role: TlsRole, stream: TcpStream) -> std::io::Result<TlsStream<TcpStream>> {
+    match role {
+        TlsRole::Server(cfg) => TlsAcceptor::from(cfg).accept(stream).await,
+        TlsRole::Client {
+            config,
+            server_name,
+        } => {
+            let domain = server_name.to_str();
+            TlsConnector::from(config).connect(&domain, stream).await
+        }
+    }
+}
+
+/// Drive the WebSocket HTTP-Upgrade exchange over the TLS-protected
+/// channel.
+///
+/// Server: plain `accept_async_with_config`; the bus does not negotiate
+/// any subprotocol. Client identity is established post-handshake by
+/// the caller via the LOGIN command.
+///
+/// Client: sends an Upgrade request to `wss://localhost/`, validates
+/// the 101 response.
+#[allow(clippy::future_not_send)]
+async fn ws_handshake(
+    tls_stream: TlsStream<TcpStream>,
+    is_server: bool,
+    peer: &str,
+    ws_config: WebSocketConfig,
+) -> Result<WebSocketStream<TlsStream<TcpStream>>, tungstenite::Error> {
+    if is_server {
+        accept_async_with_config(tls_stream, ws_config).await
+    } else {
+        let request = client_request(peer)?;
+        let (ws, _resp) = client_async_with_config(request, tls_stream, ws_config).await?;
+        Ok(ws)
+    }
+}
+
+/// Build the client-side Upgrade request: `wss://{peer}/`.
+fn client_request(peer: &str) -> Result<tungstenite::http::Request<()>, tungstenite::Error> {
+    use compio::ws::tungstenite::client::IntoClientRequest;
+
+    // The peer field is a free-form trace label from the bus; for the
+    // request URI fall back to "localhost" so tungstenite's host
+    // validation does not reject exotic identifiers (replica ids,
+    // hex-encoded client ids).
+    let host = if peer.is_empty() || !peer.chars().all(is_hostname_char) {
+        "localhost"
+    } else {
+        peer
+    };
+    let uri = format!("wss://{host}/");
+    uri.into_client_request()
+}
+
+const fn is_hostname_char(c: char) -> bool {
+    c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == ':'
+}
+
+/// Per-iteration outcome of the single-task select.
+enum PumpAction {
+    Shutdown,
+    Send(BusMessage),
+    Recv(Result<WsMessage, tungstenite::Error>),
+    MailboxClosed,
+}
+
+/// Drive the WSS connection until shutdown, peer Close, or an
+/// unrecoverable error.
+#[allow(clippy::future_not_send)]
+async fn run_pump(ws: &mut WebSocketStream<TlsStream<TcpStream>>, ctx: ActorContext) {
+    let ActorContext {
+        in_tx,
+        rx,
+        shutdown,
+        label,
+        peer,
+        ..
+    } = ctx;
+    let mut shutdown_fut = Box::pin(shutdown.wait().fuse());
+
+    loop {
+        let action = {
+            let read_fut = ws.read();
+            let recv_fut = rx.recv();
+            futures::pin_mut!(read_fut);
+            futures::pin_mut!(recv_fut);
+
+            futures::select_biased! {
+                () = shutdown_fut.as_mut() => PumpAction::Shutdown,
+                msg = recv_fut.fuse() => msg.map_or(PumpAction::MailboxClosed, PumpAction::Send),
+                res = read_fut.fuse() => PumpAction::Recv(res),
+            }
+        };
+
+        match action {
+            PumpAction::Shutdown => {
+                debug!(%label, %peer, "wss pump: shutdown observed");
+                return;
+            }
+            PumpAction::MailboxClosed => {
+                debug!(%label, %peer, "wss pump: mailbox closed");
+                return;
+            }
+            PumpAction::Send(msg) => {
+                let payload: Vec<u8> = msg.as_slice().to_vec();
+                if let Err(e) = ws.send(WsMessage::Binary(payload.into())).await {
+                    warn!(%label, %peer, error = ?e, "wss writer: send failed");
+                    return;
+                }
+                if let Err(e) = ws.flush().await {
+                    warn!(%label, %peer, error = ?e, "wss writer: flush failed");
+                    return;
+                }
+            }
+            PumpAction::Recv(Ok(msg)) => match msg {
+                WsMessage::Binary(bytes) => match decode_consensus_frame(&bytes) {
+                    Ok(frame) => {
+                        if in_tx.send(frame).await.is_err() {
+                            debug!(%label, %peer, "wss reader: inbound queue dropped");
+                            return;
+                        }
+                    }
+                    Err(e) => {
+                        warn!(%label, %peer, error = ?e, "wss reader: bad consensus frame");
+                        return;
+                    }
+                },
+                WsMessage::Ping(_) | WsMessage::Pong(_) => {
+                    // Tungstenite queues an auto-Pong for inbound Pings;
+                    // `compio_ws::WebSocketStream::poll_next` flushes the
+                    // queue between read completion and delivery.
+                }
+                WsMessage::Close(_) => {
+                    debug!(%label, %peer, "wss reader: peer initiated close");
+                    return;
+                }
+                WsMessage::Text(_) | WsMessage::Frame(_) => {
+                    warn!(%label, %peer, "wss reader: unexpected text/raw frame, closing");
+                    return;
+                }
+            },
+            PumpAction::Recv(Err(e)) => {
+                debug!(%label, %peer, error = ?e, "wss reader: read error");
+                return;
+            }
+        }
+    }
+}
+
+/// Best-effort cooperative close: send WS Close frame, flush, then drop
+/// the stream. Bounded by `close_grace`; on timeout the OS sends RST.
+///
+/// Shutdown is a transaction (no `select!` racing against the token).
+#[allow(clippy::future_not_send)]
+async fn drive_close(
+    ws: &mut WebSocketStream<TlsStream<TcpStream>>,
+    close_grace: Duration,
+    label: &'static str,
+    peer: &str,
+) {
+    // WebSocketStream::close sends a Close frame; flush drains
+    // tungstenite's write buffer to the wire. Dropping the stream after
+    // closes the inner TLS layer (no explicit close_notify; WS Close is
+    // already a protocol-level shutdown signal accepted as a degraded
+    // TLS close).
+    if compio::time::timeout(close_grace, async {
+        let _ = ws.close(None).await;
+        let _ = ws.flush().await;
+    })
+    .await
+    .is_err()
+    {
+        warn!(
+            %label,
+            %peer,
+            grace_ms = close_grace.as_millis(),
+            "wss close: grace exceeded"
+        );
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::framing;
+    use crate::lifecycle::Shutdown;
+    use crate::transports::tls::{install_default_crypto_provider, self_signed_for_loopback};
+    use async_channel::{Receiver, Sender, bounded};
+    use compio::net::TcpListener;
+    use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
+    use iggy_binary_protocol::consensus::iobuf::Frozen;
+    use iggy_binary_protocol::{Command2, GenericHeader, HEADER_SIZE, Message};
+    use rustls::RootCertStore;
+    use std::net::SocketAddr;
+    use std::sync::OnceLock;
+
+    fn ensure_provider() {
+        static GUARD: OnceLock<()> = OnceLock::new();
+        GUARD.get_or_init(install_default_crypto_provider);
+    }
+
+    fn server_cfg() -> (
+        Arc<rustls::ServerConfig>,
+        Vec<rustls::pki_types::CertificateDer<'static>>,
+    ) {
+        ensure_provider();
+        let creds = self_signed_for_loopback();
+        let cert_chain = creds.cert_chain.clone();
+        let mut cfg = rustls::ServerConfig::builder()
+            .with_no_client_auth()
+            .with_single_cert(cert_chain.clone(), creds.key_der)
+            .expect("server config");
+        cfg.max_early_data_size = 0;
+        (Arc::new(cfg), cert_chain)
+    }
+
+    fn client_cfg_trusting(
+        server_cert_chain: &[rustls::pki_types::CertificateDer<'static>],
+    ) -> Arc<rustls::ClientConfig> {
+        ensure_provider();
+        let mut roots = RootCertStore::empty();
+        for cert in server_cert_chain {
+            roots.add(cert.clone()).expect("add cert");
+        }
+        Arc::new(
+            rustls::ClientConfig::builder()
+                .with_root_certificates(Arc::new(roots))
+                .with_no_client_auth(),
+        )
+    }
+
+    #[allow(clippy::future_not_send)]
+    async fn connected_pair() -> (TcpStream, TcpStream) {
+        let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
+        let server_addr: SocketAddr = listener.local_addr().expect("local_addr");
+        let connect = TcpStream::connect(server_addr);
+        let accept = listener.accept();
+        let (client_res, accept_res) = futures::join!(connect, accept);
+        let (server, _peer) = accept_res.expect("accept");
+        (client_res.expect("connect"), server)
+    }
+
+    #[allow(clippy::cast_possible_truncation)]
+    fn header_only(command: Command2) -> Frozen<MESSAGE_ALIGN> {
+        Message::<GenericHeader>::new(HEADER_SIZE)
+            .transmute_header(|_, h: &mut GenericHeader| {
+                h.command = command;
+                h.size = HEADER_SIZE as u32;
+            })
+            .into_frozen()
+    }
+
+    #[allow(clippy::cast_possible_truncation)]
+    fn padded(command: Command2, total_size: usize) -> Frozen<MESSAGE_ALIGN> {
+        Message::<GenericHeader>::new(total_size)
+            .transmute_header(|_, h: &mut GenericHeader| {
+                h.command = command;
+                h.size = total_size as u32;
+            })
+            .into_frozen()
+    }
+
+    #[allow(clippy::future_not_send)]
+    fn drive(
+        conn: WssTransportConn,
+    ) -> (
+        Sender<Frozen<MESSAGE_ALIGN>>,
+        Receiver<Message<GenericHeader>>,
+        Shutdown,
+        compio::runtime::JoinHandle<()>,
+    ) {
+        let (out_tx, out_rx) = bounded::<Frozen<MESSAGE_ALIGN>>(16);
+        let (in_tx, in_rx) = bounded::<Message<GenericHeader>>(16);
+        let (shutdown, token) = Shutdown::new();
+        let ctx = ActorContext {
+            in_tx,
+            rx: out_rx,
+            shutdown: token,
+            max_batch: 16,
+            max_message_size: framing::MAX_MESSAGE_SIZE,
+            label: "test",
+            peer: "localhost".to_owned(),
+        };
+        let handle = compio::runtime::spawn(async move { conn.run(ctx).await });
+        (out_tx, in_rx, shutdown, handle)
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn wss_loopback_round_trip_with_self_signed_cert() {
+        let (server_config, cert_chain) = server_cfg();
+        let client_config = client_cfg_trusting(&cert_chain);
+        let server_name: ServerName<'static> = "localhost".try_into().expect("server name");
+
+        let (client_stream, server_stream) = connected_pair().await;
+        let server_conn = WssTransportConn::new_server(server_stream, server_config);
+        let client_conn = WssTransportConn::new_client(client_stream, client_config, server_name);
+
+        let (server_out, server_in, server_shutdown, server_handle) = drive(server_conn);
+        let (client_out, client_in, client_shutdown, client_handle) = drive(client_conn);
+
+        client_out
+            .send(header_only(Command2::Request))
+            .await
+            .expect("client send");
+        let received = compio::time::timeout(Duration::from_secs(5), server_in.recv())
+            .await
+            .expect("server recv within 5 s")
+            .expect("server frame");
+        assert_eq!(received.header().command, Command2::Request);
+
+        server_out
+            .send(header_only(Command2::Reply))
+            .await
+            .expect("server send");
+        let reply = compio::time::timeout(Duration::from_secs(5), client_in.recv())
+            .await
+            .expect("client recv within 5 s")
+            .expect("client frame");
+        assert_eq!(reply.header().command, Command2::Reply);
+
+        server_shutdown.trigger();
+        client_shutdown.trigger();
+        let _ = compio::time::timeout(Duration::from_secs(5), server_handle).await;
+        let _ = compio::time::timeout(Duration::from_secs(5), client_handle).await;
+    }
+
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn wss_large_frame_round_trip() {
+        const BODY_SIZE: usize = 1024 * 1024;
+        let total = HEADER_SIZE + BODY_SIZE;
+
+        let (server_config, cert_chain) = server_cfg();
+        let client_config = client_cfg_trusting(&cert_chain);
+        let server_name: ServerName<'static> = "localhost".try_into().expect("server name");
+
+        let (client_stream, server_stream) = connected_pair().await;
+        let server_conn = WssTransportConn::new_server(server_stream, server_config);
+        let client_conn = WssTransportConn::new_client(client_stream, client_config, server_name);
+
+        let (_server_out, server_in, server_shutdown, server_handle) = drive(server_conn);
+        let (client_out, _client_in, client_shutdown, client_handle) = drive(client_conn);
+
+        client_out
+            .send(padded(Command2::Request, total))
+            .await
+            .expect("client send 1 MiB");
+        let received = compio::time::timeout(Duration::from_secs(15), server_in.recv())
+            .await
+            .expect("server recv within 15 s")
+            .expect("server frame");
+        assert_eq!(received.header().command, Command2::Request);
+        assert_eq!(received.header().size as usize, total);
+
+        server_shutdown.trigger();
+        client_shutdown.trigger();
+        let _ = compio::time::timeout(Duration::from_secs(10), server_handle).await;
+        let _ = compio::time::timeout(Duration::from_secs(10), client_handle).await;
+    }
+}
diff --git a/core/message_bus/src/writer_task.rs b/core/message_bus/src/writer_task.rs
deleted file mode 100644
index de3f3304e..000000000
--- a/core/message_bus/src/writer_task.rs
+++ /dev/null
@@ -1,117 +0,0 @@
-// Licensed to the Apache Software Foundation (ASF) under one
-// or more contributor license agreements.  See the NOTICE file
-// distributed with this work for additional information
-// regarding copyright ownership.  The ASF licenses this file
-// to you under the Apache License, Version 2.0 (the
-// "License"); you may not use this file except in compliance
-// with the License.  You may obtain a copy of the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing,
-// software distributed under the License is distributed on an
-// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-// KIND, either express or implied.  See the License for the
-// specific language governing permissions and limitations
-// under the License.
-
-//! Per-connection writer task.
-//!
-//! Drains the per-peer mpsc and writes batched consensus frames in a single
-//! `writev` syscall via `compio::io::AsyncWriteExt::write_vectored_all`.
-//!
-//! Single-producer (the bus `send_to_*` path) -> single-consumer (this task),
-//! per peer. The mpsc bound provides backpressure: callers `try_send` and
-//! receive [`SendError::Backpressure`](crate::SendError::Backpressure) when
-//! the queue is full. VSR retransmits dropped messages from the WAL.
-//!
-//! Lifecycle: the task exits cleanly when either
-//! - the bus shutdown token fires (preferred path during graceful shutdown),
-//! - the `Sender` side is dropped/closed (peer unregistered), or
-//! - a write to the wire fails (broken connection).
-
-use crate::lifecycle::{BusMessage, BusReceiver, ShutdownToken};
-use compio::io::AsyncWriteExt;
-use compio::net::{OwnedWriteHalf, TcpStream};
-use futures::FutureExt;
-use tracing::{debug, error, trace};
-
-/// Run the per-connection writer loop until the channel closes, the
-/// shutdown token fires, or a write fails.
-///
-/// `max_batch` caps how many messages a single `writev` syscall coalesces.
-/// Larger batches reduce syscalls per N messages at the cost of memory
-/// per batch and worst-case latency for the head-of-batch message.
-#[allow(clippy::future_not_send)]
-pub async fn run(
-    rx: BusReceiver,
-    mut write_half: OwnedWriteHalf<TcpStream>,
-    token: ShutdownToken,
-    label: &'static str,
-    peer: String,
-    max_batch: usize,
-) {
-    let mut batch: Vec<BusMessage> = Vec::with_capacity(max_batch);
-    // Build the shutdown wait future once and re-poll the same pinned
-    // instance every loop iteration. Re-creating `token.wait().fuse()`
-    // per batch re-registers the async-channel waker; a shared fused
-    // future resolves in-place the moment the token fires and then
-    // yields Ready on every subsequent poll.
-    let mut shutdown_fut = Box::pin(token.wait().fuse());
-
-    loop {
-        let first = futures::select! {
-            () = shutdown_fut.as_mut() => {
-                debug!(%label, %peer, "writer task: shutdown token fired");
-                return;
-            }
-            msg = rx.recv().fuse() => {
-                if let Ok(m) = msg {
-                    m
-                } else {
-                    debug!(%label, %peer, "writer task: channel closed");
-                    return;
-                }
-            }
-        };
-
-        batch.push(first);
-
-        // Drain the rest non-blocking up to max_batch so a single writev
-        // syscall covers as many messages as the queue currently holds.
-        while batch.len() < max_batch {
-            match rx.try_recv() {
-                Ok(m) => batch.push(m),
-                Err(_) => break,
-            }
-        }
-
-        let drained = batch.len();
-        trace!(%label, %peer, batch = drained, "writev batch");
-
-        // Single writev for the whole batch. write_vectored_all loops
-        // internally on partial writes until the full batch lands or the
-        // socket errors.
-        let to_write = std::mem::take(&mut batch);
-        let compio::BufResult(result, mut returned) = write_half.write_vectored_all(to_write).await;
-        if let Err(e) = result {
-            // Error (not warn) because the batch is now on the floor:
-            // VSR's prepare-timeout or view-change will recover, but the
-            // operator needs a loud diagnostic to correlate with the
-            // underlying network fault. `batch_len` lets them estimate
-            // how many messages were in flight when the write failed.
-            error!(
-                %label,
-                %peer,
-                error = ?e,
-                batch_len = drained,
-                "writer task: write_vectored_all failed, dropping batch"
-            );
-            return;
-        }
-
-        // Reuse the Vec allocation across iterations.
-        returned.clear();
-        batch = returned;
-    }
-}
diff --git a/core/message_bus/tests/backpressure.rs b/core/message_bus/tests/backpressure.rs
index 1a399aa04..d95d5e27a 100644
--- a/core/message_bus/tests/backpressure.rs
+++ b/core/message_bus/tests/backpressure.rs
@@ -27,7 +27,7 @@ mod common;
 use common::{header_only, install_replicas_locally, loopback};
 use iggy_binary_protocol::Command2;
 use message_bus::connector::{DEFAULT_RECONNECT_PERIOD, start as start_connector};
-use message_bus::replica_listener::{MessageHandler, bind, run};
+use message_bus::replica::listener::{MessageHandler, bind, run};
 use message_bus::{IggyMessageBus, MessageBus, SendError};
 use std::rc::Rc;
 use std::time::Duration;
diff --git a/core/message_bus/tests/cancel_unsafe.rs b/core/message_bus/tests/cancel_unsafe.rs
new file mode 100644
index 000000000..cc5eea471
--- /dev/null
+++ b/core/message_bus/tests/cancel_unsafe.rs
@@ -0,0 +1,140 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Regression test pinning the compio cancel-unsafe property in CI.
+//!
+//! ## Why this test must never be deleted
+//!
+//! It empirically demonstrates that `compio`'s `io_uring` `read*`
+//! futures are NOT cancel-safe: dropping a mid-flight `read_exact`
+//! future loses bytes that the kernel has already DMA'd into the
+//! caller-owned buffer.
+//!
+//! Sources:
+//!   - `compio-runtime-0.11.0/src/runtime/future.rs:163-169`
+//!     (`Submit::drop` posts fire-and-forget `IORING_OP_ASYNC_CANCEL`).
+//!   - `compio-driver-0.11.4/src/lib.rs:135-148`
+//!     (`AsyncifyPool::dispatch` doc: "the cancellation is not reliable.
+//!     The underlying operation may continue.").
+//!   - `compio-driver-0.11.4/src/sys/iour/mod.rs:282-301`
+//!     (cancel SQE submitted with no completion wait).
+//!
+//! The bus's transports structurally avoid the trap by never
+//! `select!`-ing over a TCP read on a still-alive connection. If a
+//! future refactor of the IO model removes that guard, this test stays
+//! green only because the underlying compio behaviour stays unchanged;
+//! the protective code path is the bus's responsibility, not compio's.
+//!
+//! If THIS test ever flakes or starts passing under the cancel-safe
+//! verdict, the IO model assumptions across the bus must be re-audited
+//! before any `select!` over a read can be reintroduced.
+//!
+//! Mechanics:
+//!   1. Loopback TCP pair.
+//!   2. Sender writes 200 'A' bytes, sleeps 150 ms, writes 56 'B' bytes.
+//!   3. Receiver wraps `read_exact(buf, 256)` in a `select!` with a
+//!      50 ms timer. Timer wins; the `read_exact` future is dropped.
+//!   4. Receiver re-issues `read_exact(buf, 256)` on the same stream.
+//!   5. Assert: phase 2 returns `Err(UnexpectedEof)` (bytes lost) and
+//!      the bytes that DO arrive in phase 2 are 'B' bytes only - the
+//!      200 'A' bytes that were DMA'd-into-the-dropped-buffer are gone.
+
+use std::io::ErrorKind;
+use std::time::Duration;
+
+use compio::io::{AsyncReadExt, AsyncWriteExt};
+use compio::net::{TcpListener, TcpStream};
+use futures::FutureExt;
+
+const N1: usize = 200;
+const N2: usize = 56;
+const TOTAL: usize = N1 + N2;
+
+#[compio::test]
+async fn read_future_drop_loses_already_dma_bytes() {
+    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let addr = listener.local_addr().unwrap();
+    let connect = TcpStream::connect(addr);
+    let accept = listener.accept();
+    let (client_res, accept_res) = futures::join!(connect, accept);
+    let mut client = client_res.unwrap();
+    let (mut server, _) = accept_res.unwrap();
+
+    let sender = compio::runtime::spawn(async move {
+        let buf1 = vec![b'A'; N1];
+        client.write_all(buf1).await.0.unwrap();
+        compio::time::sleep(Duration::from_millis(150)).await;
+        let buf2 = vec![b'B'; N2];
+        client.write_all(buf2).await.0.unwrap();
+        // Drop closes the TCP write half so phase 2's read_exact sees EOF
+        // once the cancelled bytes are accounted for.
+    });
+
+    let phase1_buf = vec![0u8; TOTAL];
+    let phase1_timed_out = {
+        let read_fut = server.read_exact(phase1_buf);
+        let timer = compio::time::sleep(Duration::from_millis(50));
+        futures::select! {
+            res = read_fut.fuse() => {
+                let (_, b) = (res.0, res.1);
+                let _ = b;
+                false
+            }
+            () = timer.fuse() => true,
+        }
+    };
+    assert!(
+        phase1_timed_out,
+        "test premise broken: read_exact finished within the 50 ms window; \
+         expected the 50 ms timer to fire while only the first 200 B had arrived",
+    );
+
+    let phase2_buf = vec![0u8; TOTAL];
+    let phase2 = compio::time::timeout(Duration::from_secs(2), server.read_exact(phase2_buf)).await;
+
+    let phase2_inner = phase2.expect(
+        "phase 2 timed out: read_exact hung waiting for bytes. \
+         Either compio became cancel-safe (verdict needs review) or the sender \
+         did not close cleanly (test infrastructure regression).",
+    );
+    let (read_res, buf) = (phase2_inner.0, phase2_inner.1);
+    let err = read_res.expect_err(
+        "phase 2 returned Ok(()): all 256 bytes preserved across the dropped read_exact. \
+         compio cancel semantics changed; re-audit the no-`select!`-over-reads guard \
+         across the message_bus transports before relaxing it.",
+    );
+    assert_eq!(
+        err.kind(),
+        ErrorKind::UnexpectedEof,
+        "phase 2 reported {err:?}; expected UnexpectedEof from the truncated stream",
+    );
+
+    let slice = buf.as_slice();
+    let phase2_b_count = slice.iter().take_while(|&&b| b == b'B').count();
+    assert!(
+        phase2_b_count > 0 && phase2_b_count <= N2,
+        "phase 2 read {phase2_b_count} 'B' bytes (expected 1..={N2}); \
+         the 200 'A' bytes from the dropped read_exact should NOT reappear",
+    );
+    let head_has_a = slice[..phase2_b_count].contains(&b'A');
+    assert!(
+        !head_has_a,
+        "phase 2 saw 'A' bytes that should have been lost with the dropped read_exact",
+    );
+
+    let _ = sender.await;
+}
diff --git a/core/message_bus/tests/common/mod.rs b/core/message_bus/tests/common/mod.rs
index b00fbb310..dc1a8fb51 100644
--- a/core/message_bus/tests/common/mod.rs
+++ b/core/message_bus/tests/common/mod.rs
@@ -27,13 +27,27 @@
 #![allow(dead_code)] // each test binary uses a subset
 
 use iggy_binary_protocol::{Command2, GenericHeader, HEADER_SIZE, Message};
+use message_bus::ConnectionInstaller;
 use message_bus::client_listener::RequestHandler;
-use message_bus::replica_listener::MessageHandler;
-use message_bus::{AcceptedClientFn, AcceptedReplicaFn, IggyMessageBus, installer};
+use message_bus::replica::listener::MessageHandler;
+use message_bus::{
+    AcceptedClientFn, AcceptedQuicClientFn, AcceptedReplicaFn, AcceptedTlsClientFn,
+    AcceptedWsClientFn, AcceptedWssClientFn, ClientConnMeta, ClientTransportKind, IggyMessageBus,
+    fd_transfer, installer,
+};
 use std::cell::Cell;
 use std::net::SocketAddr;
 use std::rc::Rc;
 
+/// Build a stub [`ClientConnMeta`] for tests that don't care about
+/// peer addr / transport details. Uses `127.0.0.1:0` and the given
+/// transport kind. Production install paths would surface the real
+/// `SocketAddr` from the accept call site.
+#[must_use]
+pub fn test_client_meta(client_id: u128, transport: ClientTransportKind) -> ClientConnMeta {
+    ClientConnMeta::new(client_id, "127.0.0.1:0".parse().unwrap(), transport)
+}
+
 /// Loopback address with OS-chosen port.
 #[must_use]
 pub fn loopback() -> SocketAddr {
@@ -83,6 +97,91 @@ pub fn install_clients_locally(
         let seq = counter.get();
         counter.set(seq.wrapping_add(1));
         let client_id = (shard_id << 112) | seq;
-        installer::install_client_stream(&bus, client_id, stream, on_request.clone());
+        let meta = test_client_meta(client_id, ClientTransportKind::Tcp);
+        installer::install_client_stream(&bus, meta, stream, on_request.clone());
+    })
+}
+
+/// Build an [`AcceptedQuicClientFn`] that mints a local client id and
+/// installs the QUIC connection directly on the given bus. Mirror of
+/// [`install_clients_locally`] for the QUIC plane.
+#[must_use]
+pub fn install_quic_clients_locally(
+    bus: Rc<IggyMessageBus>,
+    on_request: RequestHandler,
+) -> AcceptedQuicClientFn {
+    let counter: Rc<Cell<u128>> = Rc::new(Cell::new(1));
+    let shard_id = u128::from(bus.shard_id());
+    Rc::new(move |connection, streams| {
+        let seq = counter.get();
+        counter.set(seq.wrapping_add(1));
+        let client_id = (shard_id << 112) | seq;
+        let meta = test_client_meta(client_id, ClientTransportKind::Quic);
+        installer::install_client_quic_conn(&bus, meta, connection, streams, on_request.clone());
+    })
+}
+
+/// Build an [`AcceptedWsClientFn`] that mints a local client id, dups
+/// the accepted fd (mirroring the production cross-shard fd-ship
+/// path), and hands it to [`ConnectionInstaller::install_client_ws_fd`]
+/// on the given bus. Single-shard tests bypass the inter-shard
+/// channel by dup'ing locally; the install path runs `accept_async`
+/// and `install_client_ws_stream` exactly as the production
+/// owning-shard router would.
+#[must_use]
+pub fn install_ws_clients_locally(
+    bus: Rc<IggyMessageBus>,
+    on_request: RequestHandler,
+) -> AcceptedWsClientFn {
+    let counter: Rc<Cell<u128>> = Rc::new(Cell::new(1));
+    let shard_id = u128::from(bus.shard_id());
+    Rc::new(move |stream| {
+        let seq = counter.get();
+        counter.set(seq.wrapping_add(1));
+        let client_id = (shard_id << 112) | seq;
+        let fd = fd_transfer::dup_fd(&stream).expect("dup_fd");
+        drop(stream);
+        let meta = test_client_meta(client_id, ClientTransportKind::Ws);
+        bus.install_client_ws_fd(fd, meta, on_request.clone());
+    })
+}
+
+/// Build an [`AcceptedTlsClientFn`] that mints a local client id and
+/// installs the accepted TCP-TLS stream directly on the given bus. The
+/// install path drives the rustls handshake on its own task, mirroring
+/// the production shard-0 coordinator.
+#[must_use]
+pub fn install_tls_clients_locally(
+    bus: Rc<IggyMessageBus>,
+    on_request: RequestHandler,
+) -> AcceptedTlsClientFn {
+    let counter: Rc<Cell<u128>> = Rc::new(Cell::new(1));
+    let shard_id = u128::from(bus.shard_id());
+    Rc::new(move |stream, config| {
+        let seq = counter.get();
+        counter.set(seq.wrapping_add(1));
+        let client_id = (shard_id << 112) | seq;
+        let meta = test_client_meta(client_id, ClientTransportKind::TcpTls);
+        installer::install_client_tls_stream(&bus, meta, stream, config, on_request.clone());
+    })
+}
+
+/// Build an [`AcceptedWssClientFn`] that mints a local client id and
+/// installs the accepted WSS stream directly on the given bus. The
+/// install path drives both the rustls handshake and the WS HTTP-Upgrade
+/// inside the transport's `run` body.
+#[must_use]
+pub fn install_wss_clients_locally(
+    bus: Rc<IggyMessageBus>,
+    on_request: RequestHandler,
+) -> AcceptedWssClientFn {
+    let counter: Rc<Cell<u128>> = Rc::new(Cell::new(1));
+    let shard_id = u128::from(bus.shard_id());
+    Rc::new(move |stream, config| {
+        let seq = counter.get();
+        counter.set(seq.wrapping_add(1));
+        let client_id = (shard_id << 112) | seq;
+        let meta = test_client_meta(client_id, ClientTransportKind::Wss);
+        installer::install_client_wss_stream(&bus, meta, stream, config, on_request.clone());
     })
 }
diff --git a/core/message_bus/tests/connection_lost_notify.rs b/core/message_bus/tests/connection_lost_notify.rs
index 8babea3ad..53fae7251 100644
--- a/core/message_bus/tests/connection_lost_notify.rs
+++ b/core/message_bus/tests/connection_lost_notify.rs
@@ -25,7 +25,7 @@ mod common;
 use common::{install_replicas_locally, loopback};
 use message_bus::IggyMessageBus;
 use message_bus::connector::{DEFAULT_RECONNECT_PERIOD, start as start_connector};
-use message_bus::replica_listener::{MessageHandler, bind, run};
+use message_bus::replica::listener::{MessageHandler, bind, run};
 use std::cell::Cell;
 use std::rc::Rc;
 use std::time::Duration;
diff --git a/core/message_bus/tests/directional_connection.rs b/core/message_bus/tests/directional_connection.rs
index ac8a3a7d1..71fa4cdbd 100644
--- a/core/message_bus/tests/directional_connection.rs
+++ b/core/message_bus/tests/directional_connection.rs
@@ -25,7 +25,7 @@ mod common;
 use common::{install_replicas_locally, loopback};
 use message_bus::IggyMessageBus;
 use message_bus::connector::{DEFAULT_RECONNECT_PERIOD, start as start_connector};
-use message_bus::replica_listener::{MessageHandler, bind, run};
+use message_bus::replica::listener::{MessageHandler, bind, run};
 use std::rc::Rc;
 use std::time::Duration;
 
diff --git a/core/message_bus/tests/duplicate_client_id.rs b/core/message_bus/tests/duplicate_client_id.rs
index 91062a8f5..ae5eaca6f 100644
--- a/core/message_bus/tests/duplicate_client_id.rs
+++ b/core/message_bus/tests/duplicate_client_id.rs
@@ -23,13 +23,13 @@
 
 mod common;
 
-use common::{header_only, loopback};
+use common::{header_only, loopback, test_client_meta};
 use compio::net::{TcpListener, TcpStream};
 use iggy_binary_protocol::Command2;
-use message_bus::IggyMessageBus;
 use message_bus::client_listener::RequestHandler;
 use message_bus::framing::write_message;
 use message_bus::installer::install_client_stream;
+use message_bus::{ClientTransportKind, IggyMessageBus};
 use std::cell::Cell;
 use std::rc::Rc;
 use std::time::Duration;
@@ -55,14 +55,24 @@ async fn duplicate_install_drops_second_fd_without_panic() {
 
     let client_id: u128 = 0x42;
     let first = loopback_pair().await;
-    install_client_stream(&bus, client_id, first, on_request.clone());
+    install_client_stream(
+        &bus,
+        test_client_meta(client_id, ClientTransportKind::Tcp),
+        first,
+        on_request.clone(),
+    );
     assert!(bus.clients().contains(client_id));
     assert_eq!(bus.clients().len(), 1);
 
     // Collision: same client_id, second stream. Must not panic, must not
     // double-register.
     let second = loopback_pair().await;
-    install_client_stream(&bus, client_id, second, on_request);
+    install_client_stream(
+        &bus,
+        test_client_meta(client_id, ClientTransportKind::Tcp),
+        second,
+        on_request,
+    );
     assert!(bus.clients().contains(client_id));
     assert_eq!(
         bus.clients().len(),
@@ -113,14 +123,24 @@ async fn orphan_reader_from_losing_install_does_not_invoke_on_request() {
 
     // First install wins the slot.
     let (first_local, _first_peer) = loopback_peer_pair().await;
-    install_client_stream(&bus, client_id, first_local, on_request.clone());
+    install_client_stream(
+        &bus,
+        test_client_meta(client_id, ClientTransportKind::Tcp),
+        first_local,
+        on_request.clone(),
+    );
     assert!(bus.clients().contains(client_id));
 
     // Second install loses the `AlreadyRegistered` race. Its spawned
     // reader is holding `second_local`'s read half; we drive that reader
     // by writing a `Request` frame from `second_peer`.
     let (second_local, mut second_peer) = loopback_peer_pair().await;
-    install_client_stream(&bus, client_id, second_local, on_request);
+    install_client_stream(
+        &bus,
+        test_client_meta(client_id, ClientTransportKind::Tcp),
+        second_local,
+        on_request,
+    );
 
     // Push a valid Request frame down the orphan reader's wire. Before
     // the fix, the orphan would call `on_request(client_id, msg)` and
@@ -175,7 +195,12 @@ async fn losing_install_drains_well_before_close_peer_timeout() {
 
     let client_id: u128 = 0x00c0_ffee;
     let (first_local, _first_peer) = loopback_peer_pair().await;
-    install_client_stream(&bus, client_id, first_local, on_request.clone());
+    install_client_stream(
+        &bus,
+        test_client_meta(client_id, ClientTransportKind::Tcp),
+        first_local,
+        on_request.clone(),
+    );
     assert!(bus.clients().contains(client_id));
 
     // Race: second install loses the slot. The peer never sends EOF,
@@ -183,7 +208,12 @@ async fn losing_install_drains_well_before_close_peer_timeout() {
     // per-connection shutdown trigger inside drain_rejected_registration.
     let (second_local, _second_peer) = loopback_peer_pair().await;
     let start = Instant::now();
-    install_client_stream(&bus, client_id, second_local, on_request);
+    install_client_stream(
+        &bus,
+        test_client_meta(client_id, ClientTransportKind::Tcp),
+        second_local,
+        on_request,
+    );
     // Yield once so the spawned drain task gets to poll. Even with the
     // bus's default close_peer_timeout (5 s), we expect the drain to
     // resolve in low double-digit milliseconds.
diff --git a/core/message_bus/tests/graceful_shutdown.rs b/core/message_bus/tests/graceful_shutdown.rs
index f50a5a1f8..18038039c 100644
--- a/core/message_bus/tests/graceful_shutdown.rs
+++ b/core/message_bus/tests/graceful_shutdown.rs
@@ -22,7 +22,8 @@ mod common;
 
 use common::{install_clients_locally, loopback};
 use compio::net::TcpStream;
-use message_bus::client_listener::{RequestHandler, bind, run};
+use message_bus::client_listener::RequestHandler;
+use message_bus::client_listener::tcp::{bind, run};
 use message_bus::{IggyMessageBus, MessageBus, SendError};
 use std::rc::Rc;
 use std::time::Duration;
diff --git a/core/message_bus/tests/head_of_line.rs b/core/message_bus/tests/head_of_line.rs
index 57c644971..44d20f22a 100644
--- a/core/message_bus/tests/head_of_line.rs
+++ b/core/message_bus/tests/head_of_line.rs
@@ -29,7 +29,7 @@ use common::{header_only, install_replicas_locally, loopback};
 use compio::net::TcpListener;
 use iggy_binary_protocol::Command2;
 use message_bus::connector::{DEFAULT_RECONNECT_PERIOD, start as start_connector};
-use message_bus::replica_listener::{MessageHandler, bind, run};
+use message_bus::replica::listener::{MessageHandler, bind, run};
 use message_bus::{IggyMessageBus, MessageBus, SendError};
 use std::cell::Cell;
 use std::rc::Rc;
diff --git a/core/message_bus/tests/installer_panic_cleanup.rs b/core/message_bus/tests/installer_panic_cleanup.rs
new file mode 100644
index 000000000..76dd45896
--- /dev/null
+++ b/core/message_bus/tests/installer_panic_cleanup.rs
@@ -0,0 +1,232 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Panic-cleanup test for the connection installer.
+//!
+//! `compio::runtime::spawn` wraps the future with
+//! `AssertUnwindSafe(future).catch_unwind()` (compio-runtime-0.11.0
+//! `runtime/mod.rs:202-203`), so a panicking transport returns silently
+//! to the runtime. Without a scopeguard, the post-loop cleanup that
+//! evicts the registry slot and fires `notify_connection_lost` would
+//! never run, leaking the slot and leaving the bus convinced the peer
+//! is still up. These tests force a panic inside `conn.run` and assert
+//! the cleanup ran on the unwind path.
+
+mod common;
+
+use common::test_client_meta;
+use message_bus::client_listener::RequestHandler;
+use message_bus::installer::{install_client_conn, install_replica_conn};
+use message_bus::replica::listener::MessageHandler;
+use message_bus::transports::{ActorContext, TransportConn};
+use message_bus::{ClientTransportKind, IggyMessageBus};
+use std::cell::Cell;
+use std::rc::Rc;
+use std::time::{Duration, Instant};
+
+const REPLICA_ID: u8 = 7;
+const CLIENT_ID: u128 = 0x4242_4242_4242_4242_4242_4242_4242_4242;
+
+/// Transport whose `run` body panics on first poll. Models a transport
+/// that explodes after a partial decode, an assertion failure, or any
+/// other unexpected branch the dispatcher does not catch.
+struct PanickingConn;
+
+impl TransportConn for PanickingConn {
+    #[allow(clippy::future_not_send)]
+    async fn run(self, _ctx: ActorContext) {
+        panic!("intentional panic from PanickingConn::run");
+    }
+}
+
+/// Transport whose `run` body parks until shutdown. Lets the test prove
+/// a fresh install on the cleaned-up registry slot succeeds and stays
+/// registered until tear-down.
+struct ParkingConn;
+
+impl TransportConn for ParkingConn {
+    #[allow(clippy::future_not_send)]
+    async fn run(self, ctx: ActorContext) {
+        ctx.shutdown.wait().await;
+    }
+}
+
+#[allow(clippy::future_not_send)]
+async fn wait_until<F: Fn() -> bool>(deadline: Duration, label: &str, predicate: F) {
+    let until = Instant::now() + deadline;
+    while !predicate() {
+        assert!(
+            Instant::now() < until,
+            "predicate '{label}' did not hold within {deadline:?}",
+        );
+        compio::time::sleep(Duration::from_millis(5)).await;
+    }
+}
+
+#[compio::test]
+#[allow(clippy::future_not_send)]
+async fn replica_panic_evicts_registry_and_notifies_loss() {
+    let bus = Rc::new(IggyMessageBus::new(0));
+
+    let lost_count: Rc<Cell<u32>> = Rc::new(Cell::new(0));
+    let lost_peer: Rc<Cell<Option<u8>>> = Rc::new(Cell::new(None));
+    {
+        let lost_count = Rc::clone(&lost_count);
+        let lost_peer = Rc::clone(&lost_peer);
+        bus.set_connection_lost_fn(Rc::new(move |peer_id| {
+            lost_count.set(lost_count.get() + 1);
+            lost_peer.set(Some(peer_id));
+        }));
+    }
+
+    let on_message: MessageHandler = Rc::new(|_, _| {});
+
+    install_replica_conn(&bus, REPLICA_ID, PanickingConn, on_message.clone());
+
+    {
+        let bus = Rc::clone(&bus);
+        wait_until(
+            Duration::from_secs(2),
+            "panicked replica slot evicted",
+            move || !bus.replicas().contains(REPLICA_ID),
+        )
+        .await;
+    }
+    {
+        let lost_count = Rc::clone(&lost_count);
+        wait_until(
+            Duration::from_secs(2),
+            "notify_connection_lost fired",
+            move || lost_count.get() >= 1,
+        )
+        .await;
+    }
+    assert_eq!(
+        lost_count.get(),
+        1,
+        "notify_connection_lost must fire exactly once on panic",
+    );
+    assert_eq!(lost_peer.get(), Some(REPLICA_ID));
+
+    install_replica_conn(&bus, REPLICA_ID, ParkingConn, on_message);
+    {
+        let bus = Rc::clone(&bus);
+        wait_until(
+            Duration::from_secs(2),
+            "fresh install registers on cleaned slot",
+            move || bus.replicas().contains(REPLICA_ID),
+        )
+        .await;
+    }
+
+    let outcome = bus.shutdown(Duration::from_secs(2)).await;
+    assert_eq!(
+        outcome.force, 0,
+        "ParkingConn must wake on shutdown without forced cancel",
+    );
+}
+
+#[compio::test]
+#[allow(clippy::future_not_send)]
+async fn client_panic_evicts_registry_slot() {
+    let bus = Rc::new(IggyMessageBus::new(0));
+    let on_request: RequestHandler = Rc::new(|_, _| {});
+
+    install_client_conn(
+        &bus,
+        test_client_meta(CLIENT_ID, ClientTransportKind::Tcp),
+        PanickingConn,
+        on_request.clone(),
+    );
+    {
+        let bus = Rc::clone(&bus);
+        wait_until(
+            Duration::from_secs(2),
+            "panicked client slot evicted",
+            move || !bus.clients().contains(CLIENT_ID),
+        )
+        .await;
+    }
+
+    install_client_conn(
+        &bus,
+        test_client_meta(CLIENT_ID, ClientTransportKind::Tcp),
+        ParkingConn,
+        on_request,
+    );
+    {
+        let bus = Rc::clone(&bus);
+        wait_until(
+            Duration::from_secs(2),
+            "fresh install registers on cleaned slot",
+            move || bus.clients().contains(CLIENT_ID),
+        )
+        .await;
+    }
+
+    let outcome = bus.shutdown(Duration::from_secs(2)).await;
+    assert_eq!(
+        outcome.force, 0,
+        "ParkingConn must wake on shutdown without forced cancel",
+    );
+}
+
+#[compio::test]
+#[allow(clippy::future_not_send)]
+async fn replica_panic_only_does_not_double_notify_after_dispatch_loop() {
+    // Sanity: a panicking transport plus the dispatch task's separate
+    // post-loop cleanup must still produce exactly one
+    // `notify_connection_lost`. Pre-fix, the scopeguard wrapping the
+    // transport's run path was missing entirely on the panic branch, so
+    // only the dispatch task fired - or in the panic-after-insert race,
+    // neither fired. With the fix, the shared `notified` cell still
+    // dedups them.
+    let bus = Rc::new(IggyMessageBus::new(0));
+
+    let lost_count: Rc<Cell<u32>> = Rc::new(Cell::new(0));
+    {
+        let lost_count = Rc::clone(&lost_count);
+        bus.set_connection_lost_fn(Rc::new(move |_peer_id| {
+            lost_count.set(lost_count.get() + 1);
+        }));
+    }
+
+    let on_message: MessageHandler = Rc::new(|_, _| {});
+    install_replica_conn(&bus, REPLICA_ID, PanickingConn, on_message);
+
+    {
+        let lost_count = Rc::clone(&lost_count);
+        wait_until(
+            Duration::from_secs(2),
+            "first notify_connection_lost",
+            move || lost_count.get() >= 1,
+        )
+        .await;
+    }
+
+    // Both halves should have observed the abnormal close and run their
+    // post-loop cleanup by now; give the second half a grace window so
+    // any extra notify shows up before we assert.
+    compio::time::sleep(Duration::from_millis(150)).await;
+    assert_eq!(
+        lost_count.get(),
+        1,
+        "exactly one notify_connection_lost per panicked install",
+    );
+
+    let _ = bus.shutdown(Duration::from_secs(2)).await;
+}
diff --git a/core/message_bus/tests/quic_client_roundtrip.rs b/core/message_bus/tests/quic_client_roundtrip.rs
new file mode 100644
index 000000000..ccf643e5e
--- /dev/null
+++ b/core/message_bus/tests/quic_client_roundtrip.rs
@@ -0,0 +1,135 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! End-to-end: a real QUIC client connects to the consensus QUIC client
+//! listener, sends a Request, the handler echoes a Reply back via
+//! `bus.send_to_client`, the client reads the Reply.
+
+mod common;
+
+use async_channel::bounded;
+use common::{header_only, install_quic_clients_locally, loopback};
+use compio_quic::{ClientBuilder, Endpoint};
+use iggy_binary_protocol::Command2;
+use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
+use iggy_binary_protocol::consensus::iobuf::Frozen;
+use iggy_binary_protocol::{GenericHeader, Message};
+use message_bus::client_listener::RequestHandler;
+use message_bus::client_listener::quic::{bind, run};
+use message_bus::framing;
+use message_bus::transports::quic::{QuicTransportConn, server_config_with_cert};
+use message_bus::transports::{ActorContext, TransportConn};
+use message_bus::{IggyMessageBus, MessageBus, Shutdown};
+use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
+use std::rc::Rc;
+use std::time::Duration;
+
+fn install_crypto_provider() {
+    // Idempotent across same-process retries.
+    let _ = rustls::crypto::ring::default_provider().install_default();
+}
+
+fn self_signed() -> (CertificateDer<'static>, PrivateKeyDer<'static>) {
+    let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).expect("rcgen");
+    let cert_der = CertificateDer::from(cert.cert);
+    let key_der: PrivateKeyDer<'static> =
+        PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()).into();
+    (cert_der, key_der)
+}
+
+#[allow(clippy::future_not_send)]
+async fn client_endpoint(server_cert: CertificateDer<'static>) -> Endpoint {
+    let builder = ClientBuilder::new_with_empty_roots()
+        .with_custom_certificate(server_cert)
+        .expect("trust cert")
+        .with_no_crls();
+    builder.bind("127.0.0.1:0").await.expect("client bind")
+}
+
+#[compio::test]
+async fn request_reply_round_trip() {
+    install_crypto_provider();
+
+    let bus = Rc::new(IggyMessageBus::new(7));
+
+    let bus_for_handler = bus.clone();
+    let on_request: RequestHandler = Rc::new(move |client_id, msg| {
+        assert_eq!(msg.header().command, Command2::Request);
+        let bus = bus_for_handler.clone();
+        compio::runtime::spawn(async move {
+            let reply = header_only(Command2::Reply, 42, 0);
+            bus.send_to_client(client_id, reply.into_frozen())
+                .await
+                .expect("send_to_client should succeed");
+        })
+        .detach();
+    });
+
+    let (cert, key) = self_signed();
+    let server_cfg = server_config_with_cert(vec![cert.clone()], key).expect("server config");
+    let (endpoint, server_addr) = bind(loopback(), server_cfg).await.expect("bind");
+
+    let token = bus.token();
+    let on_accepted = install_quic_clients_locally(bus.clone(), on_request);
+    let accept_handle = compio::runtime::spawn(async move {
+        run(endpoint, token, on_accepted).await;
+    });
+    bus.track_background(accept_handle);
+
+    // Dial as a real QUIC client.
+    let client = client_endpoint(cert).await;
+    let connecting = client
+        .connect(server_addr, "localhost", None)
+        .expect("connect");
+    let connection = connecting.await.expect("client handshake");
+    let (send, recv) = connection.open_bi_wait().await.expect("open_bi");
+
+    // Drive the client side of the QUIC conn through `TransportConn::run`.
+    let (out_tx, out_rx) = bounded::<Frozen<MESSAGE_ALIGN>>(8);
+    let (in_tx, in_rx) = bounded::<Message<GenericHeader>>(8);
+    let (client_shutdown, client_token) = Shutdown::new();
+    let ctx = ActorContext {
+        in_tx,
+        rx: out_rx,
+        shutdown: client_token,
+        max_batch: 16,
+        max_message_size: framing::MAX_MESSAGE_SIZE,
+        label: "test-client",
+        peer: "test-client".to_owned(),
+    };
+    let conn = QuicTransportConn::new(connection, (send, recv));
+    let client_handle = compio::runtime::spawn(async move { conn.run(ctx).await });
+
+    let request = header_only(Command2::Request, 42, 0).into_frozen();
+    out_tx.send(request).await.expect("client send");
+
+    let reply = compio::time::timeout(Duration::from_secs(5), in_rx.recv())
+        .await
+        .expect("client must receive reply within 5 s")
+        .expect("reply frame");
+    assert_eq!(reply.header().command, Command2::Reply);
+    assert_eq!(reply.header().cluster, 42);
+
+    client_shutdown.trigger();
+    let _ = client_handle.await;
+
+    let outcome = bus.shutdown(Duration::from_secs(2)).await;
+    assert_eq!(
+        outcome.force, 0,
+        "graceful shutdown should not force-cancel"
+    );
+}
diff --git a/core/message_bus/tests/reconnect.rs b/core/message_bus/tests/reconnect.rs
index 0eef98e94..6e19e9ddc 100644
--- a/core/message_bus/tests/reconnect.rs
+++ b/core/message_bus/tests/reconnect.rs
@@ -24,7 +24,7 @@ mod common;
 use common::{install_replicas_locally, loopback};
 use message_bus::IggyMessageBus;
 use message_bus::connector::start as start_connector;
-use message_bus::replica_listener::{MessageHandler, bind, run};
+use message_bus::replica::listener::{MessageHandler, bind, run};
 use std::rc::Rc;
 use std::time::Duration;
 
diff --git a/core/message_bus/tests/reconnect_skip_connected.rs b/core/message_bus/tests/reconnect_skip_connected.rs
index 5d9a77b3b..73abcbbc8 100644
--- a/core/message_bus/tests/reconnect_skip_connected.rs
+++ b/core/message_bus/tests/reconnect_skip_connected.rs
@@ -25,7 +25,7 @@ mod common;
 use common::{install_replicas_locally, loopback};
 use message_bus::IggyMessageBus;
 use message_bus::connector::start as start_connector;
-use message_bus::replica_listener::{MessageHandler, bind, run};
+use message_bus::replica::listener::{MessageHandler, bind, run};
 use std::rc::Rc;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicU32, Ordering};
diff --git a/core/message_bus/tests/replica_roundtrip.rs b/core/message_bus/tests/replica_roundtrip.rs
index e557b501a..b59a9fa27 100644
--- a/core/message_bus/tests/replica_roundtrip.rs
+++ b/core/message_bus/tests/replica_roundtrip.rs
@@ -26,7 +26,7 @@ use async_channel::Receiver;
 use common::{header_only, install_replicas_locally, loopback};
 use iggy_binary_protocol::Command2;
 use message_bus::connector::{DEFAULT_RECONNECT_PERIOD, start as start_connector};
-use message_bus::replica_listener::{MessageHandler, bind, run};
+use message_bus::replica::listener::{MessageHandler, bind, run};
 use message_bus::{IggyMessageBus, MessageBus};
 use std::rc::Rc;
 use std::time::Duration;
diff --git a/core/message_bus/tests/shard_zero_gating.rs b/core/message_bus/tests/shard_zero_gating.rs
index a65fd84d7..01628a601 100644
--- a/core/message_bus/tests/shard_zero_gating.rs
+++ b/core/message_bus/tests/shard_zero_gating.rs
@@ -15,18 +15,24 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! `replica_io::start_on_shard_zero` only wires TCP I/O when the bus runs
+//! `replica::io::start_on_shard_zero` only wires TCP I/O when the bus runs
 //! on shard 0. Non-zero shards keep a working bus instance but never bind
 //! a listener, never dial peers, and never register background tasks.
 
 mod common;
 
-use common::{install_clients_locally, install_replicas_locally, loopback};
-use message_bus::IggyMessageBus;
+use common::{
+    install_clients_locally, install_quic_clients_locally, install_replicas_locally,
+    install_tls_clients_locally, install_ws_clients_locally, install_wss_clients_locally, loopback,
+};
+use iggy_common::IggyError;
 use message_bus::client_listener::RequestHandler;
 use message_bus::connector::DEFAULT_RECONNECT_PERIOD;
-use message_bus::replica_io::start_on_shard_zero;
-use message_bus::replica_listener::{MessageHandler, bind, run};
+use message_bus::replica::io::{QuicServerCredentials, start_on_shard_zero};
+use message_bus::replica::listener::{MessageHandler, bind, run};
+use message_bus::transports::tls::self_signed_for_loopback;
+use message_bus::{IggyMessageBus, TlsServerCredentials};
+use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
 use std::net::SocketAddr;
 use std::rc::Rc;
 use std::time::Duration;
@@ -69,12 +75,23 @@ async fn shard_zero_binds_listener_and_starts_connector() {
         &bus_zero,
         loopback(),
         loopback(),
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
         CLUSTER,
         0,
         2,
         vec![(1u8, peer_addr)],
         accepted_replica,
         accepted_client,
+        None,
+        None,
+        None,
+        None,
         DEFAULT_RECONNECT_PERIOD,
     )
     .await
@@ -120,12 +137,23 @@ async fn non_zero_shard_skips_io() {
         &bus_one,
         loopback(),
         loopback(),
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
         CLUSTER,
         1,
         2,
         vec![(0u8, dead_peer)],
         accepted_replica,
         accepted_client,
+        None,
+        None,
+        None,
+        None,
         DEFAULT_RECONNECT_PERIOD,
     )
     .await
@@ -150,3 +178,182 @@ async fn non_zero_shard_skips_io() {
         "helper must not register any background tasks on non-zero shards"
     );
 }
+
+fn self_signed() -> (CertificateDer<'static>, PrivateKeyDer<'static>) {
+    let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).expect("rcgen");
+    let cert_der = CertificateDer::from(cert.cert);
+    let key_der: PrivateKeyDer<'static> =
+        PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()).into();
+    (cert_der, key_der)
+}
+
+#[compio::test]
+async fn shard_zero_binds_all_six_planes_when_configured() {
+    // Idempotent across same-process retries.
+    let _ = rustls::crypto::ring::default_provider().install_default();
+
+    let bus_zero = Rc::new(IggyMessageBus::new(0));
+    let on_message: MessageHandler = Rc::new(|_, _| {});
+    let on_request: RequestHandler = Rc::new(|_, _| {});
+    let accepted_replica = install_replicas_locally(bus_zero.clone(), on_message.clone());
+    let accepted_client = install_clients_locally(bus_zero.clone(), on_request.clone());
+    let accepted_ws = install_ws_clients_locally(bus_zero.clone(), on_request.clone());
+    let accepted_quic = install_quic_clients_locally(bus_zero.clone(), on_request.clone());
+    let accepted_tls = install_tls_clients_locally(bus_zero.clone(), on_request.clone());
+    let accepted_wss = install_wss_clients_locally(bus_zero.clone(), on_request);
+
+    let (cert, key) = self_signed();
+    let quic_creds = QuicServerCredentials {
+        cert_chain: vec![cert],
+        key_der: key,
+    };
+    let tls_creds = self_signed_for_loopback();
+    let wss_creds = self_signed_for_loopback();
+
+    let bound = start_on_shard_zero(
+        &bus_zero,
+        loopback(),
+        loopback(),
+        Some(loopback()),
+        Some(loopback()),
+        Some(quic_creds),
+        Some(loopback()),
+        Some(tls_creds),
+        Some(loopback()),
+        Some(wss_creds),
+        CLUSTER,
+        0,
+        1,
+        vec![],
+        accepted_replica,
+        accepted_client,
+        Some(accepted_ws),
+        Some(accepted_quic),
+        Some(accepted_tls),
+        Some(accepted_wss),
+        DEFAULT_RECONNECT_PERIOD,
+    )
+    .await
+    .expect("start_on_shard_zero must succeed");
+
+    let bound = bound.expect("shard 0 must return bound listeners");
+    assert_ne!(bound.replica.port(), 0, "replica must bind a port");
+    assert_ne!(bound.client.port(), 0, "client must bind a port");
+    let ws = bound.ws.expect("ws plane must be bound");
+    let quic = bound.quic.expect("quic plane must be bound");
+    let tcp_tls = bound.tcp_tls.expect("tcp_tls plane must be bound");
+    let wss = bound.wss.expect("wss plane must be bound");
+    assert_ne!(ws.port(), 0, "ws must bind a port");
+    assert_ne!(quic.port(), 0, "quic must bind a port");
+    assert_ne!(tcp_tls.port(), 0, "tcp_tls must bind a port");
+    assert_ne!(wss.port(), 0, "wss must bind a port");
+
+    // Ports must be pairwise distinct on the TCP-family slots.
+    let tcp_ports = [
+        bound.replica.port(),
+        bound.client.port(),
+        ws.port(),
+        tcp_tls.port(),
+        wss.port(),
+    ];
+    for i in 0..tcp_ports.len() {
+        for j in (i + 1)..tcp_ports.len() {
+            assert_ne!(
+                tcp_ports[i], tcp_ports[j],
+                "TCP-family listeners must use distinct ports",
+            );
+        }
+    }
+
+    bus_zero.shutdown(Duration::from_secs(2)).await;
+}
+
+#[compio::test]
+async fn tcp_tls_listen_addr_without_credentials_rejected() {
+    let _ = rustls::crypto::ring::default_provider().install_default();
+
+    let bus_zero = Rc::new(IggyMessageBus::new(0));
+    let on_message: MessageHandler = Rc::new(|_, _| {});
+    let on_request: RequestHandler = Rc::new(|_, _| {});
+    let accepted_replica = install_replicas_locally(bus_zero.clone(), on_message);
+    let accepted_client = install_clients_locally(bus_zero.clone(), on_request.clone());
+    let accepted_tls = install_tls_clients_locally(bus_zero.clone(), on_request);
+
+    let err = start_on_shard_zero(
+        &bus_zero,
+        loopback(),
+        loopback(),
+        None,
+        None,
+        None,
+        Some(loopback()),
+        None,
+        None,
+        None,
+        CLUSTER,
+        0,
+        1,
+        vec![],
+        accepted_replica,
+        accepted_client,
+        None,
+        None,
+        Some(accepted_tls),
+        None,
+        DEFAULT_RECONNECT_PERIOD,
+    )
+    .await
+    .expect_err("partial TCP-TLS trio must reject");
+    assert!(matches!(err, IggyError::InvalidConfiguration));
+
+    bus_zero.shutdown(Duration::from_secs(2)).await;
+}
+
+#[compio::test]
+async fn wss_listen_addr_without_credentials_rejected() {
+    let _ = rustls::crypto::ring::default_provider().install_default();
+
+    let bus_zero = Rc::new(IggyMessageBus::new(0));
+    let on_message: MessageHandler = Rc::new(|_, _| {});
+    let on_request: RequestHandler = Rc::new(|_, _| {});
+    let accepted_replica = install_replicas_locally(bus_zero.clone(), on_message);
+    let accepted_client = install_clients_locally(bus_zero.clone(), on_request.clone());
+    let accepted_wss = install_wss_clients_locally(bus_zero.clone(), on_request);
+
+    let err = start_on_shard_zero(
+        &bus_zero,
+        loopback(),
+        loopback(),
+        None,
+        None,
+        None,
+        None,
+        None,
+        Some(loopback()),
+        None,
+        CLUSTER,
+        0,
+        1,
+        vec![],
+        accepted_replica,
+        accepted_client,
+        None,
+        None,
+        None,
+        Some(accepted_wss),
+        DEFAULT_RECONNECT_PERIOD,
+    )
+    .await
+    .expect_err("partial WSS trio must reject");
+    assert!(matches!(err, IggyError::InvalidConfiguration));
+
+    bus_zero.shutdown(Duration::from_secs(2)).await;
+}
+
+/// Smoke test for `TlsServerCredentials` re-export at the crate root —
+/// keeps a real callee for the type alias so it doesn't decay into a
+/// dead re-export.
+#[allow(dead_code)]
+fn _credentials_type_is_reexported() -> TlsServerCredentials {
+    self_signed_for_loopback()
+}
diff --git a/core/message_bus/tests/tcp_client_roundtrip.rs b/core/message_bus/tests/tcp_client_roundtrip.rs
index 76002e287..6c8cd5985 100644
--- a/core/message_bus/tests/tcp_client_roundtrip.rs
+++ b/core/message_bus/tests/tcp_client_roundtrip.rs
@@ -24,7 +24,8 @@ mod common;
 use common::{header_only, install_clients_locally, loopback};
 use compio::net::TcpStream;
 use iggy_binary_protocol::Command2;
-use message_bus::client_listener::{RequestHandler, bind, run};
+use message_bus::client_listener::RequestHandler;
+use message_bus::client_listener::tcp::{bind, run};
 use message_bus::framing;
 use message_bus::{IggyMessageBus, MessageBus};
 use std::rc::Rc;
diff --git a/core/message_bus/tests/tcp_tls_client_listener.rs b/core/message_bus/tests/tcp_tls_client_listener.rs
new file mode 100644
index 000000000..d43218008
--- /dev/null
+++ b/core/message_bus/tests/tcp_tls_client_listener.rs
@@ -0,0 +1,127 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+//! End-to-end: a real rustls client connects to the consensus TCP-TLS
+//! client listener on shard 0; the listener's callback hands the raw
+//! stream + shared `Arc<rustls::ServerConfig>` to
+//! `install_client_tls_stream`, which drives the rustls handshake on its
+//! own task before installing reader / writer tasks. The handler echoes
+//! a Reply back via `bus.send_to_client`, the client reads the Reply.
+
+mod common;
+
+use async_channel::bounded;
+use common::{header_only, install_tls_clients_locally, loopback};
+use compio::net::TcpStream;
+use iggy_binary_protocol::Command2;
+use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
+use iggy_binary_protocol::consensus::iobuf::Frozen;
+use iggy_binary_protocol::{GenericHeader, Message};
+use message_bus::client_listener::RequestHandler;
+use message_bus::client_listener::tcp_tls::{bind, run};
+use message_bus::transports::tcp_tls::TcpTlsTransportConn;
+use message_bus::transports::tls::{install_default_crypto_provider, self_signed_for_loopback};
+use message_bus::transports::{ActorContext, TransportConn};
+use message_bus::{IggyMessageBus, MessageBus, Shutdown, framing};
+use rustls::RootCertStore;
+use rustls::pki_types::ServerName;
+use std::rc::Rc;
+use std::sync::Arc;
+use std::time::Duration;
+
+#[compio::test]
+async fn tcp_tls_client_listener_accepts_and_round_trips() {
+    install_default_crypto_provider();
+
+    let bus = Rc::new(IggyMessageBus::new(0));
+
+    // Handler echoes a Reply back to the originating client_id via the
+    // bus's send_to_client surface — same path a real dispatcher would
+    // take. Spawned because the handler signature is synchronous.
+    let bus_for_handler = Rc::clone(&bus);
+    let on_request: RequestHandler = Rc::new(move |client_id, msg| {
+        assert_eq!(msg.header().command, Command2::Request);
+        let bus = Rc::clone(&bus_for_handler);
+        compio::runtime::spawn(async move {
+            let reply = header_only(Command2::Reply, 42, 0).into_frozen();
+            bus.send_to_client(client_id, reply)
+                .await
+                .expect("server send_to_client");
+        })
+        .detach();
+    });
+
+    let creds = self_signed_for_loopback();
+    let cert_chain = creds.cert_chain.clone();
+    let (listener, server_cfg, server_addr) =
+        bind(loopback(), creds).await.expect("tls listener bind");
+    let token = bus.token();
+    let on_accepted = install_tls_clients_locally(Rc::clone(&bus), on_request);
+    let accept_handle = compio::runtime::spawn(async move {
+        run(listener, server_cfg, token, on_accepted).await;
+    });
+    bus.track_background(accept_handle);
+
+    // Client side: dial plaintext TCP, then drive the rustls client
+    // through the same `TransportConn::run` shape.
+    let mut roots = RootCertStore::empty();
+    for cert in cert_chain {
+        roots.add(cert).expect("trust self-signed cert");
+    }
+    let client_cfg: Arc<rustls::ClientConfig> = Arc::new(
+        rustls::ClientConfig::builder()
+            .with_root_certificates(Arc::new(roots))
+            .with_no_client_auth(),
+    );
+    let server_name: ServerName<'static> = "localhost".try_into().expect("server name");
+
+    let client_tcp = TcpStream::connect(server_addr).await.expect("client dial");
+    let conn = TcpTlsTransportConn::new_client(client_tcp, client_cfg, server_name);
+
+    let (out_tx, out_rx) = bounded::<Frozen<MESSAGE_ALIGN>>(8);
+    let (in_tx, in_rx) = bounded::<Message<GenericHeader>>(8);
+    let (client_shutdown, client_token) = Shutdown::new();
+    let ctx = ActorContext {
+        in_tx,
+        rx: out_rx,
+        shutdown: client_token,
+        max_batch: 16,
+        max_message_size: framing::MAX_MESSAGE_SIZE,
+        label: "test-client",
+        peer: "test-client".to_owned(),
+    };
+    let client_handle = compio::runtime::spawn(async move { conn.run(ctx).await });
+
+    let request = header_only(Command2::Request, 42, 0).into_frozen();
+    out_tx.send(request).await.expect("client send");
+
+    let reply = compio::time::timeout(Duration::from_secs(5), in_rx.recv())
+        .await
+        .expect("client must receive reply within 5 s")
+        .expect("reply frame");
+    assert_eq!(reply.header().command, Command2::Reply);
+    assert_eq!(reply.header().cluster, 42);
+
+    client_shutdown.trigger();
+    let _ = compio::time::timeout(Duration::from_secs(5), client_handle).await;
+
+    let outcome = bus.shutdown(Duration::from_secs(5)).await;
+    assert_eq!(
+        outcome.force, 0,
+        "graceful shutdown should not force-cancel"
+    );
+}
diff --git a/core/message_bus/tests/tcp_tls_client_roundtrip.rs b/core/message_bus/tests/tcp_tls_client_roundtrip.rs
new file mode 100644
index 000000000..0d9c4b5b9
--- /dev/null
+++ b/core/message_bus/tests/tcp_tls_client_roundtrip.rs
@@ -0,0 +1,159 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+//! End-to-end bootstrap: `replica::io::start_on_shard_zero` binds the
+//! TCP-TLS plane (alongside replica + client TCP listeners) and the
+//! caller dials the listener address surfaced via
+//! [`message_bus::replica::io::BoundPlanes::tcp_tls`]. Mirrors the
+//! `tcp_tls_client_listener.rs` listener-level test one level up the
+//! stack: the validator, paired-presence trio, and `BoundPlanes`
+//! growth all participate.
+
+mod common;
+
+use async_channel::bounded;
+use common::{
+    header_only, install_clients_locally, install_replicas_locally, install_tls_clients_locally,
+    loopback,
+};
+use compio::net::TcpStream;
+use iggy_binary_protocol::Command2;
+use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
+use iggy_binary_protocol::consensus::iobuf::Frozen;
+use iggy_binary_protocol::{GenericHeader, Message};
+use message_bus::client_listener::RequestHandler;
+use message_bus::connector::DEFAULT_RECONNECT_PERIOD;
+use message_bus::replica::io::start_on_shard_zero;
+use message_bus::replica::listener::MessageHandler;
+use message_bus::transports::tcp_tls::TcpTlsTransportConn;
+use message_bus::transports::tls::{install_default_crypto_provider, self_signed_for_loopback};
+use message_bus::transports::{ActorContext, TransportConn};
+use message_bus::{IggyMessageBus, MessageBus, Shutdown, framing};
+use rustls::RootCertStore;
+use rustls::pki_types::ServerName;
+use std::rc::Rc;
+use std::sync::Arc;
+use std::time::Duration;
+
+const CLUSTER: u128 = 0xC0DE;
+
+#[compio::test]
+async fn start_on_shard_zero_tcp_tls_round_trip() {
+    install_default_crypto_provider();
+
+    let bus = Rc::new(IggyMessageBus::new(0));
+
+    let on_message: MessageHandler = Rc::new(|_, _| {});
+    let bus_for_handler = Rc::clone(&bus);
+    let on_request: RequestHandler = Rc::new(move |client_id, msg| {
+        assert_eq!(msg.header().command, Command2::Request);
+        let bus = Rc::clone(&bus_for_handler);
+        compio::runtime::spawn(async move {
+            let reply = header_only(Command2::Reply, CLUSTER, 0).into_frozen();
+            bus.send_to_client(client_id, reply)
+                .await
+                .expect("server send_to_client");
+        })
+        .detach();
+    });
+
+    let accepted_replica = install_replicas_locally(Rc::clone(&bus), on_message);
+    let accepted_client = install_clients_locally(Rc::clone(&bus), Rc::clone(&on_request));
+    let accepted_tls = install_tls_clients_locally(Rc::clone(&bus), on_request);
+
+    let creds = self_signed_for_loopback();
+    let cert_chain = creds.cert_chain.clone();
+
+    let bound = start_on_shard_zero(
+        &bus,
+        loopback(),
+        loopback(),
+        None,
+        None,
+        None,
+        Some(loopback()),
+        Some(creds),
+        None,
+        None,
+        CLUSTER,
+        0,
+        1,
+        vec![],
+        accepted_replica,
+        accepted_client,
+        None,
+        None,
+        Some(accepted_tls),
+        None,
+        DEFAULT_RECONNECT_PERIOD,
+    )
+    .await
+    .expect("start_on_shard_zero must succeed")
+    .expect("shard 0 must return BoundPlanes");
+
+    let server_addr = bound.tcp_tls.expect("tcp_tls plane bound");
+    assert_ne!(server_addr.port(), 0, "tcp_tls must bind a real port");
+
+    // Client side: dial the bound TCP-TLS address, then drive the rustls
+    // client through the same `TransportConn::run` shape the server uses.
+    let mut roots = RootCertStore::empty();
+    for cert in cert_chain {
+        roots.add(cert).expect("trust self-signed cert");
+    }
+    let client_cfg: Arc<rustls::ClientConfig> = Arc::new(
+        rustls::ClientConfig::builder()
+            .with_root_certificates(Arc::new(roots))
+            .with_no_client_auth(),
+    );
+    let server_name: ServerName<'static> = "localhost".try_into().expect("server name");
+
+    let client_tcp = TcpStream::connect(server_addr).await.expect("client dial");
+    let conn = TcpTlsTransportConn::new_client(client_tcp, client_cfg, server_name);
+
+    let (out_tx, out_rx) = bounded::<Frozen<MESSAGE_ALIGN>>(8);
+    let (in_tx, in_rx) = bounded::<Message<GenericHeader>>(8);
+    let (client_shutdown, client_token) = Shutdown::new();
+    let ctx = ActorContext {
+        in_tx,
+        rx: out_rx,
+        shutdown: client_token,
+        max_batch: 16,
+        max_message_size: framing::MAX_MESSAGE_SIZE,
+        label: "test-client",
+        peer: "test-client".to_owned(),
+    };
+    let client_handle = compio::runtime::spawn(async move { conn.run(ctx).await });
+
+    let request = header_only(Command2::Request, CLUSTER, 0).into_frozen();
+    out_tx.send(request).await.expect("client send");
+
+    let reply = compio::time::timeout(Duration::from_secs(5), in_rx.recv())
+        .await
+        .expect("client must receive reply within 5 s")
+        .expect("reply frame");
+    assert_eq!(reply.header().command, Command2::Reply);
+    assert_eq!(reply.header().cluster, CLUSTER);
+
+    client_shutdown.trigger();
+    let _ = compio::time::timeout(Duration::from_secs(5), client_handle).await;
+
+    let outcome = bus.shutdown(Duration::from_secs(5)).await;
+    assert_eq!(
+        outcome.force, 0,
+        "graceful shutdown should not force-cancel"
+    );
+}
diff --git a/core/message_bus/tests/vectored_batch.rs b/core/message_bus/tests/vectored_batch.rs
index c8fc10c6b..ab222e68e 100644
--- a/core/message_bus/tests/vectored_batch.rs
+++ b/core/message_bus/tests/vectored_batch.rs
@@ -23,7 +23,7 @@ mod common;
 use common::{header_only, install_replicas_locally, loopback};
 use iggy_binary_protocol::Command2;
 use message_bus::connector::{DEFAULT_RECONNECT_PERIOD, start as start_connector};
-use message_bus::replica_listener::{MessageHandler, bind, run};
+use message_bus::replica::listener::{MessageHandler, bind, run};
 use message_bus::{IggyMessageBus, MessageBus};
 use std::cell::Cell;
 use std::rc::Rc;
diff --git a/core/message_bus/tests/ws_client_roundtrip.rs b/core/message_bus/tests/ws_client_roundtrip.rs
new file mode 100644
index 000000000..93f4397bc
--- /dev/null
+++ b/core/message_bus/tests/ws_client_roundtrip.rs
@@ -0,0 +1,143 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! End-to-end: a real WebSocket client connects to the consensus WS
+//! pre-upgrade listener on shard 0; the listener's callback dups the
+//! TCP fd and (on the same shard, locally) hands it to
+//! `install_client_ws_fd`, which runs `compio_ws::accept_async` before
+//! installing the WS connection.
+//!
+//! Coverage: client connects without any subprotocol header, handshake
+//! succeeds, the server-side handler observes a Request, and the
+//! server's `bus.send_to_client` reply lands on the client's reader.
+//! Verifies the full bidirectional plane through the reader / writer
+//! two-task split. Pre-LOGIN command gating is the caller's
+//! responsibility (server-ng) and is not exercised here.
+
+mod common;
+
+use async_channel::bounded;
+use common::{header_only, install_ws_clients_locally, loopback};
+use compio::net::TcpStream;
+use iggy_binary_protocol::Command2;
+use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
+use iggy_binary_protocol::consensus::iobuf::Frozen;
+use iggy_binary_protocol::{GenericHeader, Message};
+use message_bus::client_listener::RequestHandler;
+use message_bus::client_listener::ws::{bind, run};
+use message_bus::transports::ws::WsTransportConn;
+use message_bus::transports::{ActorContext, TransportConn};
+use message_bus::{IggyMessageBus, MessageBus, Shutdown, framing};
+use std::rc::Rc;
+use std::time::Duration;
+
+#[compio::test]
+async fn handshake_succeeds_and_round_trip_completes() {
+    let bus = Rc::new(IggyMessageBus::new(0));
+
+    // Handler echoes a Reply back to the originating client_id via the
+    // bus's send_to_client surface — same path a real dispatcher would
+    // take. Spawned because the handler signature is synchronous; the
+    // bus surface returns Ready-on-first-poll so the spawned task
+    // completes within the same runtime tick.
+    let bus_for_handler = Rc::clone(&bus);
+    let on_request: RequestHandler = Rc::new(move |client_id, msg| {
+        assert_eq!(msg.header().command, Command2::Request);
+        let bus = Rc::clone(&bus_for_handler);
+        compio::runtime::spawn(async move {
+            let reply = header_only(Command2::Reply, 42, 0).into_frozen();
+            bus.send_to_client(client_id, reply)
+                .await
+                .expect("server send_to_client");
+        })
+        .detach();
+    });
+
+    let (listener, server_addr) = bind(loopback()).await.expect("bind");
+    let token = bus.token();
+    let on_accepted = install_ws_clients_locally(bus.clone(), on_request);
+    let accept_handle = compio::runtime::spawn(async move {
+        run(listener, token, on_accepted).await;
+    });
+    bus.track_background(accept_handle);
+
+    // Dial as a real WS client; no subprotocol negotiation required.
+    let client_tcp = TcpStream::connect(server_addr).await.unwrap();
+    let url = format!("ws://{server_addr}/");
+    let (ws_client, _resp) = compio_ws::client_async(url, client_tcp)
+        .await
+        .expect("ws handshake");
+
+    // Drive the client side of the WS conn through the unified
+    // `TransportConn::run` shape: spawn the actor, push outbound
+    // requests through `out_tx`, observe inbound replies on `in_rx`.
+    let (out_tx, out_rx) = bounded::<Frozen<MESSAGE_ALIGN>>(8);
+    let (in_tx, in_rx) = bounded::<Message<GenericHeader>>(8);
+    let (client_shutdown, client_token) = Shutdown::new();
+    let ctx = ActorContext {
+        in_tx,
+        rx: out_rx,
+        shutdown: client_token,
+        max_batch: 16,
+        max_message_size: framing::MAX_MESSAGE_SIZE,
+        label: "test-client",
+        peer: "test-client".to_owned(),
+    };
+    let conn = WsTransportConn::new_client(ws_client);
+    let client_handle = compio::runtime::spawn(async move { conn.run(ctx).await });
+
+    let request = header_only(Command2::Request, 42, 0).into_frozen();
+    out_tx.send(request).await.expect("client send");
+
+    let reply = compio::time::timeout(Duration::from_secs(2), in_rx.recv())
+        .await
+        .expect("client must receive reply within 2 s")
+        .expect("reply frame");
+    assert_eq!(reply.header().command, Command2::Reply);
+
+    client_shutdown.trigger();
+    let _ = client_handle.await;
+    bus.shutdown(Duration::from_secs(2)).await;
+}
+
+#[compio::test]
+async fn handshake_succeeds_without_subprotocol_header() {
+    // Without the subprotocol gate, a client that sends NO
+    // Sec-WebSocket-Protocol header must still complete the upgrade.
+    // Pre-LOGIN command gating is enforced by the caller (server-ng),
+    // not at the bus layer.
+    let bus = Rc::new(IggyMessageBus::new(0));
+    let on_request: RequestHandler = Rc::new(|_, _| {});
+
+    let (listener, server_addr) = bind(loopback()).await.expect("bind");
+    let token = bus.token();
+    let on_accepted = install_ws_clients_locally(bus.clone(), on_request);
+    let accept_handle = compio::runtime::spawn(async move {
+        run(listener, token, on_accepted).await;
+    });
+    bus.track_background(accept_handle);
+
+    let client_tcp = TcpStream::connect(server_addr).await.unwrap();
+    let url = format!("ws://{server_addr}/");
+    let result = compio_ws::client_async(url, client_tcp).await;
+    assert!(
+        result.is_ok(),
+        "WS handshake must succeed without subprotocol negotiation"
+    );
+
+    bus.shutdown(Duration::from_secs(1)).await;
+}
diff --git a/core/message_bus/tests/wss_client_listener.rs b/core/message_bus/tests/wss_client_listener.rs
new file mode 100644
index 000000000..de8df2d53
--- /dev/null
+++ b/core/message_bus/tests/wss_client_listener.rs
@@ -0,0 +1,126 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+//! End-to-end: a WSS client connects to the consensus WSS client
+//! listener on shard 0; the listener's callback hands the raw stream +
+//! shared `Arc<rustls::ServerConfig>` to `install_client_wss_stream`,
+//! which drives both the rustls handshake and the WS HTTP-Upgrade on
+//! its own task before installing reader / writer tasks. The handler
+//! echoes a Reply back via `bus.send_to_client`, the client reads the
+//! Reply.
+//!
+//! The WSS client side is built from the same `WssTransportConn` the
+//! server uses, in client role, mirroring the in-file unit test pattern.
+
+mod common;
+
+use async_channel::bounded;
+use common::{header_only, install_wss_clients_locally, loopback};
+use compio::net::TcpStream;
+use iggy_binary_protocol::Command2;
+use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
+use iggy_binary_protocol::consensus::iobuf::Frozen;
+use iggy_binary_protocol::{GenericHeader, Message};
+use message_bus::client_listener::RequestHandler;
+use message_bus::client_listener::wss::{bind, run};
+use message_bus::transports::tls::{install_default_crypto_provider, self_signed_for_loopback};
+use message_bus::transports::wss::WssTransportConn;
+use message_bus::transports::{ActorContext, TransportConn};
+use message_bus::{IggyMessageBus, MessageBus, Shutdown, framing};
+use rustls::RootCertStore;
+use rustls::pki_types::ServerName;
+use std::rc::Rc;
+use std::sync::Arc;
+use std::time::Duration;
+
+#[compio::test]
+async fn wss_client_listener_accepts_and_round_trips() {
+    install_default_crypto_provider();
+
+    let bus = Rc::new(IggyMessageBus::new(0));
+
+    let bus_for_handler = Rc::clone(&bus);
+    let on_request: RequestHandler = Rc::new(move |client_id, msg| {
+        assert_eq!(msg.header().command, Command2::Request);
+        let bus = Rc::clone(&bus_for_handler);
+        compio::runtime::spawn(async move {
+            let reply = header_only(Command2::Reply, 7, 0).into_frozen();
+            bus.send_to_client(client_id, reply)
+                .await
+                .expect("server send_to_client");
+        })
+        .detach();
+    });
+
+    let creds = self_signed_for_loopback();
+    let cert_chain = creds.cert_chain.clone();
+    let (listener, server_cfg, server_addr) =
+        bind(loopback(), creds).await.expect("wss listener bind");
+    let token = bus.token();
+    let on_accepted = install_wss_clients_locally(Rc::clone(&bus), on_request);
+    let accept_handle = compio::runtime::spawn(async move {
+        run(listener, server_cfg, token, on_accepted).await;
+    });
+    bus.track_background(accept_handle);
+
+    let mut roots = RootCertStore::empty();
+    for cert in cert_chain {
+        roots.add(cert).expect("trust self-signed cert");
+    }
+    let client_cfg: Arc<rustls::ClientConfig> = Arc::new(
+        rustls::ClientConfig::builder()
+            .with_root_certificates(Arc::new(roots))
+            .with_no_client_auth(),
+    );
+    let server_name: ServerName<'static> = "localhost".try_into().expect("server name");
+
+    let client_tcp = TcpStream::connect(server_addr).await.expect("client dial");
+    let conn = WssTransportConn::new_client(client_tcp, client_cfg, server_name);
+
+    let (out_tx, out_rx) = bounded::<Frozen<MESSAGE_ALIGN>>(8);
+    let (in_tx, in_rx) = bounded::<Message<GenericHeader>>(8);
+    let (client_shutdown, client_token) = Shutdown::new();
+    let ctx = ActorContext {
+        in_tx,
+        rx: out_rx,
+        shutdown: client_token,
+        max_batch: 16,
+        max_message_size: framing::MAX_MESSAGE_SIZE,
+        label: "test-client",
+        peer: "test-client".to_owned(),
+    };
+    let client_handle = compio::runtime::spawn(async move { conn.run(ctx).await });
+
+    let request = header_only(Command2::Request, 7, 0).into_frozen();
+    out_tx.send(request).await.expect("client send");
+
+    let reply = compio::time::timeout(Duration::from_secs(5), in_rx.recv())
+        .await
+        .expect("client must receive reply within 5 s")
+        .expect("reply frame");
+    assert_eq!(reply.header().command, Command2::Reply);
+    assert_eq!(reply.header().cluster, 7);
+
+    client_shutdown.trigger();
+    let _ = compio::time::timeout(Duration::from_secs(5), client_handle).await;
+
+    let outcome = bus.shutdown(Duration::from_secs(5)).await;
+    assert_eq!(
+        outcome.force, 0,
+        "graceful shutdown should not force-cancel"
+    );
+}
diff --git a/core/message_bus/tests/wss_client_roundtrip.rs b/core/message_bus/tests/wss_client_roundtrip.rs
new file mode 100644
index 000000000..559925095
--- /dev/null
+++ b/core/message_bus/tests/wss_client_roundtrip.rs
@@ -0,0 +1,160 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+//! End-to-end bootstrap: `replica::io::start_on_shard_zero` binds the
+//! WSS plane (alongside replica + client TCP listeners) and the
+//! caller dials the listener address surfaced via
+//! [`message_bus::replica::io::BoundPlanes::wss`]. Mirrors the
+//! `wss_client_listener.rs` listener-level test one level up the
+//! stack: the validator, paired-presence trio, and `BoundPlanes`
+//! growth all participate.
+
+mod common;
+
+use async_channel::bounded;
+use common::{
+    header_only, install_clients_locally, install_replicas_locally, install_wss_clients_locally,
+    loopback,
+};
+use compio::net::TcpStream;
+use iggy_binary_protocol::Command2;
+use iggy_binary_protocol::consensus::MESSAGE_ALIGN;
+use iggy_binary_protocol::consensus::iobuf::Frozen;
+use iggy_binary_protocol::{GenericHeader, Message};
+use message_bus::client_listener::RequestHandler;
+use message_bus::connector::DEFAULT_RECONNECT_PERIOD;
+use message_bus::replica::io::start_on_shard_zero;
+use message_bus::replica::listener::MessageHandler;
+use message_bus::transports::tls::{install_default_crypto_provider, self_signed_for_loopback};
+use message_bus::transports::wss::WssTransportConn;
+use message_bus::transports::{ActorContext, TransportConn};
+use message_bus::{IggyMessageBus, MessageBus, Shutdown, framing};
+use rustls::RootCertStore;
+use rustls::pki_types::ServerName;
+use std::rc::Rc;
+use std::sync::Arc;
+use std::time::Duration;
+
+const CLUSTER: u128 = 0xBEEF;
+
+#[compio::test]
+async fn start_on_shard_zero_wss_round_trip() {
+    install_default_crypto_provider();
+
+    let bus = Rc::new(IggyMessageBus::new(0));
+
+    let on_message: MessageHandler = Rc::new(|_, _| {});
+    let bus_for_handler = Rc::clone(&bus);
+    let on_request: RequestHandler = Rc::new(move |client_id, msg| {
+        assert_eq!(msg.header().command, Command2::Request);
+        let bus = Rc::clone(&bus_for_handler);
+        compio::runtime::spawn(async move {
+            let reply = header_only(Command2::Reply, CLUSTER, 0).into_frozen();
+            bus.send_to_client(client_id, reply)
+                .await
+                .expect("server send_to_client");
+        })
+        .detach();
+    });
+
+    let accepted_replica = install_replicas_locally(Rc::clone(&bus), on_message);
+    let accepted_client = install_clients_locally(Rc::clone(&bus), Rc::clone(&on_request));
+    let accepted_wss = install_wss_clients_locally(Rc::clone(&bus), on_request);
+
+    let creds = self_signed_for_loopback();
+    let cert_chain = creds.cert_chain.clone();
+
+    let bound = start_on_shard_zero(
+        &bus,
+        loopback(),
+        loopback(),
+        None,
+        None,
+        None,
+        None,
+        None,
+        Some(loopback()),
+        Some(creds),
+        CLUSTER,
+        0,
+        1,
+        vec![],
+        accepted_replica,
+        accepted_client,
+        None,
+        None,
+        None,
+        Some(accepted_wss),
+        DEFAULT_RECONNECT_PERIOD,
+    )
+    .await
+    .expect("start_on_shard_zero must succeed")
+    .expect("shard 0 must return BoundPlanes");
+
+    let server_addr = bound.wss.expect("wss plane bound");
+    assert_ne!(server_addr.port(), 0, "wss must bind a real port");
+
+    // Client side: dial the bound WSS address, then drive the WSS
+    // client (rustls + WS HTTP-Upgrade) through the same
+    // `TransportConn::run` shape the server uses.
+    let mut roots = RootCertStore::empty();
+    for cert in cert_chain {
+        roots.add(cert).expect("trust self-signed cert");
+    }
+    let client_cfg: Arc<rustls::ClientConfig> = Arc::new(
+        rustls::ClientConfig::builder()
+            .with_root_certificates(Arc::new(roots))
+            .with_no_client_auth(),
+    );
+    let server_name: ServerName<'static> = "localhost".try_into().expect("server name");
+
+    let client_tcp = TcpStream::connect(server_addr).await.expect("client dial");
+    let conn = WssTransportConn::new_client(client_tcp, client_cfg, server_name);
+
+    let (out_tx, out_rx) = bounded::<Frozen<MESSAGE_ALIGN>>(8);
+    let (in_tx, in_rx) = bounded::<Message<GenericHeader>>(8);
+    let (client_shutdown, client_token) = Shutdown::new();
+    let ctx = ActorContext {
+        in_tx,
+        rx: out_rx,
+        shutdown: client_token,
+        max_batch: 16,
+        max_message_size: framing::MAX_MESSAGE_SIZE,
+        label: "test-client",
+        peer: "test-client".to_owned(),
+    };
+    let client_handle = compio::runtime::spawn(async move { conn.run(ctx).await });
+
+    let request = header_only(Command2::Request, CLUSTER, 0).into_frozen();
+    out_tx.send(request).await.expect("client send");
+
+    let reply = compio::time::timeout(Duration::from_secs(5), in_rx.recv())
+        .await
+        .expect("client must receive reply within 5 s")
+        .expect("reply frame");
+    assert_eq!(reply.header().command, Command2::Reply);
+    assert_eq!(reply.header().cluster, CLUSTER);
+
+    client_shutdown.trigger();
+    let _ = compio::time::timeout(Duration::from_secs(5), client_handle).await;
+
+    let outcome = bus.shutdown(Duration::from_secs(5)).await;
+    assert_eq!(
+        outcome.force, 0,
+        "graceful shutdown should not force-cancel"
+    );
+}
diff --git a/core/server-ng/config.toml b/core/server-ng/config.toml
index 2754b787a..d7cdd4e64 100644
--- a/core/server-ng/config.toml
+++ b/core/server-ng/config.toml
@@ -618,8 +618,3 @@ close_peer_timeout = "2 s"
 # Wall-clock bound on a single stream.shutdown() / ws.close() in the
 # safe-shutdown sequence of the TLS-family transports.
 close_grace = "2 s"
-
-# Optional listen addresses for the TLS-family transports. Comment out
-# to keep the plane unbound.
-# tcp_tls_listen_addr = "0.0.0.0:9443"
-# wss_listen_addr = "0.0.0.0:9444"
diff --git a/core/server-ng/src/dedup.rs b/core/server-ng/src/dedup.rs
new file mode 100644
index 000000000..40a8b9251
--- /dev/null
+++ b/core/server-ng/src/dedup.rs
@@ -0,0 +1,396 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// `Dedup` is consumed only by the `#[cfg(test)]` tests below today; the
+// request dispatcher loop in server-ng is not yet wired up. Once the
+// dispatcher lands and calls `lookup` / `mark_in_flight` / `complete` /
+// `evict_client`, the `dead_code` warnings disappear without needing
+// this allow.
+#![allow(dead_code)]
+
+//! At-most-once **per-shard-lifetime** request dedup window.
+//!
+//! Indexed by `(client_id, request)` pairs carried in the existing
+//! `RequestHeader.client` (u128) and `RequestHeader.request` (u64) fields.
+//! No wire-format change on the server-ng client plane is needed: both
+//! fields are already part of the request header.
+//!
+//! # Lifetime scope (load-bearing)
+//!
+//! "At-most-once" applies for the lifetime of THIS shard's `Dedup`
+//! instance. Process restart drops every entry: a client that retries a
+//! request after a server restart re-applies side effects unless the
+//! request is idempotent at the handler level. Cross-process persistence
+//! would need a checkpoint file backed by the consensus WAL and is
+//! deferred. Operators sizing client-side retry policy must treat the
+//! dedup window as a *crash-window* mitigation only.
+//!
+//! # Per-client ring
+//!
+//! Per-client state is a fixed-capacity ring holding the last N
+//! `(request, Entry)` pairs. A ring avoids unbounded growth under a
+//! chatty client without requiring TTL-driven purge ticks. Done entries
+//! also carry an `inserted_at_ns` timestamp; `lookup` treats entries
+//! older than `ttl_ns` as absent so a replay from far in the past sees
+//! `Fresh` rather than a stale cache hit.
+//!
+//! # Threading
+//!
+//! The store is NOT thread-safe. Each server-ng shard owns a `Dedup` on
+//! its single-threaded compio runtime. Cross-shard routing happens above
+//! this layer. Caller MUST invoke [`Dedup::evict_client`] on session
+//! disconnect / bind eviction so the per-client `AHashMap` entry is
+//! released; without that hook the outer map grows unbounded across the
+//! shard's session history.
+
+use ahash::AHashMap;
+use bytes::Bytes;
+
+/// Per-client ring capacity on the dedup window. Sized for a pipelining
+/// client issuing tens of outstanding requests; small enough to cap
+/// memory at `clients * 64 * avg_reply_size`.
+pub const DEFAULT_PER_CLIENT_CAPACITY: usize = 64;
+
+/// Entry TTL.
+///
+/// An entry older than this is invisible to `lookup`; a replay past
+/// the window is treated as `Fresh`. 30 s is comfortably larger than
+/// any per-request retry timer the SDK uses, so a client retry that
+/// follows a slow handler still sees the cached reply.
+pub const DEFAULT_ENTRY_TTL_NS: u128 = 30_000_000_000;
+
+/// Result of `Dedup::lookup`.
+///
+/// Returned as a borrowed view into the cache: `Cached` hands the caller
+/// a `&Bytes` it can `clone()` (cheap, reference-counted) before
+/// releasing the borrow. Avoids copying the reply payload on the
+/// fast-path cache hit.
+#[derive(Debug)]
+pub enum LookupResult<'a> {
+    /// No (live) entry for this `(client, request)`. Caller should call
+    /// [`Dedup::mark_in_flight`] and run the handler.
+    Fresh,
+    /// The same request is currently being processed by an earlier
+    /// invocation. Caller policy decides: drop (client retry will find
+    /// the cached reply once handler completes), or buffer.
+    InFlight,
+    /// Handler already ran; return the cached reply.
+    ///
+    /// The borrow ties to `&Dedup`. Callers MUST `clone()` the `Bytes`
+    /// (cheap, ref-counted) before invoking any `&mut Dedup` method on
+    /// the same instance; the borrow checker enforces this at compile
+    /// time but the contract is worth naming explicitly.
+    Cached(&'a Bytes),
+}
+
+#[derive(Debug)]
+enum Entry {
+    InFlight,
+    Done { reply: Bytes, inserted_at_ns: u128 },
+}
+
+#[derive(Debug)]
+struct Slot {
+    request: u64,
+    entry: Entry,
+}
+
+#[derive(Debug)]
+struct PerClient {
+    slots: Vec<Slot>,
+    cursor: usize,
+    capacity: usize,
+}
+
+impl PerClient {
+    fn with_capacity(capacity: usize) -> Self {
+        // Capacity > 0 is validated at the public entry
+        // (`Dedup::with_config`); every callsite here goes through that
+        // path, so no second assert is needed.
+        debug_assert!(capacity > 0, "dedup ring capacity must be > 0");
+        Self {
+            slots: Vec::with_capacity(capacity),
+            cursor: 0,
+            capacity,
+        }
+    }
+
+    fn find(&self, request: u64) -> Option<&Slot> {
+        self.slots.iter().find(|s| s.request == request)
+    }
+
+    fn find_mut(&mut self, request: u64) -> Option<&mut Slot> {
+        self.slots.iter_mut().find(|s| s.request == request)
+    }
+
+    fn push(&mut self, slot: Slot) {
+        if self.slots.len() < self.capacity {
+            self.slots.push(slot);
+        } else {
+            self.slots[self.cursor] = slot;
+            self.cursor = (self.cursor + 1) % self.capacity;
+        }
+    }
+}
+
+/// At-most-once request dedup window.
+///
+/// Single-shard, single-threaded. Callers hold a `&mut Dedup` and drive
+/// the lookup / mark / complete flow synchronously on the hot path.
+#[derive(Debug)]
+pub struct Dedup {
+    per_client: AHashMap<u128, PerClient>,
+    per_client_capacity: usize,
+    ttl_ns: u128,
+}
+
+impl Dedup {
+    /// Build a window with defaults: 64-entry per-client ring, 30 s TTL.
+    #[must_use]
+    pub fn new() -> Self {
+        Self::with_config(DEFAULT_PER_CLIENT_CAPACITY, DEFAULT_ENTRY_TTL_NS)
+    }
+
+    /// # Panics
+    /// Panics if `per_client_capacity == 0`. Every producing path in
+    /// the crate uses [`DEFAULT_PER_CLIENT_CAPACITY`]; the assertion
+    /// guards misconfiguration in tests or future callers.
+    #[must_use]
+    pub fn with_config(per_client_capacity: usize, ttl_ns: u128) -> Self {
+        assert!(per_client_capacity > 0, "capacity must be > 0");
+        Self {
+            per_client: AHashMap::new(),
+            per_client_capacity,
+            ttl_ns,
+        }
+    }
+
+    /// Inspect the state for `(client, request)` at `now_ns`.
+    ///
+    /// Done entries older than the TTL are treated as absent; the window
+    /// still holds the slot but does not report it. On eviction by a
+    /// later `push`, the slot is overwritten silently.
+    #[must_use]
+    pub fn lookup(&self, client: u128, request: u64, now_ns: u128) -> LookupResult<'_> {
+        let Some(state) = self.per_client.get(&client) else {
+            return LookupResult::Fresh;
+        };
+        let Some(slot) = state.find(request) else {
+            return LookupResult::Fresh;
+        };
+        match &slot.entry {
+            Entry::InFlight => LookupResult::InFlight,
+            Entry::Done {
+                reply,
+                inserted_at_ns,
+            } => {
+                if now_ns.saturating_sub(*inserted_at_ns) > self.ttl_ns {
+                    LookupResult::Fresh
+                } else {
+                    LookupResult::Cached(reply)
+                }
+            }
+        }
+    }
+
+    /// Mark the handler as starting. Returns `true` if the slot was
+    /// inserted; `false` if an entry for `(client, request)` already
+    /// existed (caller must consult [`Self::lookup`] for the specific
+    /// state).
+    pub fn mark_in_flight(&mut self, client: u128, request: u64) -> bool {
+        let state = self
+            .per_client
+            .entry(client)
+            .or_insert_with(|| PerClient::with_capacity(self.per_client_capacity));
+        if state.find(request).is_some() {
+            return false;
+        }
+        state.push(Slot {
+            request,
+            entry: Entry::InFlight,
+        });
+        true
+    }
+
+    /// Record a completed reply. Transitions the slot from `InFlight`
+    /// to `Done`. If no slot exists (e.g. ring-overwritten by a busier
+    /// peer `client_id`, or `evict_client`'d between `mark_in_flight`
+    /// and `complete`), a new `Done` slot is appended.
+    pub fn complete(&mut self, client: u128, request: u64, reply: Bytes, now_ns: u128) {
+        let state = self
+            .per_client
+            .entry(client)
+            .or_insert_with(|| PerClient::with_capacity(self.per_client_capacity));
+        if let Some(slot) = state.find_mut(request) {
+            slot.entry = Entry::Done {
+                reply,
+                inserted_at_ns: now_ns,
+            };
+            return;
+        }
+        state.push(Slot {
+            request,
+            entry: Entry::Done {
+                reply,
+                inserted_at_ns: now_ns,
+            },
+        });
+    }
+
+    /// Drop all state for `client`. Call on disconnect (or on
+    /// `bind_session` eviction inside `SessionManager`) so the
+    /// per-client ring is released deterministically. Without this
+    /// call the ring is only released by ring overwrite (under
+    /// continued client traffic) or process exit; TTL hides stale
+    /// entries from `lookup` but never frees their slots.
+    pub fn evict_client(&mut self, client: u128) {
+        self.per_client.remove(&client);
+    }
+
+    /// Current number of tracked clients. Test / metrics helper.
+    #[must_use]
+    pub fn client_count(&self) -> usize {
+        self.per_client.len()
+    }
+}
+
+impl Default for Dedup {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn bytes(s: &str) -> Bytes {
+        Bytes::copy_from_slice(s.as_bytes())
+    }
+
+    #[test]
+    fn fresh_lookup_on_empty_store() {
+        let d = Dedup::new();
+        assert!(matches!(d.lookup(1, 1, 0), LookupResult::Fresh));
+    }
+
+    #[test]
+    fn mark_then_lookup_returns_in_flight() {
+        let mut d = Dedup::new();
+        assert!(d.mark_in_flight(1, 1));
+        assert!(matches!(d.lookup(1, 1, 0), LookupResult::InFlight));
+    }
+
+    #[test]
+    fn double_mark_returns_false() {
+        let mut d = Dedup::new();
+        assert!(d.mark_in_flight(1, 1));
+        assert!(!d.mark_in_flight(1, 1));
+    }
+
+    #[test]
+    fn complete_transitions_to_cached() {
+        let mut d = Dedup::new();
+        d.mark_in_flight(1, 7);
+        d.complete(1, 7, bytes("ok"), 1_000);
+        match d.lookup(1, 7, 1_000) {
+            LookupResult::Cached(reply) => assert_eq!(&reply[..], b"ok"),
+            other => panic!("expected Cached, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn complete_without_mark_inserts_directly() {
+        let mut d = Dedup::new();
+        d.complete(1, 7, bytes("ok"), 0);
+        assert!(matches!(d.lookup(1, 7, 0), LookupResult::Cached(_)));
+    }
+
+    #[test]
+    fn ttl_expired_entry_reports_fresh() {
+        let mut d = Dedup::with_config(8, 1_000);
+        d.complete(1, 7, bytes("ok"), 0);
+        assert!(matches!(d.lookup(1, 7, 2_000), LookupResult::Fresh));
+    }
+
+    #[test]
+    fn ttl_at_boundary_still_cached() {
+        let mut d = Dedup::with_config(8, 1_000);
+        d.complete(1, 7, bytes("ok"), 0);
+        assert!(matches!(d.lookup(1, 7, 1_000), LookupResult::Cached(_)));
+    }
+
+    #[test]
+    fn ring_evicts_oldest_entry() {
+        let mut d = Dedup::with_config(3, DEFAULT_ENTRY_TTL_NS);
+        d.complete(1, 1, bytes("a"), 10);
+        d.complete(1, 2, bytes("b"), 20);
+        d.complete(1, 3, bytes("c"), 30);
+        d.complete(1, 4, bytes("d"), 40);
+        // Slot for request 1 was overwritten by request 4.
+        assert!(matches!(d.lookup(1, 1, 50), LookupResult::Fresh));
+        assert!(matches!(d.lookup(1, 2, 50), LookupResult::Cached(_)));
+        assert!(matches!(d.lookup(1, 3, 50), LookupResult::Cached(_)));
+        assert!(matches!(d.lookup(1, 4, 50), LookupResult::Cached(_)));
+    }
+
+    #[test]
+    fn evict_client_drops_all_entries() {
+        let mut d = Dedup::new();
+        d.complete(1, 1, bytes("a"), 10);
+        d.complete(1, 2, bytes("b"), 20);
+        d.evict_client(1);
+        assert!(matches!(d.lookup(1, 1, 30), LookupResult::Fresh));
+        assert_eq!(d.client_count(), 0);
+    }
+
+    #[test]
+    fn isolation_between_clients() {
+        let mut d = Dedup::new();
+        d.complete(1, 5, bytes("a"), 10);
+        d.complete(2, 5, bytes("b"), 20);
+        match d.lookup(1, 5, 30) {
+            LookupResult::Cached(r) => assert_eq!(&r[..], b"a"),
+            other => panic!("expected Cached a, got {other:?}"),
+        }
+        match d.lookup(2, 5, 30) {
+            LookupResult::Cached(r) => assert_eq!(&r[..], b"b"),
+            other => panic!("expected Cached b, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn complete_updates_inserted_at_on_refresh() {
+        let mut d = Dedup::with_config(8, 100);
+        d.complete(1, 1, bytes("v1"), 0);
+        // Refresh with new timestamp and payload.
+        d.complete(1, 1, bytes("v2"), 50);
+        match d.lookup(1, 1, 140) {
+            LookupResult::Cached(r) => assert_eq!(&r[..], b"v2"),
+            other => panic!("expected Cached v2, got {other:?}"),
+        }
+        // Would have been TTL-expired if inserted_at had stayed at 0.
+    }
+
+    #[test]
+    #[should_panic(expected = "capacity must be > 0")]
+    fn zero_capacity_rejected() {
+        let _ = Dedup::with_config(0, DEFAULT_ENTRY_TTL_NS);
+    }
+}
diff --git a/core/server-ng/src/lib.rs b/core/server-ng/src/lib.rs
index caae94a3a..f3b5f9ca9 100644
--- a/core/server-ng/src/lib.rs
+++ b/core/server-ng/src/lib.rs
@@ -17,5 +17,10 @@
  * under the License.
  */
 
+// `dedup` is gated `pub(crate)` until the request dispatcher loop wires
+// up the lookup / mark / complete / evict_client API end-to-end. Marking
+// it crate-private now blocks downstream crates from coupling to a shape
+// the dispatcher work will likely refine.
+pub(crate) mod dedup;
 pub mod login_register;
 pub mod session_manager;
diff --git a/core/server-ng/src/session_manager.rs b/core/server-ng/src/session_manager.rs
index b89ae4a00..0527c45e3 100644
--- a/core/server-ng/src/session_manager.rs
+++ b/core/server-ng/src/session_manager.rs
@@ -23,7 +23,7 @@
 //! Each connection goes through: `connect → login → register → bound`.
 //!
 //! The [`SessionManager`] is the server-side counterpart of the SDK's
-//! session lifecycle. It does **not** own the [`ClientTable`]. That lives
+//! session lifecycle. It does **not** own the `ClientTable`. That lives
 //! in the consensus layer. This module tracks the binding between a
 //! transport connection and the consensus-level `(client_id, session)` pair.
 
@@ -65,9 +65,10 @@ pub struct Connection {
 
 /// Bridges transport connections to consensus sessions.
 ///
-/// Thread-safe: intended to be shared across connection handler tasks.
-/// Uses interior mutability via the caller's synchronization (single-threaded
-/// shard model like the rest of iggy's server-ng).
+/// NOT thread-safe: each shard owns one `SessionManager` on its
+/// single-threaded compio runtime, the same way the rest of server-ng
+/// is structured. All mutators take `&mut self`; the type carries no
+/// internal locking.
 ///
 /// ## Invariants
 ///
diff --git a/core/shard/src/builder.rs b/core/shard/src/builder.rs
index a3e74b9b5..b365b9913 100644
--- a/core/shard/src/builder.rs
+++ b/core/shard/src/builder.rs
@@ -44,7 +44,7 @@ use journal::JournalHandle;
 use message_bus::MessageBus;
 use message_bus::client_listener::RequestHandler;
 use message_bus::lifecycle::ShutdownToken;
-use message_bus::replica_listener::MessageHandler;
+use message_bus::replica::listener::MessageHandler;
 use metadata::IggyMetadata;
 use metadata::stm::StateMachine;
 use partitions::IggyPartitions;
@@ -142,8 +142,9 @@ where
     /// # Panics
     ///
     /// Panics if `senders` is not in canonical order
-    /// (`senders[i].shard_id() != i`) or, on shard 0, if `senders.len()`
-    /// does not fit in `u16`. Both are bootstrap programming errors.
+    /// (`senders[i].shard_id() != i`) or if `senders.len()` does not
+    /// fit in `u16`. Both are bootstrap programming errors and the
+    /// `u16` overflow check fires on every shard, not only shard 0.
     pub fn build(self) -> BuiltShard<B, MJ, S, M, T, R> {
         let is_shard_zero = self.identity.id == 0;
 
diff --git a/core/shard/src/coordinator.rs b/core/shard/src/coordinator.rs
index edd88e07c..1b9ca128a 100644
--- a/core/shard/src/coordinator.rs
+++ b/core/shard/src/coordinator.rs
@@ -43,9 +43,9 @@ use crate::config::CoordinatorConfig;
 use crate::{ShardFrame, ShardFramePayload, TaggedSender, assert_sender_ordering};
 use compio::net::TcpStream;
 use compio::runtime::JoinHandle;
-use message_bus::SendError;
-use message_bus::fd_transfer;
+use message_bus::installer::conn_info::{ClientConnMeta, ClientTransportKind};
 use message_bus::lifecycle::ShutdownToken;
+use message_bus::{SendError, fd_transfer};
 use std::cell::{Cell, RefCell};
 use std::rc::Rc;
 use std::time::Duration;
@@ -211,14 +211,18 @@ impl<R: Send + 'static> ShardZeroCoordinator<R> {
     ///
     /// # Errors
     ///
-    /// Returns an error when `dup(2)` fails or the target shard's inbox
-    /// refuses the setup frame.
+    /// Returns [`SendError::DupFailed`] if `stream.peer_addr()` lookup
+    /// fails or `dup(2)` fails. Returns [`SendError::RoutingFailed`]
+    /// when the target shard's inbox refuses the setup frame (full or
+    /// disconnected).
     pub fn delegate_client(&self, stream: TcpStream) -> Result<u128, SendError> {
         let target = self.next_client_target();
         let client_id = self.mint_client_id(target);
+        let peer_addr = stream.peer_addr().map_err(SendError::DupFailed)?;
 
         let fd = fd_transfer::dup_fd(&stream).map_err(SendError::DupFailed)?;
-        let setup = ShardFramePayload::ClientConnectionSetup { fd, client_id };
+        let meta = ClientConnMeta::new(client_id, peer_addr, ClientTransportKind::Tcp);
+        let setup = ShardFramePayload::ClientConnectionSetup { fd, meta };
         if let Err(e) = self.senders[target as usize].try_send(ShardFrame::lifecycle(setup)) {
             // The returned frame owns the `DupedFd` and closes it on drop.
             warn!(client_id, target, "delegate_client try_send failed: {e:?}");
@@ -229,6 +233,41 @@ impl<R: Send + 'static> ShardZeroCoordinator<R> {
         Ok(client_id)
     }
 
+    /// Ship a WebSocket client's pre-upgrade TCP connection to the next
+    /// round-robin target shard.
+    ///
+    /// Identical wire path to [`Self::delegate_client`] but ships
+    /// [`ShardFramePayload::ClientWsConnectionSetup`] so the receiving
+    /// shard runs `compio_ws::accept_async_with_config` before
+    /// installing the connection. The fd at ship-time is plain TCP;
+    /// the WS state machine only materialises post-upgrade on the
+    /// owning shard.
+    ///
+    /// # Errors
+    ///
+    /// Returns [`SendError::DupFailed`] if `stream.peer_addr()` lookup
+    /// fails or `dup(2)` fails. Returns [`SendError::RoutingFailed`]
+    /// when the target shard's inbox refuses the setup frame.
+    pub fn delegate_ws_client(&self, stream: TcpStream) -> Result<u128, SendError> {
+        let target = self.next_client_target();
+        let client_id = self.mint_client_id(target);
+        let peer_addr = stream.peer_addr().map_err(SendError::DupFailed)?;
+
+        let fd = fd_transfer::dup_fd(&stream).map_err(SendError::DupFailed)?;
+        let meta = ClientConnMeta::new(client_id, peer_addr, ClientTransportKind::Ws);
+        let setup = ShardFramePayload::ClientWsConnectionSetup { fd, meta };
+        if let Err(e) = self.senders[target as usize].try_send(ShardFrame::lifecycle(setup)) {
+            warn!(
+                client_id,
+                target, "delegate_ws_client try_send failed: {e:?}"
+            );
+            return Err(SendError::RoutingFailed(target));
+        }
+
+        drop(stream);
+        Ok(client_id)
+    }
+
     /// Broadcast a `ReplicaMappingClear` to every shard. Used by the
     /// `ConnectionLost` handler before the next `delegate_replica` runs.
     pub fn broadcast_mapping_clear(&self, replica_id: u8) {
@@ -273,10 +312,10 @@ impl<R: Send + 'static> ShardZeroCoordinator<R> {
         }
     }
 
-    /// Spawn a compio task that calls [`broadcast_mapping_snapshot`] every
-    /// `period` until `token` fires. Bootstrap is expected to track the
-    /// returned handle on the bus's background tasks so graceful shutdown
-    /// awaits it.
+    /// Spawn a compio task that calls [`Self::broadcast_mapping_snapshot`]
+    /// every `period` until `token` fires. Bootstrap is expected to track
+    /// the returned handle on the bus's background tasks so graceful
+    /// shutdown awaits it.
     pub fn spawn_refresh_task(
         self: &Rc<Self>,
         token: ShutdownToken,
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index 579577eee..0c1703056 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -39,7 +39,8 @@ use journal::{Journal, JournalHandle};
 use message_bus::MessageBus;
 use message_bus::client_listener::RequestHandler;
 use message_bus::fd_transfer::DupedFd;
-use message_bus::replica_listener::MessageHandler;
+use message_bus::installer::conn_info::ClientConnMeta;
+use message_bus::replica::listener::MessageHandler;
 use metadata::IggyMetadata;
 use metadata::impls::metadata::StreamsFrontend;
 use metadata::stm::StateMachine;
@@ -198,8 +199,27 @@ pub enum ShardFramePayload {
     /// Shard 0 distributes an inbound SDK client TCP connection fd to the
     /// owning shard. The receiving shard wraps the fd and installs client
     /// reader / writer tasks locally. The owning shard is encoded in the top
-    /// 16 bits of `client_id`.
-    ClientConnectionSetup { fd: DupedFd, client_id: u128 },
+    /// 16 bits of `meta.client_id`.
+    ClientConnectionSetup { fd: DupedFd, meta: ClientConnMeta },
+    /// Shard 0 distributes an inbound SDK WebSocket client's pre-upgrade
+    /// TCP connection fd to the owning shard. The HTTP-Upgrade handshake
+    /// has NOT run yet at this point: the fd is plain TCP, the dup is
+    /// safe (cross-shard fd-delegation only happens for plain TCP), and
+    /// `compio_ws::WebSocketStream<TcpStream>`'s `!Send` constraint
+    /// (compio `Rc<...>` driver state, post-upgrade) does not apply.
+    /// The receiving shard wraps the fd, runs `compio_ws::accept_async`,
+    /// then installs client reader / writer tasks locally via
+    /// `message_bus::installer::install_client_ws_fd`. Owning shard is
+    /// encoded in the top 16 bits of `meta.client_id`.
+    ///
+    /// QUIC clients deliberately do NOT get an analog variant: a
+    /// `compio_quic::Endpoint` binds one UDP socket and demuxes incoming
+    /// packets to per-connection `quinn-proto::Connection` objects by
+    /// Connection ID. Per-connection TLS / packet-number / congestion
+    /// state is non-serialisable and tied to the endpoint's reactor.
+    /// Shard 0 therefore terminates QUIC locally and uses the existing
+    /// `ForwardClientSend` variant for outbound traffic.
+    ClientWsConnectionSetup { fd: DupedFd, meta: ClientConnMeta },
     /// Shard 0 broadcasts the owner for a replica to every shard so each
     /// bus' `send_to_replica` slow path can route through the correct owner.
     ReplicaMappingUpdate { replica_id: u8, owning_shard: u16 },
diff --git a/core/shard/src/router.rs b/core/shard/src/router.rs
index 95c1be665..a3df7f2a0 100644
--- a/core/shard/src/router.rs
+++ b/core/shard/src/router.rs
@@ -189,8 +189,8 @@ where
         //   looked up via the [`ShardsTable`].
         // - Consensus control-plane (`StartViewChange`, `DoViewChange`,
         //   `StartView`, `Commit`) carries a raw `u64` consensus namespace;
-        //   hash-route it the same way partitions are looked up so every
-        //   node agrees on the owning shard.
+        //   route it via a deterministic hash function so every node agrees
+        //   on the owning shard without consulting the partitions table.
         let target = if operation.is_metadata() {
             0
         } else if operation.is_partition() {
@@ -307,15 +307,25 @@ where
                 self.bus
                     .install_replica_fd(fd, replica_id, self.on_replica_message.clone());
             }
-            crate::ShardFramePayload::ClientConnectionSetup { fd, client_id } => {
+            crate::ShardFramePayload::ClientConnectionSetup { fd, meta } => {
                 tracing::info!(
                     shard = self.id,
-                    client_id,
+                    client_id = meta.client_id,
                     raw_fd = fd.as_raw_fd(),
                     "installing delegated client fd"
                 );
                 self.bus
-                    .install_client_fd(fd, client_id, self.on_client_request.clone());
+                    .install_client_fd(fd, meta, self.on_client_request.clone());
+            }
+            crate::ShardFramePayload::ClientWsConnectionSetup { fd, meta } => {
+                tracing::info!(
+                    shard = self.id,
+                    client_id = meta.client_id,
+                    raw_fd = fd.as_raw_fd(),
+                    "installing delegated WS client fd (pre-upgrade)"
+                );
+                self.bus
+                    .install_client_ws_fd(fd, meta, self.on_client_request.clone());
             }
             crate::ShardFramePayload::ReplicaMappingUpdate {
                 replica_id,