You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by gk...@apache.org on 2026/07/07 11:05:16 UTC

(iggy) branch master updated: feat(server-ng): add cluster-correct HTTP listener + per-op RBAC (#3622)

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

numinnex pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new d892380ee feat(server-ng): add cluster-correct HTTP listener + per-op RBAC (#3622)
d892380ee is described below

commit d892380ee8b29b969773eab5451c51b3e3234334
Author: Hubert Gruszecki <h....@gmail.com>
AuthorDate: Tue Jul 7 13:05:11 2026 +0200

    feat(server-ng): add cluster-correct HTTP listener + per-op RBAC (#3622)
    
    server-ng had no HTTP transport, and the legacy HTTP stack is
    single-node-coupled with no notion of views, leaders, or replicated
    authorization.
    
    Make HTTP a regular client of the dispatch spine. Control writes
    await the committed reply; the data plane gets one new primitive, a
    request-keyed in-process reply sink on the connection registry.
    Auth is stateless JWT/PAT verification bound to a VSR session, with
    a per-session gate keeping request ids gap-free; HTTP control
    writes are at-least-once since retries mint fresh ids. Per-op RBAC
    is enforced in the metadata apply and at dispatch time on every
    transport. Reads default to serializable-local; linearizable reads
    serve on the primary or 307-redirect. submit_gated replays
    TransientNotCommitted server-side (HTTP has no client retry loop).
    Produce honors ack=none behind the same admission caps as the acked
    path.
    
    GetSnapshot is implemented across binary and HTTP:
    authorization-gated on read_servers || manage_servers from day one,
    collected on a dedicated OS thread so the compio event loop never
    stalls, single-flight, with a typed reject past the 64 MiB binary
    frame cap.
    
    Replication fixes ride along. Poll auto_commit offsets replicate
    through consensus with a monotone commit-apply, so a late
    fire-and-forget op cannot rewind the offset and re-deliver. The
    commit-path offset reader tolerates torn files instead of
    crash-looping. ChangePassword rejects a wrong current password as a
    committed no-op, keeping request ids contiguous. Username bounds
    are enforced inside the replicated apply, which raw clients cannot
    bypass.
    
    Parity: typed denies replace silent drops and fake successes, PAT
    framing is SDK-decodable, legacy login opcodes evict instead of
    stalling, re-login works on a live connection, GetClusterMetadata
    serves the real roster, the missing partition and segment HTTP
    routes are wired, create routes uniformly answer 200.
    
    In the test harness HTTP becomes a first-class VSR transport;
    authorization coverage consolidates into one permissions scenario
    asserting exact error codes over all four transports; snapshot
    tests and the mcp suite run fully under vsr.
---
 core/binary_protocol/src/consensus/header.rs       |   85 +-
 core/common/src/error/eviction.rs                  |   69 +
 core/common/src/error/mod.rs                       |    1 +
 core/common/src/lib.rs                             |    1 +
 core/common/src/traits/binary_impls/mod.rs         |   19 +
 .../traits/binary_impls/personal_access_tokens.rs  |    1 +
 core/common/src/traits/binary_impls/users.rs       |    1 +
 core/common/src/types/consumer/consumer_kind.rs    |    2 +-
 core/consensus/src/client_table.rs                 |  156 +-
 core/consensus/src/impls.rs                        |    4 +
 core/consensus/src/metadata_helpers.rs             |   14 +-
 core/consensus/src/plane_helpers.rs                |   93 +-
 core/harness_derive/src/codegen.rs                 |   15 +-
 core/integration/tests/data_integrity/mod.rs       |   12 +-
 .../verify_auto_commit_offset_replicates.rs        |  188 +++
 core/integration/tests/mcp/mod.rs                  |   16 +-
 .../tests/server/cluster_metadata_vsr.rs           |   76 +
 .../tests/server/concurrent_addition.rs            |   12 +-
 core/integration/tests/server/flush_vsr.rs         |   66 +
 core/integration/tests/server/general.rs           |   29 +-
 core/integration/tests/server/http_client.rs       |  188 +++
 core/integration/tests/server/http_rbac.rs         |  532 +++++++
 core/integration/tests/server/http_vsr.rs          |  843 +++++++++++
 core/integration/tests/server/legacy_login_vsr.rs  |  108 ++
 core/integration/tests/server/mod.rs               |   28 +-
 core/integration/tests/server/purge_delete.rs      |   21 +-
 core/integration/tests/server/scenarios/mod.rs     |    8 +-
 .../tests/server/scenarios/permissions_scenario.rs |  402 +++++
 core/message_bus/src/lib.rs                        |  106 +-
 .../src/lifecycle/connection_registry.rs           |  466 +++++-
 core/message_bus/src/lifecycle/mod.rs              |    2 +-
 core/metadata/src/impls/metadata.rs                |  302 +++-
 core/metadata/src/impls/recovery.rs                |    6 +-
 .../src/permissioner/permissioner_rules/system.rs  |    4 +
 core/metadata/src/stm/authz.rs                     |  760 ++++++++++
 core/metadata/src/stm/consumer_group.rs            |   52 +-
 core/metadata/src/stm/mod.rs                       |    1 +
 core/metadata/src/stm/result.rs                    |  101 +-
 core/metadata/src/stm/stream.rs                    |    8 +-
 core/metadata/src/stm/user.rs                      |  665 ++++++++-
 core/partitions/src/iggy_partition.rs              |  595 +++++++-
 core/partitions/src/lib.rs                         |    2 +-
 core/partitions/src/offset_storage.rs              |  145 +-
 core/partitions/src/poll_plan.rs                   |  258 ++--
 core/sdk/src/session.rs                            |   54 +-
 core/sdk/src/vsr.rs                                |  123 +-
 core/server-ng/Cargo.toml                          |    1 +
 core/server-ng/{server-ng.http => server.http}     |   35 +-
 core/server-ng/src/auth.rs                         |   63 +-
 core/server-ng/src/bootstrap.rs                    |   96 +-
 core/server-ng/src/cluster_meta.rs                 |  128 ++
 core/server-ng/src/dispatch.rs                     |  804 +++++++---
 core/server-ng/src/dispatch/authz.rs               |  360 +++++
 core/server-ng/src/http.rs                         |  239 +++
 core/server-ng/src/http/admission.rs               |  184 +++
 core/server-ng/src/http/error.rs                   |  558 +++++++
 core/server-ng/src/http/extractor.rs               |  160 ++
 core/server-ng/src/http/handlers.rs                | 1548 ++++++++++++++++++++
 core/server-ng/src/http/jwt.rs                     |  307 ++++
 core/server-ng/src/http/reads.rs                   |  184 +++
 core/server-ng/src/http/reply.rs                   |  425 ++++++
 core/server-ng/src/http/session.rs                 |  386 +++++
 core/server-ng/src/http/state.rs                   |  283 ++++
 core/server-ng/src/http/submit.rs                  |  404 +++++
 core/server-ng/src/http/wire.rs                    |  544 +++++++
 core/server-ng/src/lib.rs                          |    3 +
 core/server-ng/src/pat.rs                          |   22 +-
 core/server-ng/src/responses.rs                    |  609 ++++++--
 core/server-ng/src/session_manager.rs              |   19 +
 core/server-ng/src/snapshot.rs                     |  295 ++++
 core/server-ng/src/snapshot/procdump.rs            |  212 +++
 core/server-ng/src/users.rs                        |  156 +-
 core/shard/src/lib.rs                              |    1 +
 core/simulator/src/lib.rs                          |    5 +-
 core/simulator/src/replica.rs                      |    4 +
 core/simulator/src/workload/ops/change_password.rs |    6 +-
 .../src/workload/ops/create_consumer_group.rs      |   33 +-
 core/simulator/src/workload/ops/create_user.rs     |    5 +-
 core/simulator/src/workload/ops/delete_user.rs     |    7 +-
 .../src/workload/ops/update_permissions.rs         |    2 +
 core/simulator/src/workload/ops/update_user.rs     |    7 +-
 81 files changed, 13917 insertions(+), 818 deletions(-)

diff --git a/core/binary_protocol/src/consensus/header.rs b/core/binary_protocol/src/consensus/header.rs
index ff87bf05f..3913f5f58 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -151,7 +151,13 @@ pub struct RequestHeader {
     pub operation_padding: [u8; 7],
     pub namespace: u64,
     pub session: u64,
-    pub reserved: [u8; 56],
+    /// Acting user id, stamped by the metadata primary at admission for every
+    /// gated client op so the in-apply RBAC gate resolves the same identity on
+    /// every replica; on `Register` it carries the freshly authenticated user.
+    /// The submitter's wire value is never trusted. Zero for `Logout`,
+    /// partition-plane, and server-internal ops.
+    pub user_id: u32,
+    pub reserved: [u8; 52],
 }
 const _: () = {
     assert!(size_of::<RequestHeader>() == HEADER_SIZE);
@@ -159,7 +165,10 @@ const _: () = {
         offset_of!(RequestHeader, client)
             == offset_of!(RequestHeader, reserved_frame) + size_of::<[u8; 66]>()
     );
-    assert!(offset_of!(RequestHeader, reserved) + size_of::<[u8; 56]>() == HEADER_SIZE);
+    assert!(
+        offset_of!(RequestHeader, user_id) == offset_of!(RequestHeader, session) + size_of::<u64>()
+    );
+    assert!(offset_of!(RequestHeader, reserved) + size_of::<[u8; 52]>() == HEADER_SIZE);
 };
 
 impl Default for RequestHeader {
@@ -182,7 +191,8 @@ impl Default for RequestHeader {
             operation_padding: [0; 7],
             namespace: 0,
             session: 0,
-            reserved: [0; 56],
+            user_id: 0,
+            reserved: [0; 52],
         }
     }
 }
@@ -272,7 +282,20 @@ pub struct ReplyHeader {
     pub operation: Operation,
     pub operation_padding: [u8; 7],
     pub namespace: u64,
-    pub reserved: [u8; 32],
+    /// Request-level status: 0 = ok; nonzero = the `IggyError` code for a
+    /// failure decided before commit (e.g. a dispatch-time authorization
+    /// denial, or the partition primary rejecting a consumer-offset op).
+    ///
+    /// Contract: this is nonzero ONLY on a pre-commit denial, and a deny
+    /// reply always carries an EMPTY body. So this header channel and the
+    /// committed per-sub-op results in the metadata result section are mutually
+    /// exclusive by construction: a reply either commits (status 0, result
+    /// section present) or is denied before commit (status set, no body), and a
+    /// consumer never reconciles the two. Carved from `reserved` exactly like
+    /// `user_id` in `RequestHeader` / `PrepareHeader`; no existing field offset
+    /// moves and `validate` does not inspect it.
+    pub status: u32,
+    pub reserved: [u8; 28],
 }
 const _: () = {
     assert!(size_of::<ReplyHeader>() == HEADER_SIZE);
@@ -280,7 +303,10 @@ const _: () = {
         offset_of!(ReplyHeader, request_checksum)
             == offset_of!(ReplyHeader, reserved_frame) + size_of::<[u8; 66]>()
     );
-    assert!(offset_of!(ReplyHeader, reserved) + size_of::<[u8; 32]>() == HEADER_SIZE);
+    assert!(
+        offset_of!(ReplyHeader, status) == offset_of!(ReplyHeader, namespace) + size_of::<u64>()
+    );
+    assert!(offset_of!(ReplyHeader, reserved) + size_of::<[u8; 28]>() == HEADER_SIZE);
 };
 
 impl Default for ReplyHeader {
@@ -305,7 +331,8 @@ impl Default for ReplyHeader {
             operation: Operation::Reserved,
             operation_padding: [0; 7],
             namespace: 0,
-            reserved: [0; 32],
+            status: 0,
+            reserved: [0; 28],
         }
     }
 }
@@ -591,7 +618,10 @@ pub struct PrepareHeader {
     pub operation: Operation,
     pub operation_padding: [u8; 7],
     pub namespace: u64,
-    pub reserved: [u8; 32],
+    /// Acting user id, copied verbatim from the admitted `RequestHeader`; see
+    /// that field for the stamping contract.
+    pub user_id: u32,
+    pub reserved: [u8; 28],
 }
 const _: () = {
     assert!(size_of::<PrepareHeader>() == HEADER_SIZE);
@@ -599,7 +629,11 @@ const _: () = {
         offset_of!(PrepareHeader, client)
             == offset_of!(PrepareHeader, reserved_frame) + size_of::<[u8; 66]>()
     );
-    assert!(offset_of!(PrepareHeader, reserved) + size_of::<[u8; 32]>() == HEADER_SIZE);
+    assert!(
+        offset_of!(PrepareHeader, user_id)
+            == offset_of!(PrepareHeader, namespace) + size_of::<u64>()
+    );
+    assert!(offset_of!(PrepareHeader, reserved) + size_of::<[u8; 28]>() == HEADER_SIZE);
 };
 
 impl Default for PrepareHeader {
@@ -624,7 +658,8 @@ impl Default for PrepareHeader {
             operation: Operation::Reserved,
             operation_padding: [0; 7],
             namespace: 0,
-            reserved: [0; 32],
+            user_id: 0,
+            reserved: [0; 28],
         }
     }
 }
@@ -989,8 +1024,8 @@ impl ConsensusHeader for StartViewHeader {
 mod tests {
     use super::{
         Command2, CommitHeader, ConsensusHeader, DoViewChangeHeader, EvictionHeader,
-        EvictionReason, GenericHeader, Operation, PrepareHeader, PrepareOkHeader, ReplyHeader,
-        RequestHeader, StartViewChangeHeader, StartViewHeader,
+        EvictionReason, GenericHeader, HEADER_SIZE, Operation, PrepareHeader, PrepareOkHeader,
+        ReplyHeader, RequestHeader, StartViewChangeHeader, StartViewHeader,
     };
     use aligned_vec::{AVec, ConstAlign};
 
@@ -1112,6 +1147,34 @@ mod tests {
         assert!(header.validate().is_ok());
     }
 
+    // `status` is carved from the reserved tail; the SDK reply funnel peeks it
+    // at this offset before any body decode, so a layout drift must trip here.
+    #[test]
+    fn reply_header_status_offset_and_size_pinned() {
+        use std::mem::offset_of;
+        assert_eq!(size_of::<ReplyHeader>(), HEADER_SIZE);
+        assert_eq!(
+            offset_of!(ReplyHeader, status),
+            offset_of!(ReplyHeader, namespace) + size_of::<u64>()
+        );
+        assert_eq!(
+            offset_of!(ReplyHeader, reserved) + size_of::<[u8; 28]>(),
+            HEADER_SIZE
+        );
+    }
+
+    // A nonzero status rides the reserved region, which reply `validate` does
+    // not inspect: a status-bearing reply stays valid so the SDK can peek it.
+    #[test]
+    fn reply_header_nonzero_status_still_validates() {
+        let header = ReplyHeader {
+            command: Command2::Reply,
+            status: 41,
+            ..ReplyHeader::default()
+        };
+        assert!(header.validate().is_ok());
+    }
+
     // Wire-discriminant pin: any change breaks SDK decoders.
     #[test]
     fn eviction_reason_discriminants_pinned() {
diff --git a/core/common/src/error/eviction.rs b/core/common/src/error/eviction.rs
new file mode 100644
index 000000000..851027425
--- /dev/null
+++ b/core/common/src/error/eviction.rs
@@ -0,0 +1,69 @@
+// 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 grading of a wire [`EvictionReason`] to the typed [`IggyError`] an
+//! evicted session surfaces. The SDK's binary-transport Eviction-frame decoder
+//! (TCP / QUIC / WebSocket) and the server-ng HTTP write path both call this,
+//! so every transport sees one status per reason.
+
+use iggy_binary_protocol::consensus::EvictionReason;
+use iggy_binary_protocol::version::IGGY_PROTOCOL_VERSION;
+
+use super::iggy_error::IggyError;
+
+/// Map a session-terminal [`EvictionReason`], plus the accepted protocol window
+/// an `IncompatibleProtocol` frame carries, to the [`IggyError`] the caller
+/// renders. Session-terminal reasons collapse to re-authentication statuses;
+/// `IncompatibleProtocol` reports the exact window unless it is degenerate (a
+/// zero minimum or an inverted range), which also falls back to
+/// re-authentication.
+///
+/// The two callers extract the fields differently - server-ng from an aligned
+/// `EvictionHeader`, the SDK by wire offset off an unaligned buffer - then grade
+/// through here so the mappings cannot drift apart.
+#[must_use]
+pub fn eviction_reason_to_error(
+    reason: EvictionReason,
+    server_protocol_version: u32,
+    server_protocol_version_min: u32,
+) -> IggyError {
+    match reason {
+        EvictionReason::InvalidCredentials => IggyError::InvalidCredentials,
+        EvictionReason::InvalidToken => IggyError::InvalidPersonalAccessToken,
+        EvictionReason::UserInactive
+        | EvictionReason::SessionError
+        | EvictionReason::NoSession
+        | EvictionReason::SessionTooLow
+        | EvictionReason::SessionReleaseMismatch => IggyError::Unauthenticated,
+        EvictionReason::StaleClient => IggyError::StaleClient,
+        EvictionReason::IncompatibleProtocol => {
+            if server_protocol_version_min == 0
+                || server_protocol_version < server_protocol_version_min
+            {
+                IggyError::Unauthenticated
+            } else {
+                IggyError::IncompatibleProtocolVersion(
+                    IGGY_PROTOCOL_VERSION,
+                    server_protocol_version_min,
+                    server_protocol_version,
+                )
+            }
+        }
+        EvictionReason::MalformedLogin => IggyError::InvalidFormat,
+        _ => IggyError::InvalidCommand,
+    }
+}
diff --git a/core/common/src/error/mod.rs b/core/common/src/error/mod.rs
index 593dde577..ad3b557ed 100644
--- a/core/common/src/error/mod.rs
+++ b/core/common/src/error/mod.rs
@@ -16,6 +16,7 @@
 // under the License.
 
 pub(crate) mod client_error;
+pub mod eviction;
 pub(crate) mod iggy_error;
 
 // preserves existing internal import paths across
diff --git a/core/common/src/lib.rs b/core/common/src/lib.rs
index 9b8032043..12f139d2a 100644
--- a/core/common/src/lib.rs
+++ b/core/common/src/lib.rs
@@ -26,6 +26,7 @@ mod utils;
 pub mod wire_conversions;
 
 pub use error::client_error::ClientError;
+pub use error::eviction::eviction_reason_to_error;
 pub use error::iggy_error::{IggyError, IggyErrorDiscriminants};
 // Locking is feature gated, thus only mod level re-export.
 pub mod locking;
diff --git a/core/common/src/traits/binary_impls/mod.rs b/core/common/src/traits/binary_impls/mod.rs
index a10112253..7a7e7e6d4 100644
--- a/core/common/src/traits/binary_impls/mod.rs
+++ b/core/common/src/traits/binary_impls/mod.rs
@@ -31,6 +31,8 @@ use crate::IggyError;
 use crate::http::users::defaults::{
     MAX_PASSWORD_LENGTH, MAX_USERNAME_LENGTH, MIN_PASSWORD_LENGTH, MIN_USERNAME_LENGTH,
 };
+#[cfg(feature = "vsr")]
+use crate::{BinaryClient, ClientState};
 use iggy_binary_protocol::WireDecode;
 #[cfg(feature = "vsr")]
 use iggy_binary_protocol::{ClientVersionInfo, IGGY_PROTOCOL_VERSION, WireName};
@@ -52,6 +54,23 @@ pub(crate) fn rust_sdk_version_info(sdk_version: &str) -> Result<ClientVersionIn
     })
 }
 
+/// Release a live session before a fresh login on the same connection.
+///
+/// server-ng binds a connection to one `(client, session)` and resolves the
+/// acting user from that binding, not the wire header. A re-login on a still-
+/// bound connection is served as an idempotent replay that keeps the old
+/// identity, so switching users on a live connection requires logging out
+/// first: the committed Logout unbinds the connection cluster-wide, so the
+/// following Register takes the full register path and rebinds the newly
+/// authenticated user. No-op when the client is not authenticated.
+#[cfg(feature = "vsr")]
+pub(crate) async fn logout_before_relogin<B: BinaryClient>(client: &B) -> Result<(), IggyError> {
+    if client.get_state().await == ClientState::Authenticated {
+        client.logout_user().await?;
+    }
+    Ok(())
+}
+
 /// Same bounds and error every server (HTTP and binary) enforces, applied
 /// before encoding so an oversized password can never desync the u8 length
 /// prefix on the wire.
diff --git a/core/common/src/traits/binary_impls/personal_access_tokens.rs b/core/common/src/traits/binary_impls/personal_access_tokens.rs
index 01eb40a9f..202b4f41f 100644
--- a/core/common/src/traits/binary_impls/personal_access_tokens.rs
+++ b/core/common/src/traits/binary_impls/personal_access_tokens.rs
@@ -105,6 +105,7 @@ impl<B: BinaryClient> PersonalAccessTokenClient for B {
     ) -> Result<IdentityInfo, IggyError> {
         #[cfg(feature = "vsr")]
         {
+            super::logout_before_relogin(self).await?;
             // Same bounds the non-vsr branch gets from `WireName::new(token)`;
             // the request stores a `SecretString`, so enforce them here to keep
             // the u8 length prefix consistent with the realized bytes.
diff --git a/core/common/src/traits/binary_impls/users.rs b/core/common/src/traits/binary_impls/users.rs
index 37bc907b7..1a7f15b70 100644
--- a/core/common/src/traits/binary_impls/users.rs
+++ b/core/common/src/traits/binary_impls/users.rs
@@ -189,6 +189,7 @@ impl<B: BinaryClient> UserClient for B {
         super::validate_password(password)?;
         #[cfg(feature = "vsr")]
         {
+            super::logout_before_relogin(self).await?;
             let wire_name = WireName::new(username).map_err(|_| IggyError::InvalidFormat)?;
             let response = match self
                 .send_raw_with_response(
diff --git a/core/common/src/types/consumer/consumer_kind.rs b/core/common/src/types/consumer/consumer_kind.rs
index bf68c6e48..60a33cb20 100644
--- a/core/common/src/types/consumer/consumer_kind.rs
+++ b/core/common/src/types/consumer/consumer_kind.rs
@@ -42,7 +42,7 @@ pub struct Consumer {
 }
 
 /// `ConsumerKind` is an enum that represents the type of consumer.
-#[derive(Debug, Serialize, Deserialize, PartialEq, Default, Copy, Clone, ValueEnum)]
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Default, Copy, Clone, ValueEnum)]
 #[serde(rename_all = "snake_case")]
 pub enum ConsumerKind {
     /// `Consumer` represents a regular consumer.
diff --git a/core/consensus/src/client_table.rs b/core/consensus/src/client_table.rs
index cf582de65..ed517d0f2 100644
--- a/core/consensus/src/client_table.rs
+++ b/core/consensus/src/client_table.rs
@@ -79,6 +79,9 @@ pub struct ClientEntry {
     /// Session number = commit op of the register. Monotonic across
     /// registrations; new register always gets a higher session.
     pub session: u64,
+    /// Acting user id captured at register. Fixed for the entry's lifetime;
+    /// lets every replica resolve session -> user without a metadata lookup.
+    pub user_id: u32,
     /// Cached reply for client's latest committed request.
     pub reply: CachedReply,
 }
@@ -241,8 +244,13 @@ impl ClientTable {
     /// # Panics
     /// If `client_id == 0`, `session == 0`, or `client_id != reply.header().client`.
     /// Session mismatch does NOT panic.
-    pub fn commit_register<F>(&mut self, client_id: u128, reply: Message<ReplyHeader>, in_flight: F)
-    where
+    pub fn commit_register<F>(
+        &mut self,
+        client_id: u128,
+        user_id: u32,
+        reply: Message<ReplyHeader>,
+        in_flight: F,
+    ) where
         F: Fn(u128) -> bool,
     {
         assert!(client_id != 0, "client_id 0 is reserved for internal use");
@@ -291,6 +299,7 @@ impl ClientTable {
             let slot_idx = self.first_free_slot().expect("eviction must free a slot");
             self.slots[slot_idx] = Some(ClientEntry {
                 session,
+                user_id,
                 reply: cached,
             });
             self.index.insert(client_id, slot_idx);
@@ -462,6 +471,13 @@ impl ClientTable {
         self.slots[slot_idx].as_ref().map(|entry| entry.session)
     }
 
+    /// Acting user id captured when the client registered.
+    #[must_use]
+    pub fn get_user_id(&self, client_id: u128) -> Option<u32> {
+        let &slot_idx = self.index.get(&client_id)?;
+        self.slots[slot_idx].as_ref().map(|entry| entry.user_id)
+    }
+
     /// Active committed entries.
     #[must_use]
     pub fn count(&self) -> usize {
@@ -474,6 +490,10 @@ mod tests {
     use super::*;
     use iggy_binary_protocol::{Command2, Operation};
 
+    /// Arbitrary non-zero user id for register fixtures; most tests don't
+    /// assert on it (see `register_stores_user_id` for the accessor check).
+    const TEST_USER_ID: u32 = 7;
+
     fn make_register_reply(client: u128, commit: u64) -> Message<ReplyHeader> {
         let header_size = std::mem::size_of::<ReplyHeader>();
         let mut msg = Message::<ReplyHeader>::new(header_size);
@@ -519,7 +539,12 @@ mod tests {
     fn table_with_client() -> (ClientTable, u64) {
         let mut table = ClientTable::new(10);
         let session = 10;
-        table.commit_register(1, make_register_reply(1, session), no_in_flight());
+        table.commit_register(
+            1,
+            TEST_USER_ID,
+            make_register_reply(1, session),
+            no_in_flight(),
+        );
         (table, session)
     }
 
@@ -528,11 +553,27 @@ mod tests {
     #[test]
     fn register_creates_session() {
         let mut table = ClientTable::new(10);
-        table.commit_register(1, make_register_reply(1, 42), no_in_flight());
+        table.commit_register(1, TEST_USER_ID, make_register_reply(1, 42), no_in_flight());
         assert_eq!(table.get_session(1), Some(42));
+        assert_eq!(table.get_user_id(1), Some(TEST_USER_ID));
         assert_eq!(table.count(), 1);
     }
 
+    // Each entry keeps the user id it registered with; lookups are per-client.
+    #[test]
+    fn register_stores_user_id() {
+        let mut table = ClientTable::new(10);
+        table.commit_register(1, 11, make_register_reply(1, 10), no_in_flight());
+        table.commit_register(2, 22, make_register_reply(2, 20), no_in_flight());
+        assert_eq!(table.get_user_id(1), Some(11));
+        assert_eq!(table.get_user_id(2), Some(22));
+        assert_eq!(
+            table.get_user_id(3),
+            None,
+            "unregistered client has no user"
+        );
+    }
+
     #[test]
     fn check_register_new_client() {
         let table = ClientTable::new(10);
@@ -696,9 +737,24 @@ mod tests {
     #[test]
     fn eviction_removes_oldest_commit() {
         let mut table = ClientTable::new(2);
-        table.commit_register(100, make_register_reply(100, 10), no_in_flight());
-        table.commit_register(200, make_register_reply(200, 20), no_in_flight());
-        table.commit_register(300, make_register_reply(300, 30), no_in_flight());
+        table.commit_register(
+            100,
+            TEST_USER_ID,
+            make_register_reply(100, 10),
+            no_in_flight(),
+        );
+        table.commit_register(
+            200,
+            TEST_USER_ID,
+            make_register_reply(200, 20),
+            no_in_flight(),
+        );
+        table.commit_register(
+            300,
+            TEST_USER_ID,
+            make_register_reply(300, 30),
+            no_in_flight(),
+        );
         assert!(table.get_reply(100).is_none());
         assert!(table.get_reply(200).is_some());
         assert!(table.get_reply(300).is_some());
@@ -708,9 +764,24 @@ mod tests {
     #[test]
     fn eviction_is_deterministic_by_slot_index() {
         let mut table = ClientTable::new(2);
-        table.commit_register(100, make_register_reply(100, 10), no_in_flight());
-        table.commit_register(200, make_register_reply(200, 10), no_in_flight());
-        table.commit_register(300, make_register_reply(300, 30), no_in_flight());
+        table.commit_register(
+            100,
+            TEST_USER_ID,
+            make_register_reply(100, 10),
+            no_in_flight(),
+        );
+        table.commit_register(
+            200,
+            TEST_USER_ID,
+            make_register_reply(200, 10),
+            no_in_flight(),
+        );
+        table.commit_register(
+            300,
+            TEST_USER_ID,
+            make_register_reply(300, 30),
+            no_in_flight(),
+        );
         assert!(table.get_reply(100).is_none());
         assert!(table.get_reply(200).is_some());
         assert!(table.get_reply(300).is_some());
@@ -719,8 +790,18 @@ mod tests {
     #[test]
     fn slot_reuse_after_eviction() {
         let mut table = ClientTable::new(1);
-        table.commit_register(100, make_register_reply(100, 10), no_in_flight());
-        table.commit_register(200, make_register_reply(200, 20), no_in_flight());
+        table.commit_register(
+            100,
+            TEST_USER_ID,
+            make_register_reply(100, 10),
+            no_in_flight(),
+        );
+        table.commit_register(
+            200,
+            TEST_USER_ID,
+            make_register_reply(200, 20),
+            no_in_flight(),
+        );
         assert!(table.get_reply(100).is_none());
         assert!(table.get_reply(200).is_some());
         assert_eq!(table.count(), 1);
@@ -732,11 +813,21 @@ mod tests {
     #[test]
     fn eviction_skips_in_flight_clients() {
         let mut table = ClientTable::new(2);
-        table.commit_register(100, make_register_reply(100, 10), no_in_flight());
-        table.commit_register(200, make_register_reply(200, 20), no_in_flight());
+        table.commit_register(
+            100,
+            TEST_USER_ID,
+            make_register_reply(100, 10),
+            no_in_flight(),
+        );
+        table.commit_register(
+            200,
+            TEST_USER_ID,
+            make_register_reply(200, 20),
+            no_in_flight(),
+        );
         // 100 in-flight; eviction must pick 200.
         let in_flight = |c: u128| c == 100;
-        table.commit_register(300, make_register_reply(300, 30), in_flight);
+        table.commit_register(300, TEST_USER_ID, make_register_reply(300, 30), in_flight);
         assert!(
             table.get_reply(100).is_some(),
             "in-flight client must survive"
@@ -753,10 +844,25 @@ mod tests {
     #[test]
     fn eviction_falls_back_to_oldest_when_all_in_flight() {
         let mut table = ClientTable::new(2);
-        table.commit_register(100, make_register_reply(100, 10), no_in_flight());
-        table.commit_register(200, make_register_reply(200, 20), no_in_flight());
+        table.commit_register(
+            100,
+            TEST_USER_ID,
+            make_register_reply(100, 10),
+            no_in_flight(),
+        );
+        table.commit_register(
+            200,
+            TEST_USER_ID,
+            make_register_reply(200, 20),
+            no_in_flight(),
+        );
         let all_in_flight = |_| true;
-        table.commit_register(300, make_register_reply(300, 30), all_in_flight);
+        table.commit_register(
+            300,
+            TEST_USER_ID,
+            make_register_reply(300, 30),
+            all_in_flight,
+        );
         assert!(
             table.get_reply(100).is_none(),
             "100 evicted (oldest fallback)"
@@ -770,9 +876,9 @@ mod tests {
     #[test]
     fn commit_register_idempotent_on_replay() {
         let mut table = ClientTable::new(10);
-        table.commit_register(1, make_register_reply(1, 10), no_in_flight());
+        table.commit_register(1, TEST_USER_ID, make_register_reply(1, 10), no_in_flight());
         // Same client_id + session = idempotent (WAL replay).
-        table.commit_register(1, make_register_reply(1, 10), no_in_flight());
+        table.commit_register(1, TEST_USER_ID, make_register_reply(1, 10), no_in_flight());
         assert_eq!(table.get_session(1), Some(10));
         assert_eq!(table.count(), 1);
     }
@@ -783,12 +889,12 @@ mod tests {
     #[test]
     fn commit_register_different_session_logs_and_skips() {
         let mut table = ClientTable::new(10);
-        table.commit_register(1, make_register_reply(1, 10), no_in_flight());
+        table.commit_register(1, TEST_USER_ID, make_register_reply(1, 10), no_in_flight());
         // existing=10, replay=20.
-        table.commit_register(1, make_register_reply(1, 20), no_in_flight());
+        table.commit_register(1, TEST_USER_ID, make_register_reply(1, 20), no_in_flight());
         assert_eq!(table.get_session(1), Some(10), "first session stays");
         // Smaller replay session: same skip.
-        table.commit_register(1, make_register_reply(1, 5), no_in_flight());
+        table.commit_register(1, TEST_USER_ID, make_register_reply(1, 5), no_in_flight());
         assert_eq!(table.get_session(1), Some(10));
     }
 
@@ -814,8 +920,8 @@ mod tests {
     #[test]
     fn different_clients_independent_sessions() {
         let mut table = ClientTable::new(10);
-        table.commit_register(1, make_register_reply(1, 10), no_in_flight());
-        table.commit_register(2, make_register_reply(2, 20), no_in_flight());
+        table.commit_register(1, TEST_USER_ID, make_register_reply(1, 10), no_in_flight());
+        table.commit_register(2, TEST_USER_ID, make_register_reply(2, 20), no_in_flight());
         assert_eq!(table.get_session(1), Some(10));
         assert_eq!(table.get_session(2), Some(20));
         assert!(matches!(table.check_request(1, 10, 1), RequestStatus::New));
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 53bb8aaac..94be74bf1 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -2108,6 +2108,10 @@ where
                 timestamp,
                 operation: old.operation,
                 namespace: old.namespace,
+                // Copied verbatim: carries the stamped acting user for client
+                // ops (and the authenticated user on Register), so the in-apply
+                // RBAC gate resolves the same identity on every backup.
+                user_id: old.user_id,
                 ..Default::default()
             }
         })
diff --git a/core/consensus/src/metadata_helpers.rs b/core/consensus/src/metadata_helpers.rs
index b2a4ecfdc..8a180c37c 100644
--- a/core/consensus/src/metadata_helpers.rs
+++ b/core/consensus/src/metadata_helpers.rs
@@ -414,6 +414,10 @@ mod tests {
     use iggy_binary_protocol::{Command2, Operation, ReplyHeader};
     use message_bus::SendError;
 
+    /// Acting user for register fixtures; these tests exercise preflight /
+    /// replay, not user resolution, so the exact value is immaterial.
+    const ACTING_USER_ID: u32 = 1;
+
     /// Production-sized `ClientTable`.
     fn fresh_client_table() -> RefCell<ClientTable> {
         RefCell::new(ClientTable::new(CLIENTS_TABLE_MAX))
@@ -475,7 +479,7 @@ mod tests {
         let original_checksum = initial_reply.header().checksum;
         client_table
             .borrow_mut()
-            .commit_register(client_id, initial_reply, |_| false);
+            .commit_register(client_id, ACTING_USER_ID, initial_reply, |_| false);
 
         let result =
             futures::executor::block_on(register_preflight(&consensus, &client_table, client_id));
@@ -511,7 +515,7 @@ mod tests {
         let initial_reply = synthesize_register_reply(&consensus, client_id, session);
         client_table
             .borrow_mut()
-            .commit_register(client_id, initial_reply, |_| false);
+            .commit_register(client_id, ACTING_USER_ID, initial_reply, |_| false);
 
         // SendMessages commits -> cached is no longer the register reply.
         let app_reply = synthesize_send_messages_reply(&consensus, client_id, session, 1, 18);
@@ -579,7 +583,7 @@ mod tests {
         let initial_reply = synthesize_register_reply(&consensus, client_id, real_session);
         client_table
             .borrow_mut()
-            .commit_register(client_id, initial_reply, |_| false);
+            .commit_register(client_id, ACTING_USER_ID, initial_reply, |_| false);
 
         // Older retry (17 < 99): stale-session case.
         let result = futures::executor::block_on(apply_preflight_consensus_plane(
@@ -615,7 +619,7 @@ mod tests {
         let initial_reply = synthesize_register_reply(&consensus, client_id, real_session);
         client_table
             .borrow_mut()
-            .commit_register(client_id, initial_reply, |_| false);
+            .commit_register(client_id, ACTING_USER_ID, initial_reply, |_| false);
 
         // Client claims newer session (99 > 17), client bug.
         let result = futures::executor::block_on(apply_preflight_consensus_plane(
@@ -677,7 +681,7 @@ mod tests {
         let initial_reply = synthesize_register_reply(&consensus, client_id, session);
         client_table
             .borrow_mut()
-            .commit_register(client_id, initial_reply, |_| false);
+            .commit_register(client_id, ACTING_USER_ID, initial_reply, |_| false);
         // Cache at request 5 -> request 3 is stale.
         let advanced = synthesize_send_messages_reply(&consensus, client_id, session, 5, 100);
         client_table
diff --git a/core/consensus/src/plane_helpers.rs b/core/consensus/src/plane_helpers.rs
index 32f027e37..0b454c078 100644
--- a/core/consensus/src/plane_helpers.rs
+++ b/core/consensus/src/plane_helpers.rs
@@ -298,9 +298,7 @@ where
     let total_size = header_size + body_len;
     let mut buffer = bytes::BytesMut::zeroed(total_size);
 
-    let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(&mut buffer[..header_size])
-        .expect("zeroed bytes are valid");
-    *header = ReplyHeader {
+    let header = ReplyHeader {
         checksum: 0,
         checksum_body: 0,
         cluster: prepare_header.cluster,
@@ -325,6 +323,8 @@ where
         namespace: prepare_header.namespace,
         ..Default::default()
     };
+    // `BytesMut` makes no alignment guarantee, so never cast into it.
+    buffer[..header_size].copy_from_slice(bytemuck::bytes_of(&header));
 
     fill(&mut buffer[header_size..]);
 
@@ -341,9 +341,9 @@ where
 /// request that could not be committed *right now* (not-caught-up / in-flight
 /// / pipeline-full / view-change cancel); the SDK decodes it and replays the
 /// same `request_id` immediately instead of waiting out its response
-/// read-timeout. Any other code is a TERMINAL rejection (e.g.
-/// `ConsumerOffsetNotFound` / `InvalidOffset` from partition-plane admission)
-/// that the SDK surfaces as the typed error.
+/// read-timeout. Any other code is a TERMINAL rejection (e.g. a committed
+/// metadata rejection like `UserAlreadyExists`) that the SDK surfaces as the
+/// typed error.
 ///
 /// Stamped from the request header (no prepare exists for a rejected op).
 /// `commit` is the primary's current commit position and is informational
@@ -366,9 +366,7 @@ pub fn build_result_rejection_reply(
     let total_size = header_size + RESULT_BODY_LEN;
     let mut buffer = bytes::BytesMut::zeroed(total_size);
 
-    let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(&mut buffer[..header_size])
-        .expect("zeroed bytes are valid");
-    *header = ReplyHeader {
+    let header = ReplyHeader {
         cluster: request_header.cluster,
         size: total_size as u32,
         view: request_header.view,
@@ -388,6 +386,7 @@ pub fn build_result_rejection_reply(
         namespace: request_header.namespace,
         ..Default::default()
     };
+    buffer[..header_size].copy_from_slice(bytemuck::bytes_of(&header));
 
     let body = &mut buffer[header_size..];
     body[0..4].copy_from_slice(&1u32.to_le_bytes());
@@ -420,9 +419,7 @@ where
     let mut buffer = bytes::BytesMut::zeroed(total_size);
 
     let commit = consensus.commit_max();
-    let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(&mut buffer[..header_size])
-        .expect("zeroed bytes are valid");
-    *header = ReplyHeader {
+    let header = ReplyHeader {
         checksum: 0,
         checksum_body: 0,
         cluster: consensus.cluster(),
@@ -443,6 +440,7 @@ where
         namespace: request_header.namespace,
         ..Default::default()
     };
+    buffer[..header_size].copy_from_slice(bytemuck::bytes_of(&header));
 
     if !body.is_empty() {
         buffer[header_size..].copy_from_slice(&body);
@@ -452,6 +450,37 @@ where
         .expect("reply buffer must contain a valid reply message")
 }
 
+/// Reply that denies a request on the primary before it enters the VSR
+/// pipeline.
+///
+/// The request's frame with an empty body and a nonzero `ReplyHeader.status`
+/// (the request-level error channel the SDK peeks before body decode).
+/// Nothing is prepared or replicated, so backups never see the denied
+/// request; `op` stays 0, the shape reply consumers already read as "nothing
+/// committed".
+///
+/// # Panics
+/// If the constructed message buffer is not valid.
+pub fn build_deny_reply_from_request<B, P>(
+    consensus: &VsrConsensus<B, P>,
+    request_header: &RequestHeader,
+    status: u32,
+) -> Message<ReplyHeader>
+where
+    B: MessageBus,
+    P: Pipeline<Entry = PipelineEntry>,
+{
+    let mut reply = build_reply_from_request(consensus, request_header, bytes::Bytes::new());
+    let header_size = std::mem::size_of::<ReplyHeader>();
+    let header_bytes = &mut reply.as_mut_slice()[..header_size];
+    let mut header = bytemuck::checked::try_pod_read_unaligned::<ReplyHeader>(header_bytes)
+        .expect("freshly built reply header is valid");
+    header.status = status;
+    header.op = 0;
+    header_bytes.copy_from_slice(bytemuck::bytes_of(&header));
+    reply
+}
+
 /// Verify hash chain would not break if we add this header.
 ///
 /// # Panics
@@ -539,7 +568,7 @@ pub async fn send_prepare_ok<B, P>(
 mod tests {
     use super::*;
     use crate::{Consensus, LocalPipeline, VsrAction};
-    use iggy_binary_protocol::StartViewChangeHeader;
+    use iggy_binary_protocol::{ConsensusHeader, Operation, StartViewChangeHeader};
     use message_bus::SendError;
     use server_common::{MESSAGE_ALIGN, iobuf::Frozen};
 
@@ -984,4 +1013,42 @@ mod tests {
             Some(7)
         );
     }
+
+    // A deny echoes the request frame with an empty body, carries the error
+    // code in `status`, and keeps `op` at 0 even when the group has committed
+    // ops -- reply consumers read `op == 0` as "nothing committed", and the
+    // deny must never be mistaken for a committed reply.
+    #[test]
+    fn deny_reply_from_request_stamps_status_and_commits_nothing() {
+        let consensus = VsrConsensus::new(1, 0, 3, 0, NoopBus, LocalPipeline::new());
+        consensus.init();
+        consensus.advance_commit_max(4);
+
+        let request = RequestHeader {
+            command: Command2::Request,
+            operation: Operation::DeleteConsumerOffset2,
+            client: 42,
+            request: 7,
+            namespace: 9,
+            ..Default::default()
+        };
+        let status = 3021;
+        let reply = build_deny_reply_from_request(&consensus, &request, status);
+
+        let header = reply.header();
+        assert_eq!(header.command, Command2::Reply);
+        assert_eq!(header.status, status);
+        assert_eq!(header.op, 0, "a deny commits nothing");
+        assert_eq!(header.commit, 4);
+        assert_eq!(header.client, 42);
+        assert_eq!(header.request, 7);
+        assert_eq!(header.namespace, 9);
+        assert_eq!(header.operation, Operation::DeleteConsumerOffset2);
+        assert_eq!(
+            header.size as usize,
+            std::mem::size_of::<ReplyHeader>(),
+            "deny reply body must be empty"
+        );
+        assert!(header.validate().is_ok());
+    }
 }
diff --git a/core/harness_derive/src/codegen.rs b/core/harness_derive/src/codegen.rs
index d31d48e90..95e8e987b 100644
--- a/core/harness_derive/src/codegen.rs
+++ b/core/harness_derive/src/codegen.rs
@@ -124,16 +124,11 @@ fn generate_variants(attrs: &IggyTestAttrs) -> Vec<TestVariant> {
     variants
 }
 
-/// HTTP is not served by the next-gen (VSR) server, so HTTP transport variants
-/// must not compile into the test binary under `--features vsr`. The proc macro
-/// cannot read the consuming crate's feature flags at expansion time, so it
-/// emits this `cfg` gate on HTTP variants; the `integration` crate resolves it.
-fn vsr_transport_cfg(transport: Transport) -> TokenStream {
-    if matches!(transport, Transport::Http) {
-        quote!(#[cfg(not(feature = "vsr"))])
-    } else {
-        quote!()
-    }
+/// No transport is gated out of the VSR test matrix. Retained as the single
+/// seam where a transport could be excluded from `--features vsr` if one is
+/// ever unsupported by the next-gen server again.
+fn vsr_transport_cfg(_transport: Transport) -> TokenStream {
+    quote!()
 }
 
 /// Generate test code from attributes and input function.
diff --git a/core/integration/tests/data_integrity/mod.rs b/core/integration/tests/data_integrity/mod.rs
index 6201bc744..ed4f0c1e2 100644
--- a/core/integration/tests/data_integrity/mod.rs
+++ b/core/integration/tests/data_integrity/mod.rs
@@ -21,10 +21,13 @@
 #[cfg(not(feature = "vsr"))]
 mod verify_after_server_restart;
 #[cfg(not(feature = "vsr"))]
-mod verify_no_plaintext_credentials_on_disk;
-#[cfg(not(feature = "vsr"))]
 mod verify_user_login_after_restart;
 
+// Not restart-based: it creates a user + PAT, stops the server, and greps the
+// data dir for plaintext. No replica catch-up needed, and server-ng hashes the
+// password / PAT before either reaches the WAL, so it runs under vsr too.
+mod verify_no_plaintext_credentials_on_disk;
+
 // The cooperative-rebalance matrix runs under vsr too: it exercises server-ng's
 // consumer-group rebalancing (a VSR feature) and never restarts the cluster, so
 // the no-state-transfer limitation above does not apply. Green at 95/95.
@@ -33,3 +36,8 @@ mod verify_consumer_group_partition_assignment;
 // Cross-replica on-disk data identity is VSR-only.
 #[cfg(feature = "vsr")]
 mod verify_cluster_replica_data_identical;
+
+// Auto-commit offset replication is inherently a multi-node (VSR) property: the
+// backup only holds the offset if the poll's auto-commit rode consensus.
+#[cfg(feature = "vsr")]
+mod verify_auto_commit_offset_replicates;
diff --git a/core/integration/tests/data_integrity/verify_auto_commit_offset_replicates.rs b/core/integration/tests/data_integrity/verify_auto_commit_offset_replicates.rs
new file mode 100644
index 000000000..6f26dd983
--- /dev/null
+++ b/core/integration/tests/data_integrity/verify_auto_commit_offset_replicates.rs
@@ -0,0 +1,188 @@
+// 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.
+
+//! A poll with `auto_commit = true` must replicate the consumer offset through
+//! the partition consensus, not merely persist it on the serving node -- else a
+//! failover to a backup silently loses the committed offset.
+//!
+//! Discriminator: drive the poll+auto_commit on the primary (node 0) and read
+//! the offset from a backup's (node 1) on-disk store. The backup holds it only
+//! if the commit rode consensus; the pre-fix node-local persist never reaches
+//! it, so the file stays absent until the deadline and the test fails.
+
+use iggy::prelude::*;
+use integration::harness::TestHarness;
+use integration::iggy_harness;
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+use tokio::time::sleep;
+
+const STREAM_NAME: &str = "auto-commit-repl-stream";
+const TOPIC_NAME: &str = "auto-commit-repl-topic";
+const PARTITION_ID: u32 = 0;
+// Explicit numeric consumer id shared by the poll and the offset read;
+// `Consumer::default()` would carry id 0.
+const CONSUMER_ID: u32 = 7;
+const MESSAGE_COUNT: u32 = 10;
+// Offset of the last message after polling all of `0..MESSAGE_COUNT` from 0.
+const EXPECTED_OFFSET: u64 = (MESSAGE_COUNT - 1) as u64;
+// The primary answers the poll before the auto-commit replicates, so allow the
+// backup a bounded window to catch up rather than a fixed settle sleep.
+const REPLICATION_TIMEOUT: Duration = Duration::from_secs(15);
+const REPLICATION_POLL_INTERVAL: Duration = Duration::from_millis(100);
+
+#[iggy_harness(cluster_nodes = 3)]
+async fn given_cluster_when_poll_auto_commits_then_offset_replicates_to_backup(
+    harness: &TestHarness,
+) {
+    run(harness).await;
+}
+
+async fn run(harness: &TestHarness) {
+    let stream = Identifier::named(STREAM_NAME).unwrap();
+    let topic = Identifier::named(TOPIC_NAME).unwrap();
+    let consumer = Consumer::new(Identifier::numeric(CONSUMER_ID).unwrap());
+
+    // Primary (node 0, the view-0 primary): create the topic, produce, then poll
+    // with auto_commit so the offset is committed server-side.
+    let primary = harness.tcp_root_client().await.unwrap();
+    primary.create_stream(STREAM_NAME).await.unwrap();
+    primary
+        .create_topic(
+            &stream,
+            TOPIC_NAME,
+            1,
+            CompressionAlgorithm::None,
+            None,
+            IggyExpiry::NeverExpire,
+            MaxTopicSize::ServerDefault,
+        )
+        .await
+        .unwrap();
+
+    let mut messages: Vec<IggyMessage> = (0..MESSAGE_COUNT)
+        .map(|i| {
+            IggyMessage::builder()
+                .payload(format!("auto-commit-{i}").into())
+                .build()
+                .unwrap()
+        })
+        .collect();
+    primary
+        .send_messages(
+            &stream,
+            &topic,
+            &Partitioning::partition_id(PARTITION_ID),
+            &mut messages,
+        )
+        .await
+        .unwrap();
+
+    let polled = primary
+        .poll_messages(
+            &stream,
+            &topic,
+            Some(PARTITION_ID),
+            &consumer,
+            &PollingStrategy::offset(0),
+            MESSAGE_COUNT,
+            true,
+        )
+        .await
+        .unwrap();
+    assert_eq!(
+        polled.messages.len(),
+        MESSAGE_COUNT as usize,
+        "poll must return every produced message so the auto-commit has an offset to store",
+    );
+
+    // Sanity: the serving node holds its own committed offset (eager in-memory
+    // apply). Passes on both the buggy and fixed paths -- the discriminator is
+    // the backup below.
+    let on_primary = primary
+        .get_consumer_offset(&consumer, &stream, &topic, Some(PARTITION_ID))
+        .await
+        .unwrap();
+    assert_eq!(
+        on_primary.map(|info| info.stored_offset),
+        Some(EXPECTED_OFFSET),
+        "the serving node must hold the auto-committed offset",
+    );
+
+    // Discriminator: the backup's on-disk consumer-offset store. A backup does
+    // not serve client logins (Register is a primary-only metadata op), so read
+    // its data dir directly. Only a consensus-replicated auto-commit lands there
+    // -- the pre-fix node-local persist never reaches the backup, so the file
+    // stays absent until the deadline and the test fails.
+    let backup_dir = harness.node(1).data_path();
+    let deadline = tokio::time::Instant::now() + REPLICATION_TIMEOUT;
+    loop {
+        if let Some(stored) = read_replicated_consumer_offset(&backup_dir) {
+            assert_eq!(
+                stored, EXPECTED_OFFSET,
+                "backup replicated a wrong auto-commit offset",
+            );
+            return;
+        }
+        assert!(
+            tokio::time::Instant::now() < deadline,
+            "auto-commit offset never replicated to the backup within {REPLICATION_TIMEOUT:?}; \
+             it stayed node-local (the bug)",
+        );
+        sleep(REPLICATION_POLL_INTERVAL).await;
+    }
+}
+
+/// The u64 offset persisted under any `offsets/consumers/<id>` file in a node's
+/// data dir, or `None` when no such file has been written yet. Walks the tree so
+/// it is robust to the stream/topic/partition id layout; the test drives exactly
+/// one consumer, so at most one such file exists. A zero-length read (persist
+/// truncates before writing the 8 bytes) is treated as not-yet-written.
+fn read_replicated_consumer_offset(data_dir: &Path) -> Option<u64> {
+    let mut stack: Vec<PathBuf> = vec![data_dir.to_path_buf()];
+    while let Some(dir) = stack.pop() {
+        let Ok(entries) = std::fs::read_dir(&dir) else {
+            continue;
+        };
+        for entry in entries.flatten() {
+            let path = entry.path();
+            let Ok(file_type) = entry.file_type() else {
+                continue;
+            };
+            if file_type.is_dir() {
+                stack.push(path);
+                continue;
+            }
+            let is_consumer_offset = path
+                .parent()
+                .and_then(Path::file_name)
+                .is_some_and(|name| name == "consumers")
+                && path
+                    .parent()
+                    .and_then(Path::parent)
+                    .and_then(Path::file_name)
+                    .is_some_and(|name| name == "offsets");
+            if is_consumer_offset
+                && let Ok(bytes) = std::fs::read(&path)
+                && let Ok(array) = <[u8; 8]>::try_from(bytes.as_slice())
+            {
+                return Some(u64::from_le_bytes(array));
+            }
+        }
+    }
+    None
+}
diff --git a/core/integration/tests/mcp/mod.rs b/core/integration/tests/mcp/mod.rs
index 6d2f74376..5468c273e 100644
--- a/core/integration/tests/mcp/mod.rs
+++ b/core/integration/tests/mcp/mod.rs
@@ -16,12 +16,11 @@
 // under the License.
 
 use iggy_common::{
-    ClientInfo, ClientInfoDetails, ConsumerGroup, ConsumerGroupDetails, ConsumerOffsetInfo,
-    PersonalAccessTokenExpiry, PersonalAccessTokenInfo, PolledMessages, RawPersonalAccessToken,
-    Stats, Stream, StreamDetails, Topic, TopicDetails, UserInfo, UserInfoDetails,
+    ClientInfo, ClientInfoDetails, ClusterMetadata, ConsumerGroup, ConsumerGroupDetails,
+    ConsumerOffsetInfo, PersonalAccessTokenExpiry, PersonalAccessTokenInfo, PolledMessages,
+    RawPersonalAccessToken, Snapshot, Stats, Stream, StreamDetails, Topic, TopicDetails, UserInfo,
+    UserInfoDetails,
 };
-#[cfg(not(feature = "vsr"))]
-use iggy_common::{ClusterMetadata, Snapshot};
 use integration::{
     harness::{McpClient, seeds},
     iggy_harness,
@@ -86,8 +85,10 @@ async fn should_handle_ping(harness: &TestHarness) {
     invoke_empty(&mcp_client, "ping", None).await;
 }
 
-#[cfg(not(feature = "vsr"))]
-#[iggy_harness(server(cluster.enabled = true, mcp))]
+// Pins two nodes: the default vsr harness spawns three, but this test asserts a
+// two-node roster. Exercises the binary GetClusterMetadata roster end-to-end
+// over MCP (MCP server -> TCP -> iggy).
+#[iggy_harness(cluster_nodes = 2, server(mcp))]
 async fn should_return_cluster_metadata(harness: &TestHarness) {
     let mcp_client = harness.mcp_client().await.expect("MCP client required");
     let cluster: ClusterMetadata = invoke(&mcp_client, "get_cluster_metadata", None).await;
@@ -361,7 +362,6 @@ async fn should_return_clients(harness: &TestHarness) {
     assert!(!clients.is_empty());
 }
 
-#[cfg(not(feature = "vsr"))]
 #[iggy_harness(server(mcp), seed = seeds::mcp_standard)]
 async fn should_handle_snapshot(harness: &TestHarness) {
     let mcp_client = harness.mcp_client().await.expect("MCP client required");
diff --git a/core/integration/tests/server/cluster_metadata_vsr.rs b/core/integration/tests/server/cluster_metadata_vsr.rs
new file mode 100644
index 000000000..f15ad4416
--- /dev/null
+++ b/core/integration/tests/server/cluster_metadata_vsr.rs
@@ -0,0 +1,76 @@
+// 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.
+
+//! Binary `GetClusterMetadata` over a real VSR cluster. The reply must carry
+//! the full configured roster with real client ports and live leader/follower
+//! roles - not a single synthesized self node with zeroed ports.
+
+use iggy::prelude::*;
+use integration::iggy_harness;
+
+/// One shard per node so the client connection is always served by shard 0,
+/// where the metadata consensus that marks the leader lives. A request
+/// round-robined to a peer shard would still get the full roster but with no
+/// leader marked, since peer shards run no consensus.
+#[iggy_harness(cluster_nodes = 2, server(system.sharding.cpu_allocation = "0..1"))]
+async fn given_two_node_cluster_when_getting_cluster_metadata_should_return_full_roster(
+    harness: &TestHarness,
+) {
+    let client = harness
+        .node(0)
+        .tcp_client()
+        .expect("tcp client")
+        .with_root_login()
+        .connect()
+        .await
+        .expect("connect to node 0");
+
+    let metadata = client
+        .get_cluster_metadata()
+        .await
+        .expect("get cluster metadata");
+
+    assert_eq!(
+        metadata.nodes.len(),
+        harness.cluster_size(),
+        "every configured node must appear in the roster, got {metadata}"
+    );
+    for node in &metadata.nodes {
+        assert_ne!(
+            node.endpoints.tcp, 0,
+            "node {} must report its real tcp port, not the stub's 0",
+            node.name
+        );
+    }
+
+    let leaders = metadata
+        .nodes
+        .iter()
+        .filter(|node| node.role == ClusterNodeRole::Leader)
+        .count();
+    let followers = metadata
+        .nodes
+        .iter()
+        .filter(|node| node.role == ClusterNodeRole::Follower)
+        .count();
+    assert_eq!(leaders, 1, "exactly one node must lead, got {metadata}");
+    assert_eq!(
+        followers,
+        harness.cluster_size() - 1,
+        "every other node must follow, got {metadata}"
+    );
+}
diff --git a/core/integration/tests/server/concurrent_addition.rs b/core/integration/tests/server/concurrent_addition.rs
index e590078f1..ff1a4347e 100644
--- a/core/integration/tests/server/concurrent_addition.rs
+++ b/core/integration/tests/server/concurrent_addition.rs
@@ -37,19 +37,12 @@ use test_case::test_matrix;
     quic.max_idle_timeout = "500s",
     quic.keep_alive_interval = "15s"
 ))]
-// vsr excludes HTTP only (server-ng exposes no HTTP listener).
-#[cfg_attr(not(feature = "vsr"), test_matrix(
+#[test_matrix(
     [tcp(), http(), quic(), websocket()],
     [user(), stream(), topic(), partition(), consumer_group()],
     [hot(), cold()],
     [barrier_on(), barrier_off()]
-))]
-#[cfg_attr(feature = "vsr", test_matrix(
-    [tcp(), quic(), websocket()],
-    [user(), stream(), topic(), partition(), consumer_group()],
-    [hot(), cold()],
-    [barrier_on(), barrier_off()]
-))]
+)]
 async fn matrix(
     harness: &TestHarness,
     transport: TransportProtocol,
@@ -64,7 +57,6 @@ fn tcp() -> TransportProtocol {
     TransportProtocol::Tcp
 }
 
-#[cfg(not(feature = "vsr"))]
 fn http() -> TransportProtocol {
     TransportProtocol::Http
 }
diff --git a/core/integration/tests/server/flush_vsr.rs b/core/integration/tests/server/flush_vsr.rs
new file mode 100644
index 000000000..68f41040e
--- /dev/null
+++ b/core/integration/tests/server/flush_vsr.rs
@@ -0,0 +1,66 @@
+// 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.
+
+//! Flush contract against server-ng (vsr): the server has no on-demand flush
+//! primitive, so `FLUSH_UNSAVED_BUFFER` must surface a typed
+//! `FeatureUnavailable` over the SDK rather than the non-replicated catch-all's
+//! empty-ok, which would fake a durability guarantee.
+
+use iggy::prelude::*;
+use integration::iggy_harness;
+
+// server-ng partition ids are 0-based (CreateTopic assigns them from 0).
+const PARTITION_ID: u32 = 0;
+
+#[iggy_harness(
+    test_client_transport = [Tcp],
+    server(tcp.socket.override_defaults = true, tcp.socket.nodelay = true)
+)]
+async fn given_valid_partition_when_flushing_should_reject_feature_unavailable(
+    harness: &TestHarness,
+) {
+    let client = harness.tcp_root_client().await.expect("tcp root client");
+    client
+        .create_stream("flush-stream")
+        .await
+        .expect("create stream");
+    let stream_id = Identifier::from_str_value("flush-stream").expect("stream identifier");
+    client
+        .create_topic(
+            &stream_id,
+            "flush-topic",
+            1,
+            CompressionAlgorithm::None,
+            None,
+            IggyExpiry::NeverExpire,
+            MaxTopicSize::ServerDefault,
+        )
+        .await
+        .expect("create topic");
+    let topic_id = Identifier::from_str_value("flush-topic").expect("topic identifier");
+
+    // A valid, existing partition proves the deny is feature-unavailable, not a
+    // target-not-found in disguise; fsync makes it the strongest durability ask.
+    let result = client
+        .flush_unsaved_buffer(&stream_id, &topic_id, PARTITION_ID, true)
+        .await;
+
+    assert!(
+        matches!(&result, Err(error) if error.as_code() == IggyError::FeatureUnavailable.as_code()),
+        "flush over TCP-ng must surface Err(FeatureUnavailable), got {result:?}"
+    );
+}
diff --git a/core/integration/tests/server/general.rs b/core/integration/tests/server/general.rs
index eb1a88d57..cb51ac316 100644
--- a/core/integration/tests/server/general.rs
+++ b/core/integration/tests/server/general.rs
@@ -15,14 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use crate::server::scenarios::{
-    authentication_scenario, consumer_timestamp_polling_scenario, create_message_payload,
-    invalid_consumer_offset_scenario, message_headers_scenario, stream_size_validation_scenario,
-    system_scenario,
-};
 #[cfg(not(feature = "vsr"))]
+use crate::server::scenarios::bench_scenario;
 use crate::server::scenarios::{
-    bench_scenario, permissions_scenario, snapshot_scenario, user_scenario,
+    authentication_scenario, consumer_timestamp_polling_scenario, create_message_payload,
+    invalid_consumer_offset_scenario, message_headers_scenario, permissions_scenario,
+    snapshot_scenario, stream_size_validation_scenario, system_scenario, user_scenario,
 };
 use integration::iggy_harness;
 
@@ -53,14 +51,6 @@ async fn system(harness: &TestHarness) {
     system_scenario::run(harness).await;
 }
 
-// Blocked under vsr: the startup-hang is fixed and root `created_at` now
-// resolves, but the scenario issues several sequential `login_user` calls on
-// one connection. Under vsr login == register, and the SDK's one-shot
-// `ConsensusSession` only resets on the reconnect/replay path, so the second
-// deliberate re-login panics `register_request_id already called`
-// (sdk/src/session.rs). Needs an SDK login-lifecycle fix (reset the session
-// on each fresh login).
-#[cfg(not(feature = "vsr"))]
 #[iggy_harness(
     test_client_transport = [Tcp, Http, Quic, WebSocket],
     server(
@@ -74,14 +64,6 @@ async fn user(harness: &TestHarness) {
     user_scenario::run(harness).await;
 }
 
-// Blocked under vsr: password hashing and the metadata journal-append
-// race are now fixed, so the scenario runs deep into its permission
-// matrix. Remaining blocker is per-operation authorization: server-ng
-// does not enforce the caller's permissions on metadata ops, so a
-// `read_streams`-only user calling `create_stream` gets `InvalidFormat`
-// instead of `Unauthorized`. Needs the permission-enforcement subsystem
-// wired on the metadata plane.
-#[cfg(not(feature = "vsr"))]
 #[iggy_harness(
     test_client_transport = [Tcp, Http, Quic, WebSocket],
     server(
@@ -164,9 +146,6 @@ async fn consumer_timestamp_polling(harness: &TestHarness) {
     consumer_timestamp_polling_scenario::run(harness).await;
 }
 
-// Blocked under vsr: the snapshot-file feature (GET_SNAPSHOT_FILE) is
-// not implemented in server-ng.
-#[cfg(not(feature = "vsr"))]
 #[iggy_harness(
     test_client_transport = [Tcp, Http, Quic, WebSocket],
     server(
diff --git a/core/integration/tests/server/http_client.rs b/core/integration/tests/server/http_client.rs
new file mode 100644
index 000000000..7d9558494
--- /dev/null
+++ b/core/integration/tests/server/http_client.rs
@@ -0,0 +1,188 @@
+// 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 HTTP transport plumbing for the server-ng REST suites (`http_vsr`,
+//! `http_rbac`): one authenticated `reqwest` session with the login-retry gate
+//! and the generic verb helpers. Each suite keeps its own request shapes and
+//! assertions as extension methods on [`HttpClient`], so the wire-contract and
+//! listener-behavior separation between the suites stays intact.
+
+use std::time::{Duration, Instant};
+
+use iggy::prelude::*;
+use integration::harness::TestHarness;
+use reqwest::Response;
+use serde_json::{Value, json};
+use tokio::time::sleep;
+
+/// The harness readiness gate waits for the dumped runtime config, which the
+/// server writes before binding the HTTP listener; a bounded login retry
+/// bridges that gap plus single-node consensus warmup.
+pub const LOGIN_TIMEOUT: Duration = Duration::from_secs(15);
+pub const LOGIN_RETRY_INTERVAL: Duration = Duration::from_millis(50);
+pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// One authenticated HTTP session against the test server's shard-0 listener: a
+/// `reqwest` client, the listener base URL, and the bearer to send. The bearer
+/// is either a login JWT or a raw personal access token (server-ng resolves
+/// either on the `Authorization: Bearer` header).
+pub struct HttpClient {
+    pub client: reqwest::Client,
+    pub base_url: String,
+    pub token: String,
+}
+
+impl HttpClient {
+    /// Log in as root, retrying while the listener binds and consensus warms up.
+    pub async fn login_root(harness: &TestHarness) -> Self {
+        let addr = harness
+            .server()
+            .http_addr()
+            .expect("HTTP transport not configured on test server");
+        let base_url = format!("http://{addr}");
+        let client = reqwest::Client::builder()
+            .timeout(REQUEST_TIMEOUT)
+            .build()
+            .expect("build reqwest client");
+
+        let body = json!({
+            "username": DEFAULT_ROOT_USERNAME,
+            "password": DEFAULT_ROOT_PASSWORD,
+        });
+        let deadline = Instant::now() + LOGIN_TIMEOUT;
+        loop {
+            let attempt = client
+                .post(format!("{base_url}/users/login"))
+                .json(&body)
+                .send()
+                .await;
+            match attempt {
+                Ok(response) if response.status().is_success() => {
+                    return Self {
+                        client,
+                        base_url,
+                        token: access_token(response).await,
+                    };
+                }
+                // Listener not bound yet or consensus still warming up; any
+                // non-5xx rejection (e.g. 401) is a real failure.
+                Ok(response) if response.status().is_server_error() => {}
+                Ok(response) => panic!("root login rejected: {}", response.status()),
+                Err(error) if error.is_connect() => {}
+                Err(error) => panic!("root login request failed: {error}"),
+            }
+            assert!(
+                Instant::now() < deadline,
+                "HTTP root login did not succeed within {LOGIN_TIMEOUT:?}"
+            );
+            sleep(LOGIN_RETRY_INTERVAL).await;
+        }
+    }
+
+    /// Log in as an already-created user; single attempt (the server is warm
+    /// once root exists). Reuses this session's client and base URL.
+    pub async fn login(&self, username: &str, password: &str) -> Self {
+        let body = json!({ "username": username, "password": password });
+        let response = self
+            .client
+            .post(self.url("/users/login"))
+            .json(&body)
+            .send()
+            .await
+            .expect("login request");
+        assert!(
+            response.status().is_success(),
+            "login for {username} failed: {}",
+            response.status()
+        );
+        Self {
+            client: self.client.clone(),
+            base_url: self.base_url.clone(),
+            token: access_token(response).await,
+        }
+    }
+
+    /// A view of this session bound to a different bearer (a refreshed JWT or a
+    /// PAT), reusing the same client and base URL.
+    pub fn with_token(&self, token: String) -> Self {
+        Self {
+            client: self.client.clone(),
+            base_url: self.base_url.clone(),
+            token,
+        }
+    }
+
+    pub fn url(&self, path: &str) -> String {
+        format!("{}{path}", self.base_url)
+    }
+
+    pub async fn get(&self, path: &str) -> Response {
+        self.client
+            .get(self.url(path))
+            .bearer_auth(&self.token)
+            .send()
+            .await
+            .expect("get request")
+    }
+
+    /// Unauthenticated GET (no bearer) for the public `/ping` probe.
+    pub async fn get_anonymous(&self, path: &str) -> Response {
+        self.client
+            .get(self.url(path))
+            .send()
+            .await
+            .expect("anonymous get request")
+    }
+
+    pub async fn post_json(&self, path: &str, body: &Value) -> Response {
+        self.client
+            .post(self.url(path))
+            .bearer_auth(&self.token)
+            .json(body)
+            .send()
+            .await
+            .expect("post request")
+    }
+
+    pub async fn put_json(&self, path: &str, body: &Value) -> Response {
+        self.client
+            .put(self.url(path))
+            .bearer_auth(&self.token)
+            .json(body)
+            .send()
+            .await
+            .expect("put request")
+    }
+
+    pub async fn delete(&self, path: &str) -> Response {
+        self.client
+            .delete(self.url(path))
+            .bearer_auth(&self.token)
+            .send()
+            .await
+            .expect("delete request")
+    }
+}
+
+/// Extract the JWT from a successful login response.
+pub async fn access_token(response: Response) -> String {
+    let identity: IdentityInfo = response.json().await.expect("decode IdentityInfo");
+    identity
+        .access_token
+        .expect("login must return an access token")
+        .token
+}
diff --git a/core/integration/tests/server/http_rbac.rs b/core/integration/tests/server/http_rbac.rs
new file mode 100644
index 000000000..e07fffb5d
--- /dev/null
+++ b/core/integration/tests/server/http_rbac.rs
@@ -0,0 +1,532 @@
+// 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.
+
+//! HTTP wire-contract residue for server-ng's shard-0 REST listener. The RBAC
+//! authorization matrix itself (who may do what, over every transport) lives in
+//! the cross-transport `permissions_scenario` suite; this file keeps only what
+//! the SDK abstracts away and only raw HTTP can show:
+//!
+//! - exact HTTP status codes paired with the typed `{id, code}` JSON error body
+//!   (the SDK collapses every non-401/403/404 status into a generic error);
+//! - the SDK-hidden success codes (produce 201, reads 200, mutations 204, and
+//!   the server-assigned id in a 200 create-stream body);
+//! - HTTP-only route shapes: the `/users/me` self alias, the caller-scoped
+//!   name-sorted PAT-list array, auth-only cluster metadata, and the public ping;
+//! - the change-password status/body shape: a wrong current password renders a
+//!   typed 400 `InvalidCredentials` and a missing target a 404 `ResourceNotFound`,
+//!   both bodies the SDK hides. Change-password commits over every transport, so
+//!   its authz is covered in `permissions_scenario`; only the wire shape is here.
+//!
+//! Each test owns its own server (one spawn per `#[iggy_harness]` fn).
+
+use crate::server::http_client::HttpClient;
+use iggy::prelude::*;
+use iggy_common::IggyMessagesBatch;
+use iggy_common::create_stream::CreateStream;
+use iggy_common::create_topic::CreateTopic;
+use integration::iggy_harness;
+use reqwest::{Response, StatusCode};
+use serde_json::{Value, json};
+
+// server-ng partition ids are 0-based (CreateTopic assigns them from 0).
+const PARTITION_ID: u32 = 0;
+// Explicit consumer id in the poll query (`Consumer::default()` is numeric 0).
+const CONSUMER_ID: u32 = 1;
+
+// `IggyError` discriminants (== the numeric `id` in the JSON error body).
+const UNAUTHORIZED_ID: u64 = 41;
+const INVALID_CREDENTIALS_ID: u64 = 42;
+const CANNOT_DELETE_USER_ID: u64 = 48;
+const CANNOT_CHANGE_PERMISSIONS_ID: u64 = 49;
+// The apply's `ChangePasswordResult::UserNotFound` maps to `ResourceNotFound`.
+const RESOURCE_NOT_FOUND_ID: u64 = 20;
+// Snake-case `IggyError` names (== the `code` string in the JSON error body).
+const UNAUTHORIZED_CODE: &str = "unauthorized";
+const INVALID_CREDENTIALS_CODE: &str = "invalid_credentials";
+const CANNOT_DELETE_USER_CODE: &str = "cannot_delete_user";
+const CANNOT_CHANGE_PERMISSIONS_CODE: &str = "cannot_change_permissions";
+const RESOURCE_NOT_FOUND_CODE: &str = "resource_not_found";
+
+/// Root is the first user (slab id 0) and is undeletable / permission-locked.
+const ROOT_USER_PATH: &str = "0";
+
+/// Wire-contract request shapes layered on the shared [`HttpClient`]: this suite
+/// pins raw HTTP status codes and typed `{id, code}` error bodies, so its
+/// helpers return the raw [`Response`] for the caller to assert on.
+trait ClientExt {
+    async fn create_stream(&self, name: &str) -> u64;
+    async fn create_topic(&self, stream: &str, topic: &str, partitions: u32);
+    async fn create_user(&self, username: &str, password: &str, permissions: Value) -> Response;
+    async fn change_password(&self, user_id: &str, current: &str, new: &str) -> Response;
+    async fn login_attempt(&self, username: &str, password: &str) -> Response;
+    async fn produce(
+        &self,
+        stream: &str,
+        topic: &str,
+        partition_id: u32,
+        messages: Vec<IggyMessage>,
+    ) -> Response;
+    async fn poll(&self, stream: &str, topic: &str, partition_id: u32) -> Response;
+}
+
+impl ClientExt for HttpClient {
+    /// Create a stream and return its server-assigned numeric id (proves the
+    /// SDK-hidden `id` field of a 200 create-stream body).
+    async fn create_stream(&self, name: &str) -> u64 {
+        let response = self
+            .post_json(
+                "/streams",
+                &serde_json::to_value(CreateStream { name: name.into() }).unwrap(),
+            )
+            .await;
+        assert!(
+            response.status().is_success(),
+            "root create stream {name} failed: {}",
+            response.status()
+        );
+        let details: Value = response.json().await.expect("stream details json");
+        details["id"]
+            .as_u64()
+            .expect("stream id in create response")
+    }
+
+    async fn create_topic(&self, stream: &str, topic: &str, partitions: u32) {
+        let command = CreateTopic {
+            stream_id: Identifier::default(),
+            partitions_count: partitions,
+            compression_algorithm: CompressionAlgorithm::None,
+            message_expiry: IggyExpiry::NeverExpire,
+            max_topic_size: MaxTopicSize::ServerDefault,
+            replication_factor: None,
+            name: topic.to_string(),
+        };
+        let response = self
+            .post_json(
+                &format!("/streams/{stream}/topics"),
+                &serde_json::to_value(command).unwrap(),
+            )
+            .await;
+        assert!(
+            response.status().is_success(),
+            "root create topic {topic} failed: {}",
+            response.status()
+        );
+    }
+
+    /// Create a user with the given permissions (`Value::Null` = no grants).
+    async fn create_user(&self, username: &str, password: &str, permissions: Value) -> Response {
+        self.post_json(
+            "/users",
+            &json!({
+                "username": username,
+                "password": password,
+                "status": "active",
+                "permissions": permissions,
+            }),
+        )
+        .await
+    }
+
+    /// Change a user's password via `PUT /users/{user_id}/password`.
+    async fn change_password(&self, user_id: &str, current: &str, new: &str) -> Response {
+        self.put_json(
+            &format!("/users/{user_id}/password"),
+            &json!({ "current_password": current, "new_password": new }),
+        )
+        .await
+    }
+
+    /// Anonymous login attempt returning the raw response, for exercising a
+    /// rejected login (the asserting [`HttpClient::login`] cannot express failure).
+    async fn login_attempt(&self, username: &str, password: &str) -> Response {
+        self.client
+            .post(self.url("/users/login"))
+            .json(&json!({ "username": username, "password": password }))
+            .send()
+            .await
+            .expect("login request")
+    }
+
+    async fn produce(
+        &self,
+        stream: &str,
+        topic: &str,
+        partition_id: u32,
+        messages: Vec<IggyMessage>,
+    ) -> Response {
+        let body = SendMessages {
+            metadata_length: 0,
+            stream_id: Identifier::default(),
+            topic_id: Identifier::default(),
+            partitioning: Partitioning::partition_id(partition_id),
+            batch: IggyMessagesBatch::from(&messages),
+        };
+        self.client
+            .post(self.url(&format!("/streams/{stream}/topics/{topic}/messages")))
+            .bearer_auth(&self.token)
+            .json(&body)
+            .send()
+            .await
+            .expect("produce request")
+    }
+
+    async fn poll(&self, stream: &str, topic: &str, partition_id: u32) -> Response {
+        let path = format!(
+            "/streams/{stream}/topics/{topic}/messages\
+             ?consumer_id={CONSUMER_ID}&partition_id={partition_id}\
+             &kind=offset&value=0&count=10&auto_commit=false"
+        );
+        self.get(&path).await
+    }
+}
+
+fn text_message(id: u128, payload: &str) -> IggyMessage {
+    IggyMessage::builder()
+        .id(id)
+        .payload(payload.to_string().into())
+        .build()
+        .expect("message build")
+}
+
+/// No permissions at all: the permissioner holds no entry, so every rule denies.
+fn no_permissions() -> Value {
+    Value::Null
+}
+
+/// Assert a JSON error body carries the expected typed `id` + `code`. Proves the
+/// error crossed the wire as a real `IggyError` code, not a bare status.
+async fn assert_error_body(response: Response, expected_id: u64, expected_code: &str, ctx: &str) {
+    let body: Value = response.json().await.expect("error body json");
+    assert_eq!(
+        body["id"].as_u64(),
+        Some(expected_id),
+        "{ctx}: error id (body={body})"
+    );
+    assert_eq!(
+        body["code"].as_str(),
+        Some(expected_code),
+        "{ctx}: error code (body={body})"
+    );
+}
+
+/// Decode a PAT-list response body into its token names, asserting 200 first.
+async fn pat_names(response: Response) -> Vec<String> {
+    assert_eq!(
+        response.status(),
+        StatusCode::OK,
+        "PAT list must be 200 for an authenticated caller"
+    );
+    let body: Value = response.json().await.expect("PAT list body must be JSON");
+    body.as_array()
+        .expect("PAT list must be a JSON array")
+        .iter()
+        .map(|token| {
+            token["name"]
+                .as_str()
+                .expect("every token must carry a name")
+                .to_owned()
+        })
+        .collect()
+}
+
+// === Denials carry the exact HTTP status + typed JSON error body ============
+
+/// The SDK reconstructs only 401/403/404 as typed errors and collapses every
+/// other status; raw HTTP is the only place the paired status + `{id, code}`
+/// body is observable. Covers both denial gates and both root-protection rules.
+#[iggy_harness]
+async fn given_http_requests_when_denied_should_carry_typed_status_and_body(harness: &TestHarness) {
+    let root = HttpClient::login_root(harness).await;
+    // A populated stream so the read gate denies over a real listing surface.
+    root.create_stream("rbac-s1").await;
+    root.create_user("alice", "alice-pass", no_permissions())
+        .await;
+    let alice = root.login("alice", "alice-pass").await;
+
+    // Dispatch read gate: an ungranted read is 403 with a typed Unauthorized body.
+    let read = alice.get("/streams").await;
+    assert_eq!(
+        read.status(),
+        StatusCode::FORBIDDEN,
+        "alice GET /streams must be 403"
+    );
+    assert_error_body(
+        read,
+        UNAUTHORIZED_ID,
+        UNAUTHORIZED_CODE,
+        "alice GET /streams",
+    )
+    .await;
+
+    // In-apply control gate: the denial rides the metadata result section back as
+    // a typed Unauthorized body, still under a 403.
+    let create = alice
+        .post_json("/streams", &json!({ "name": "alice-stream" }))
+        .await;
+    assert_eq!(
+        create.status(),
+        StatusCode::FORBIDDEN,
+        "alice POST /streams must be 403"
+    );
+    assert_error_body(
+        create,
+        UNAUTHORIZED_ID,
+        UNAUTHORIZED_CODE,
+        "alice POST /streams",
+    )
+    .await;
+
+    // Business-rule rejections fire in the STM apply AFTER RBAC admits root
+    // (both guards are target-based on slab id 0). The typed code rides the body
+    // and the `IggyError` map yields 400, not 403.
+    let delete_root = root.delete(&format!("/users/{ROOT_USER_PATH}")).await;
+    assert_eq!(
+        delete_root.status(),
+        StatusCode::BAD_REQUEST,
+        "delete root status"
+    );
+    assert_error_body(
+        delete_root,
+        CANNOT_DELETE_USER_ID,
+        CANNOT_DELETE_USER_CODE,
+        "delete root",
+    )
+    .await;
+
+    let change_root = root
+        .put_json(
+            &format!("/users/{ROOT_USER_PATH}/permissions"),
+            &json!({ "permissions": no_permissions() }),
+        )
+        .await;
+    assert_eq!(
+        change_root.status(),
+        StatusCode::BAD_REQUEST,
+        "re-permission root status"
+    );
+    assert_error_body(
+        change_root,
+        CANNOT_CHANGE_PERMISSIONS_ID,
+        CANNOT_CHANGE_PERMISSIONS_CODE,
+        "re-permission root",
+    )
+    .await;
+}
+
+// === Authorized requests carry the SDK-hidden success status codes ==========
+
+/// Root exercises the happy path; the SDK hides these exact status codes behind
+/// `Ok(...)`, so only raw HTTP can pin produce 201 / read 200 / create-stream
+/// 200 + the id body.
+#[iggy_harness]
+async fn given_http_requests_when_authorized_should_carry_sdk_hidden_status_codes(
+    harness: &TestHarness,
+) {
+    let root = HttpClient::login_root(harness).await;
+    let stream_id = root.create_stream("root-canary").await;
+    assert!(stream_id < u64::MAX, "root create stream must return an id");
+    root.create_topic("root-canary", "canary-topic", 1).await;
+
+    assert_eq!(
+        root.produce(
+            "root-canary",
+            "canary-topic",
+            PARTITION_ID,
+            vec![text_message(1, "root-msg")],
+        )
+        .await
+        .status(),
+        StatusCode::CREATED,
+        "root produce must be 201"
+    );
+    assert_eq!(
+        root.poll("root-canary", "canary-topic", PARTITION_ID)
+            .await
+            .status(),
+        StatusCode::OK,
+        "root poll must be 200"
+    );
+    assert_eq!(
+        root.get("/stats").await.status(),
+        StatusCode::OK,
+        "root GET /stats must be 200"
+    );
+}
+
+// === HTTP-only route shapes the SDK does not expose =========================
+
+/// Routes with no SDK equivalent: the `/users/me` self alias, auth-only cluster
+/// metadata (readable by an ungranted caller, so never RBAC-gated), the public
+/// ping, and the caller-scoped name-sorted PAT-list JSON array.
+#[iggy_harness]
+async fn given_http_only_routes_when_requested_should_return_expected_shapes(
+    harness: &TestHarness,
+) {
+    let root = HttpClient::login_root(harness).await;
+    root.create_user("alice", "alice-pass", no_permissions())
+        .await;
+    let alice = root.login("alice", "alice-pass").await;
+
+    // The `me` alias resolves to the caller's own user (self-scoped, no rule).
+    let me = alice.get("/users/me").await;
+    assert_eq!(me.status(), StatusCode::OK, "GET /users/me must be 200");
+    let me_body: Value = me.json().await.expect("users/me body must be JSON");
+    assert_eq!(
+        me_body["username"], "alice",
+        "the me alias must resolve to the caller's own user"
+    );
+
+    // Cluster metadata is auth-only: an ungranted caller still reads it, proving
+    // the route is never RBAC-gated.
+    assert_eq!(
+        alice.get("/cluster/metadata").await.status(),
+        StatusCode::OK,
+        "cluster metadata is auth-only, never RBAC-gated"
+    );
+
+    // Ping is public (no bearer).
+    assert_eq!(
+        alice.get_anonymous("/ping").await.status(),
+        StatusCode::OK,
+        "ping is public"
+    );
+
+    // PAT listing is a JSON array of the caller's own tokens, name-sorted
+    // (created in reverse order to prove the sort). Create returns 200.
+    for name in ["alice-zeta", "alice-alpha"] {
+        assert_eq!(
+            alice
+                .post_json(
+                    "/personal-access-tokens",
+                    &json!({ "name": name, "expiry": 0 }),
+                )
+                .await
+                .status(),
+            StatusCode::OK,
+            "alice create PAT {name} must be 200"
+        );
+    }
+    assert_eq!(
+        pat_names(alice.get("/personal-access-tokens").await).await,
+        ["alice-alpha", "alice-zeta"],
+        "PAT list must be a name-sorted JSON array of the caller's tokens"
+    );
+}
+
+// === Change-password wire contract (status/body shape) ==========================
+
+/// The current-password check gates a self-service rotation: a wrong current
+/// password is verified against the stored hash on shard 0, commits as a
+/// rejecting no-op, and surfaces as 400 `InvalidCredentials` from the
+/// replicated apply; only the correct one rotates the credential so the old
+/// password stops working and the new one starts.
+#[iggy_harness]
+async fn given_own_password_when_current_wrong_should_reject_and_correct_should_rotate(
+    harness: &TestHarness,
+) {
+    let root = HttpClient::login_root(harness).await;
+    root.create_user("carol", "carol-pass-1", no_permissions())
+        .await;
+    let carol = root.login("carol", "carol-pass-1").await;
+
+    let wrong = carol
+        .change_password("carol", "not-my-password", "carol-pass-2")
+        .await;
+    assert_eq!(
+        wrong.status(),
+        StatusCode::BAD_REQUEST,
+        "wrong current password must be 400"
+    );
+    assert_error_body(
+        wrong,
+        INVALID_CREDENTIALS_ID,
+        INVALID_CREDENTIALS_CODE,
+        "carol change-password wrong current",
+    )
+    .await;
+
+    let correct = carol
+        .change_password("carol", "carol-pass-1", "carol-pass-2")
+        .await;
+    assert_eq!(
+        correct.status(),
+        StatusCode::NO_CONTENT,
+        "correct current password must be 204"
+    );
+
+    assert!(
+        !carol
+            .login_attempt("carol", "carol-pass-1")
+            .await
+            .status()
+            .is_success(),
+        "old password must stop working after a successful rotation"
+    );
+    assert!(
+        carol
+            .login_attempt("carol", "carol-pass-2")
+            .await
+            .status()
+            .is_success(),
+        "new password must work after a successful rotation"
+    );
+}
+
+/// Admin-initiated change (target != caller): the current password is checked
+/// against the TARGET's stored hash, so a wrong one is 400 `InvalidCredentials`;
+/// a target that does not resolve is left to the replicated apply, which
+/// answers 404 `UserNotFound` (the plaintext is still stripped + hashed first).
+#[iggy_harness]
+async fn given_admin_change_password_when_target_current_wrong_or_missing_should_reject(
+    harness: &TestHarness,
+) {
+    let root = HttpClient::login_root(harness).await;
+    root.create_user("dave", "dave-pass", no_permissions())
+        .await;
+
+    let wrong = root
+        .change_password("dave", "not-daves-password", "new-dave-pass")
+        .await;
+    assert_eq!(
+        wrong.status(),
+        StatusCode::BAD_REQUEST,
+        "admin change with the wrong target current password must be 400"
+    );
+    assert_error_body(
+        wrong,
+        INVALID_CREDENTIALS_ID,
+        INVALID_CREDENTIALS_CODE,
+        "admin change dave wrong current",
+    )
+    .await;
+
+    let missing = root
+        .change_password("ghost-does-not-exist", "whatever", "whatever-else")
+        .await;
+    assert_eq!(
+        missing.status(),
+        StatusCode::NOT_FOUND,
+        "change for an unknown target must be 404 from the apply"
+    );
+    assert_error_body(
+        missing,
+        RESOURCE_NOT_FOUND_ID,
+        RESOURCE_NOT_FOUND_CODE,
+        "admin change missing target",
+    )
+    .await;
+}
diff --git a/core/integration/tests/server/http_vsr.rs b/core/integration/tests/server/http_vsr.rs
new file mode 100644
index 000000000..2018d4ae7
--- /dev/null
+++ b/core/integration/tests/server/http_vsr.rs
@@ -0,0 +1,843 @@
+// 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.
+
+//! HTTP data-plane gate for server-ng: produce, poll, and consumer-offset
+//! routes exercised over raw `reqwest` (not the SDK HTTP client) so the wire
+//! contract itself is under test - exact status codes, the
+//! `x-iggy-durability` header, the body-size cap, and cross-request isolation
+//! of concurrent produces on one login session.
+
+use crate::server::http_client::HttpClient;
+use futures::future::join_all;
+use iggy::prelude::*;
+use iggy_common::create_stream::CreateStream;
+use iggy_common::create_topic::CreateTopic;
+use iggy_common::store_consumer_offset::StoreConsumerOffset;
+use iggy_common::{ConsumerOffsetInfo, IggyMessagesBatch};
+use integration::iggy_harness;
+use reqwest::{Response, StatusCode};
+use serde_json::json;
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::str::FromStr;
+use std::time::{Duration, Instant};
+use tokio::time::sleep;
+
+// server-ng partition ids are 0-based (CreateTopic assigns them from 0).
+const PARTITION_ID: u32 = 0;
+
+/// Explicit consumer id shared by the offset store body and the read/delete
+/// query, so both sides address the same offset-table key
+/// (`Consumer::default()` would carry numeric id 0, not 1).
+const CONSUMER_ID: u32 = 1;
+
+const DURABILITY_HEADER: &str = "x-iggy-durability";
+const DURABILITY_REPLICATED_MEMORY: &str = "replicated-memory";
+const DURABILITY_NONE: &str = "none";
+
+/// `?ack=none` answers before the commit; poll until visible, never unbounded.
+const ASYNC_COMMIT_TIMEOUT: Duration = Duration::from_secs(15);
+const ASYNC_COMMIT_RETRY_INTERVAL: Duration = Duration::from_millis(100);
+
+/// Data-plane request shapes layered on the shared [`HttpClient`]: this suite
+/// exercises the listener's produce / poll / consumer-offset behavior, so its
+/// helpers decode `PolledMessages` and drive multi-partition produces.
+trait HttpSessionExt {
+    async fn create_stream_and_topic(&self, stream: &str, topic: &str, partitions: u32);
+    async fn produce(
+        &self,
+        stream: &str,
+        topic: &str,
+        partition_id: u32,
+        messages: Vec<IggyMessage>,
+    ) -> Response;
+    async fn produce_with_query(
+        &self,
+        stream: &str,
+        topic: &str,
+        partition_id: u32,
+        messages: Vec<IggyMessage>,
+        query: &str,
+    ) -> Response;
+    async fn poll(
+        &self,
+        stream: &str,
+        topic: &str,
+        partition_id: u32,
+        offset: u64,
+        count: u32,
+    ) -> PolledMessages;
+    async fn create_user(&self, username: &str, password: &str);
+}
+
+impl HttpSessionExt for HttpClient {
+    async fn create_stream_and_topic(&self, stream: &str, topic: &str, partitions: u32) {
+        let response = self
+            .client
+            .post(self.url("/streams"))
+            .bearer_auth(&self.token)
+            .json(&CreateStream {
+                name: stream.to_string(),
+            })
+            .send()
+            .await
+            .expect("create stream request");
+        assert!(
+            response.status().is_success(),
+            "create stream failed: {}",
+            response.status()
+        );
+
+        let command = CreateTopic {
+            stream_id: Identifier::default(),
+            partitions_count: partitions,
+            compression_algorithm: CompressionAlgorithm::None,
+            message_expiry: IggyExpiry::NeverExpire,
+            max_topic_size: MaxTopicSize::ServerDefault,
+            replication_factor: None,
+            name: topic.to_string(),
+        };
+        let response = self
+            .client
+            .post(self.url(&format!("/streams/{stream}/topics")))
+            .bearer_auth(&self.token)
+            .json(&command)
+            .send()
+            .await
+            .expect("create topic request");
+        assert!(
+            response.status().is_success(),
+            "create topic failed: {}",
+            response.status()
+        );
+    }
+
+    async fn produce(
+        &self,
+        stream: &str,
+        topic: &str,
+        partition_id: u32,
+        messages: Vec<IggyMessage>,
+    ) -> Response {
+        self.produce_with_query(stream, topic, partition_id, messages, "")
+            .await
+    }
+
+    async fn produce_with_query(
+        &self,
+        stream: &str,
+        topic: &str,
+        partition_id: u32,
+        messages: Vec<IggyMessage>,
+        query: &str,
+    ) -> Response {
+        let body = SendMessages {
+            metadata_length: 0,
+            stream_id: Identifier::default(),
+            topic_id: Identifier::default(),
+            partitioning: Partitioning::partition_id(partition_id),
+            batch: IggyMessagesBatch::from(&messages),
+        };
+        self.client
+            .post(self.url(&format!("/streams/{stream}/topics/{topic}/messages{query}")))
+            .bearer_auth(&self.token)
+            .json(&body)
+            .send()
+            .await
+            .expect("produce request")
+    }
+
+    async fn poll(
+        &self,
+        stream: &str,
+        topic: &str,
+        partition_id: u32,
+        offset: u64,
+        count: u32,
+    ) -> PolledMessages {
+        let path = format!(
+            "/streams/{stream}/topics/{topic}/messages\
+             ?consumer_id={CONSUMER_ID}&partition_id={partition_id}\
+             &kind=offset&value={offset}&count={count}&auto_commit=false"
+        );
+        let response = self
+            .client
+            .get(self.url(&path))
+            .bearer_auth(&self.token)
+            .send()
+            .await
+            .expect("poll request");
+        assert_eq!(response.status(), StatusCode::OK, "poll must answer 200");
+        response.json().await.expect("decode PolledMessages")
+    }
+
+    /// Create an ungranted user (no permissions -> the permissioner holds no
+    /// entry, so every RBAC rule denies). Runs as this session's user.
+    async fn create_user(&self, username: &str, password: &str) {
+        let body = serde_json::json!({
+            "username": username,
+            "password": password,
+            "status": "active",
+            "permissions": null,
+        });
+        let response = self
+            .client
+            .post(self.url("/users"))
+            .bearer_auth(&self.token)
+            .json(&body)
+            .send()
+            .await
+            .expect("create user request");
+        assert!(
+            response.status().is_success(),
+            "create user failed: {}",
+            response.status()
+        );
+    }
+}
+
+fn durability(response: &Response) -> &str {
+    response
+        .headers()
+        .get(DURABILITY_HEADER)
+        .expect("produce response must carry the durability header")
+        .to_str()
+        .expect("durability header is ASCII")
+}
+
+fn text_message(id: u128, payload: String) -> IggyMessage {
+    IggyMessage::builder()
+        .id(id)
+        .payload(payload.into())
+        .build()
+        .expect("message build")
+}
+
+#[iggy_harness]
+async fn given_http_session_when_producing_and_polling_should_round_trip(harness: &TestHarness) {
+    let http = HttpClient::login_root(harness).await;
+    http.create_stream_and_topic("http-stream", "http-topic", 1)
+        .await;
+
+    let header_key = HeaderKey::try_from("trace-id").expect("header key");
+    let header_value = HeaderValue::from_str("trace-42").expect("header value");
+    let messages: Vec<IggyMessage> = (0..3u32)
+        .map(|i| {
+            let builder = IggyMessage::builder()
+                .id(u128::from(i + 1))
+                .payload(format!("round-trip-{i}").into());
+            if i == 0 {
+                let user_headers = BTreeMap::from([(header_key.clone(), header_value.clone())]);
+                builder
+                    .user_headers(user_headers)
+                    .build()
+                    .expect("message build")
+            } else {
+                builder.build().expect("message build")
+            }
+        })
+        .collect();
+
+    let response = http
+        .produce("http-stream", "http-topic", PARTITION_ID, messages)
+        .await;
+    assert_eq!(
+        response.status(),
+        StatusCode::CREATED,
+        "produce must commit"
+    );
+    assert_eq!(durability(&response), DURABILITY_REPLICATED_MEMORY);
+
+    let polled = http
+        .poll("http-stream", "http-topic", PARTITION_ID, 0, 10)
+        .await;
+    assert_eq!(polled.messages.len(), 3, "all sent messages must come back");
+    for (i, message) in polled.messages.iter().enumerate() {
+        assert_eq!(message.header.offset, i as u64, "offsets must be dense");
+        assert_eq!(
+            message.payload,
+            bytes::Bytes::from(format!("round-trip-{i}")),
+            "payload round trip for message {i}"
+        );
+    }
+    let user_headers = polled.messages[0]
+        .user_headers_map()
+        .expect("headers decode")
+        .expect("first message carries user headers");
+    assert_eq!(
+        user_headers.get(&header_key),
+        Some(&header_value),
+        "user header must survive the round trip"
+    );
+}
+
+const CONCURRENT_PRODUCES: usize = 16;
+const CONCURRENT_PARTITIONS: u32 = 4;
+
+#[iggy_harness]
+async fn given_one_session_when_producing_concurrently_should_not_cross_talk(
+    harness: &TestHarness,
+) {
+    let http = HttpClient::login_root(harness).await;
+    http.create_stream_and_topic("http-concurrent", "hammer", CONCURRENT_PARTITIONS)
+        .await;
+
+    let produces = (0..CONCURRENT_PRODUCES).map(|i| {
+        let http = &http;
+        let partition_id = (i as u32) % CONCURRENT_PARTITIONS;
+        async move {
+            let message = text_message(i as u128 + 1, format!("concurrent-{i}"));
+            let response = http
+                .produce("http-concurrent", "hammer", partition_id, vec![message])
+                .await;
+            (i, response.status(), durability(&response).to_string())
+        }
+    });
+    let outcomes = join_all(produces).await;
+
+    for (i, status, durability) in &outcomes {
+        assert_eq!(
+            *status,
+            StatusCode::CREATED,
+            "concurrent produce {i} must commit"
+        );
+        assert_eq!(
+            durability, DURABILITY_REPLICATED_MEMORY,
+            "concurrent produce {i} must attest a replicated commit"
+        );
+    }
+
+    let mut produced: HashMap<u32, HashSet<String>> = HashMap::new();
+    for i in 0..CONCURRENT_PRODUCES {
+        produced
+            .entry((i as u32) % CONCURRENT_PARTITIONS)
+            .or_default()
+            .insert(format!("concurrent-{i}"));
+    }
+
+    // Every partition must return exactly its own produced payloads: nothing
+    // lost, nothing foreign. The produce path is at-least-once, so duplicates
+    // are tolerated but must be byte-identical to a produced payload.
+    let mut polled_union: HashSet<String> = HashSet::new();
+    for partition_id in 0..CONCURRENT_PARTITIONS {
+        let polled = http
+            .poll("http-concurrent", "hammer", partition_id, 0, 100)
+            .await;
+        let expected = &produced[&partition_id];
+        let mut seen: HashSet<String> = HashSet::new();
+        for message in &polled.messages {
+            let payload = String::from_utf8(message.payload.to_vec()).expect("payload is UTF-8");
+            assert!(
+                expected.contains(&payload),
+                "partition {partition_id} returned foreign payload {payload:?}"
+            );
+            seen.insert(payload);
+        }
+        assert_eq!(
+            &seen, expected,
+            "partition {partition_id} must return exactly its own produced payloads"
+        );
+        polled_union.extend(seen);
+    }
+    assert_eq!(
+        polled_union.len(),
+        CONCURRENT_PRODUCES,
+        "every produced payload must be polled back"
+    );
+}
+
+#[iggy_harness]
+async fn given_consumer_offset_when_stored_and_deleted_should_round_trip(harness: &TestHarness) {
+    let http = HttpClient::login_root(harness).await;
+    http.create_stream_and_topic("http-offsets", "offsets", 1)
+        .await;
+
+    // Two committed messages so offset 1 names a real position.
+    let messages = (0..2u32)
+        .map(|i| text_message(u128::from(i + 1), format!("offset-fodder-{i}")))
+        .collect();
+    let response = http
+        .produce("http-offsets", "offsets", PARTITION_ID, messages)
+        .await;
+    assert_eq!(
+        response.status(),
+        StatusCode::CREATED,
+        "produce must commit"
+    );
+
+    let offsets_path = "/streams/http-offsets/topics/offsets/consumer-offsets";
+    let store = StoreConsumerOffset {
+        consumer: Consumer::new(Identifier::numeric(CONSUMER_ID).expect("consumer id")),
+        partition_id: Some(PARTITION_ID),
+        offset: 1,
+    };
+    let response = http
+        .client
+        .put(http.url(offsets_path))
+        .bearer_auth(&http.token)
+        .json(&store)
+        .send()
+        .await
+        .expect("store offset request");
+    assert_eq!(
+        response.status(),
+        StatusCode::NO_CONTENT,
+        "store must commit"
+    );
+
+    let get_path = format!("{offsets_path}?consumer_id={CONSUMER_ID}&partition_id={PARTITION_ID}");
+    let response = http
+        .client
+        .get(http.url(&get_path))
+        .bearer_auth(&http.token)
+        .send()
+        .await
+        .expect("get offset request");
+    assert_eq!(
+        response.status(),
+        StatusCode::OK,
+        "stored offset must be readable"
+    );
+    let info: ConsumerOffsetInfo = response.json().await.expect("decode ConsumerOffsetInfo");
+    assert_eq!(info.stored_offset, 1, "stored offset must round trip");
+    assert_eq!(info.partition_id, PARTITION_ID);
+
+    let delete_path = format!("{offsets_path}/{CONSUMER_ID}?partition_id={PARTITION_ID}");
+    let response = http
+        .client
+        .delete(http.url(&delete_path))
+        .bearer_auth(&http.token)
+        .send()
+        .await
+        .expect("delete offset request");
+    assert_eq!(
+        response.status(),
+        StatusCode::NO_CONTENT,
+        "delete must commit"
+    );
+
+    let response = http
+        .client
+        .get(http.url(&get_path))
+        .bearer_auth(&http.token)
+        .send()
+        .await
+        .expect("get offset request after delete");
+    assert_eq!(
+        response.status(),
+        StatusCode::NOT_FOUND,
+        "deleted offset must be gone"
+    );
+}
+
+/// A DELETE of a never-stored consumer offset is denied by the partition
+/// primary before consensus (`ReplyHeader.status`), so the route must answer
+/// a typed 404 immediately - not wait out the 10s partition-write reply
+/// timeout into a 504 - and the deny gate must not break a store-then-delete
+/// of an offset that does exist.
+#[iggy_harness]
+async fn given_missing_consumer_offset_when_deleting_should_reject_404_fast(harness: &TestHarness) {
+    /// Well under the partition-write reply timeout: a deny is a single
+    /// round trip, a silent drop only answers at the full 10s.
+    const DENY_LATENCY_BUDGET: Duration = Duration::from_secs(5);
+
+    let http = HttpClient::login_root(harness).await;
+    http.create_stream_and_topic("http-missing-offset", "offsets", 1)
+        .await;
+
+    let offsets_path = "/streams/http-missing-offset/topics/offsets/consumer-offsets";
+    let delete_path = format!("{offsets_path}/{CONSUMER_ID}?partition_id={PARTITION_ID}");
+    let started = Instant::now();
+    let response = http
+        .client
+        .delete(http.url(&delete_path))
+        .bearer_auth(&http.token)
+        .send()
+        .await
+        .expect("delete missing offset request");
+    let elapsed = started.elapsed();
+    assert_eq!(
+        response.status(),
+        StatusCode::NOT_FOUND,
+        "missing offset delete must reject 404"
+    );
+    assert!(
+        elapsed < DENY_LATENCY_BUDGET,
+        "deny must answer fast, not ride the reply timeout (took {elapsed:?})"
+    );
+    // The typed deny body, not the generic pre-dispatch not-found shape.
+    let body: serde_json::Value = response.json().await.expect("decode error body");
+    assert_eq!(
+        body["code"], "consumer_offset_not_found",
+        "deny must carry the typed error code, got {body}"
+    );
+
+    // The deny gate must not reject offsets that exist: store, then delete.
+    let messages = vec![text_message(1, "offset-fodder".to_string())];
+    let response = http
+        .produce("http-missing-offset", "offsets", PARTITION_ID, messages)
+        .await;
+    assert_eq!(
+        response.status(),
+        StatusCode::CREATED,
+        "produce must commit"
+    );
+    let store = StoreConsumerOffset {
+        consumer: Consumer::new(Identifier::numeric(CONSUMER_ID).expect("consumer id")),
+        partition_id: Some(PARTITION_ID),
+        offset: 0,
+    };
+    let response = http
+        .client
+        .put(http.url(offsets_path))
+        .bearer_auth(&http.token)
+        .json(&store)
+        .send()
+        .await
+        .expect("store offset request");
+    assert_eq!(
+        response.status(),
+        StatusCode::NO_CONTENT,
+        "store must commit"
+    );
+    let response = http
+        .client
+        .delete(http.url(&delete_path))
+        .bearer_auth(&http.token)
+        .send()
+        .await
+        .expect("delete stored offset request");
+    assert_eq!(
+        response.status(),
+        StatusCode::NO_CONTENT,
+        "existing offset delete must commit"
+    );
+}
+
+/// A store of an out-of-range consumer offset is denied by the partition
+/// primary at admission and must answer a typed 400, NOT a silent 204.
+/// Regression guard: the deny once rode the result-body result section with
+/// `status = 0`; the status-only `classify_partition_reply` read that as a
+/// committed success on a non-empty partition (`op = commit_max >= 1`) and
+/// returned 204 for a rejected store, dropping the offset silently. The two
+/// committed messages below make the partition non-empty so that `op != 0`
+/// path is exercised - an empty partition would only surface a different 404.
+#[iggy_harness]
+async fn given_out_of_range_consumer_offset_when_storing_should_reject_400_fast(
+    harness: &TestHarness,
+) {
+    /// Well under the partition-write reply timeout: an admission deny is a
+    /// single round trip, never the full reply timeout.
+    const DENY_LATENCY_BUDGET: Duration = Duration::from_secs(5);
+
+    let http = HttpClient::login_root(harness).await;
+    http.create_stream_and_topic("http-store-range", "offsets", 1)
+        .await;
+
+    // Two committed messages so the partition is non-empty (current_offset == 1,
+    // commit_max >= 1); the silent-204 path only appears once op != 0.
+    let messages = (0..2u32)
+        .map(|i| text_message(u128::from(i + 1), format!("offset-fodder-{i}")))
+        .collect();
+    let response = http
+        .produce("http-store-range", "offsets", PARTITION_ID, messages)
+        .await;
+    assert_eq!(
+        response.status(),
+        StatusCode::CREATED,
+        "produce must commit"
+    );
+
+    // Offset 5 runs past the committed tail (offset 1), so it is out of range.
+    let offsets_path = "/streams/http-store-range/topics/offsets/consumer-offsets";
+    let store = StoreConsumerOffset {
+        consumer: Consumer::new(Identifier::numeric(CONSUMER_ID).expect("consumer id")),
+        partition_id: Some(PARTITION_ID),
+        offset: 5,
+    };
+    let started = Instant::now();
+    let response = http
+        .client
+        .put(http.url(offsets_path))
+        .bearer_auth(&http.token)
+        .json(&store)
+        .send()
+        .await
+        .expect("store out-of-range offset request");
+    let elapsed = started.elapsed();
+    assert_eq!(
+        response.status(),
+        StatusCode::BAD_REQUEST,
+        "out-of-range store must reject 400, not silently accept as 204"
+    );
+    assert!(
+        elapsed < DENY_LATENCY_BUDGET,
+        "deny must answer fast, not ride the reply timeout (took {elapsed:?})"
+    );
+    // The typed deny body carried in `ReplyHeader.status`, not a swallowed code.
+    let body: serde_json::Value = response.json().await.expect("decode error body");
+    assert_eq!(
+        body["code"], "invalid_offset",
+        "deny must carry the typed error code, got {body}"
+    );
+}
+
+#[iggy_harness]
+async fn given_oversized_body_when_producing_should_reject_413(harness: &TestHarness) {
+    let http = HttpClient::login_root(harness).await;
+    http.create_stream_and_topic("http-oversized", "big", 1)
+        .await;
+
+    // 3 MiB payload -> ~4 MiB JSON after base64: over the 2 MB body cap under
+    // either decimal or binary megabyte semantics.
+    let oversized = IggyMessage::builder()
+        .payload(vec![0x42u8; 3 * 1024 * 1024].into())
+        .build()
+        .expect("message build");
+    let response = http
+        .produce("http-oversized", "big", PARTITION_ID, vec![oversized])
+        .await;
+    assert_eq!(
+        response.status(),
+        StatusCode::PAYLOAD_TOO_LARGE,
+        "oversized body must be rejected"
+    );
+
+    // The rejection must not poison the listener: a normal produce still commits.
+    let normal = text_message(1, "small-after-large".to_string());
+    let response = http
+        .produce("http-oversized", "big", PARTITION_ID, vec![normal])
+        .await;
+    assert_eq!(
+        response.status(),
+        StatusCode::CREATED,
+        "server must stay healthy after a 413"
+    );
+    let polled = http
+        .poll("http-oversized", "big", PARTITION_ID, 0, 10)
+        .await;
+    assert_eq!(polled.messages.len(), 1, "normal produce must be pollable");
+    assert_eq!(
+        polled.messages[0].payload,
+        bytes::Bytes::from("small-after-large")
+    );
+}
+
+#[iggy_harness]
+async fn given_ack_none_when_producing_should_return_202_and_commit(harness: &TestHarness) {
+    let http = HttpClient::login_root(harness).await;
+    http.create_stream_and_topic("http-ack-none", "fire", 1)
+        .await;
+
+    let message = text_message(1, "fire-and-forget".to_string());
+    let response = http
+        .produce_with_query(
+            "http-ack-none",
+            "fire",
+            PARTITION_ID,
+            vec![message],
+            "?ack=none",
+        )
+        .await;
+    assert_eq!(
+        response.status(),
+        StatusCode::ACCEPTED,
+        "ack=none must answer before the commit"
+    );
+    assert_eq!(durability(&response), DURABILITY_NONE);
+
+    // The commit still happens, just asynchronously: poll bounded until the
+    // message becomes visible.
+    let deadline = Instant::now() + ASYNC_COMMIT_TIMEOUT;
+    loop {
+        let polled = http
+            .poll("http-ack-none", "fire", PARTITION_ID, 0, 10)
+            .await;
+        if !polled.messages.is_empty() {
+            assert_eq!(polled.messages.len(), 1, "exactly one message was produced");
+            assert_eq!(
+                polled.messages[0].payload,
+                bytes::Bytes::from("fire-and-forget"),
+                "ack=none payload must round trip"
+            );
+            break;
+        }
+        assert!(
+            Instant::now() < deadline,
+            "ack=none produce did not become pollable within {ASYNC_COMMIT_TIMEOUT:?}"
+        );
+        sleep(ASYNC_COMMIT_RETRY_INTERVAL).await;
+    }
+}
+
+/// End-to-end RBAC proof: an ungranted user is 403 on a metadata read and on a
+/// data-plane produce, root stays 200/201, and the auth-only cluster-metadata
+/// route is never gated. Exercises the HTTP per-op gates (read + partition
+/// write) and the in-apply control gate (user creation as root) in one path.
+#[iggy_harness]
+async fn given_ungranted_user_when_reading_or_producing_should_be_forbidden(harness: &TestHarness) {
+    let root = HttpClient::login_root(harness).await;
+    root.create_stream_and_topic("rbac-stream", "rbac-topic", 1)
+        .await;
+    // Root provisions a user with no permissions, then we log in as them.
+    root.create_user("rbac-nobody", "rbac-nobody-pass").await;
+    let nobody = root.login("rbac-nobody", "rbac-nobody-pass").await;
+
+    // A metadata read: 403 for the ungranted user, 200 for root.
+    assert_eq!(
+        nobody.get("/streams").await.status(),
+        StatusCode::FORBIDDEN,
+        "ungranted read must be 403"
+    );
+    assert_eq!(
+        root.get("/streams").await.status(),
+        StatusCode::OK,
+        "root read must be 200"
+    );
+
+    // A data-plane produce: 403 for the ungranted user, 201 for root.
+    let denied = nobody
+        .produce(
+            "rbac-stream",
+            "rbac-topic",
+            PARTITION_ID,
+            vec![text_message(1, "denied".to_string())],
+        )
+        .await;
+    assert_eq!(
+        denied.status(),
+        StatusCode::FORBIDDEN,
+        "ungranted produce must be 403"
+    );
+    let allowed = root
+        .produce(
+            "rbac-stream",
+            "rbac-topic",
+            PARTITION_ID,
+            vec![text_message(2, "allowed".to_string())],
+        )
+        .await;
+    assert_eq!(
+        allowed.status(),
+        StatusCode::CREATED,
+        "root produce must commit"
+    );
+
+    // The auth-only cluster-metadata route carries no rule: any authenticated
+    // user reaches it, ungranted included.
+    assert_eq!(
+        nobody.get("/cluster/metadata").await.status(),
+        StatusCode::OK,
+        "cluster metadata is auth-only, never RBAC-gated"
+    );
+}
+
+/// The unauthenticated `POST /users/refresh-token` route: the current token
+/// travels in the JSON body, sent with no bearer header.
+trait RefreshExt {
+    async fn refresh(&self, token: &str) -> Response;
+}
+
+impl RefreshExt for HttpClient {
+    async fn refresh(&self, token: &str) -> Response {
+        self.client
+            .post(self.url("/users/refresh-token"))
+            .json(&json!({ "token": token }))
+            .send()
+            .await
+            .expect("refresh-token request")
+    }
+}
+
+#[iggy_harness]
+async fn given_valid_access_token_when_refreshing_should_issue_working_token_without_revoking_old(
+    harness: &TestHarness,
+) {
+    let http = HttpClient::login_root(harness).await;
+
+    // Baseline identity carrying both a token and its expiry; `login_root` keeps
+    // only the token string, so log in once more for the full `IdentityInfo`.
+    let old: IdentityInfo = http
+        .client
+        .post(http.url("/users/login"))
+        .json(&json!({
+            "username": DEFAULT_ROOT_USERNAME,
+            "password": DEFAULT_ROOT_PASSWORD,
+        }))
+        .send()
+        .await
+        .expect("login request")
+        .json()
+        .await
+        .expect("decode IdentityInfo");
+    let old_user_id = old.user_id;
+    let old_token = old.access_token.expect("login returns a token");
+
+    let response = http.refresh(&old_token.token).await;
+    assert_eq!(response.status(), StatusCode::OK, "refresh must answer 200");
+    let refreshed: IdentityInfo = response.json().await.expect("decode IdentityInfo");
+    let new_token = refreshed.access_token.expect("refresh returns a token");
+
+    assert_eq!(
+        refreshed.user_id, old_user_id,
+        "refresh must re-issue for the same user"
+    );
+    assert_ne!(
+        new_token.token, old_token.token,
+        "a fresh jti must yield a different token"
+    );
+    assert!(
+        new_token.expiry >= old_token.expiry,
+        "the refreshed token must not expire earlier than the one it replaced"
+    );
+
+    // The fresh token authenticates: its new jti lazily Registers a session on
+    // first authed use.
+    assert_eq!(
+        http.with_token(new_token.token)
+            .get("/streams")
+            .await
+            .status(),
+        StatusCode::OK,
+        "the refreshed token must authenticate"
+    );
+
+    // Stateless by design: server-ng has no replicated revocation list (P3), so
+    // refreshing never invalidates the token it was minted from - the old token
+    // lives to its natural exp. Deliberate, not a bug; the same posture as
+    // logout, which ends a session without revoking its bearer.
+    assert_eq!(
+        http.with_token(old_token.token)
+            .get("/streams")
+            .await
+            .status(),
+        StatusCode::OK,
+        "the pre-refresh token stays valid (no revocation by design)"
+    );
+}
+
+#[iggy_harness]
+async fn given_garbage_or_empty_token_when_refreshing_should_reject_401(harness: &TestHarness) {
+    let http = HttpClient::login_root(harness).await;
+    for token in ["not-a-jwt", ""] {
+        let response = http.refresh(token).await;
+        assert_eq!(
+            response.status(),
+            StatusCode::UNAUTHORIZED,
+            "refresh with an invalid token ({token:?}) must be 401"
+        );
+    }
+}
diff --git a/core/integration/tests/server/legacy_login_vsr.rs b/core/integration/tests/server/legacy_login_vsr.rs
new file mode 100644
index 000000000..4161a4c3b
--- /dev/null
+++ b/core/integration/tests/server/legacy_login_vsr.rs
@@ -0,0 +1,108 @@
+// 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.
+
+//! Legacy login codes against server-ng (vsr). server-ng authenticates only
+//! through the Register handshake, so the pre-register `LOGIN_USER` (38) and
+//! `LOGIN_WITH_PERSONAL_ACCESS_TOKEN` (44) codes -- which the vsr SDK never
+//! emits (its typed login methods send the register codes, its raw path
+//! rejects session-control codes) -- must be rejected with a typed
+//! `MalformedLogin` eviction, instead of the misleading `NoSession` eviction
+//! the pre-auth guard would send unbound, or the silent empty-ok reply the
+//! bound non-replicated path would send.
+//! Since the SDK cannot send these codes, the frames are hand-crafted on a raw
+//! TCP socket: a header-only non-replicated frame carrying the code in the
+//! reserved command slot.
+
+#![cfg(feature = "vsr")]
+
+use iggy_binary_protocol::HEADER_SIZE;
+use iggy_binary_protocol::codes::{LOGIN_USER_CODE, LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE};
+use iggy_binary_protocol::consensus::{Command2, Operation, RequestHeader};
+use iggy_binary_protocol::namespace::METADATA_CONSENSUS_NAMESPACE;
+use integration::harness::TestHarness;
+use integration::iggy_harness;
+use std::mem::offset_of;
+use std::time::Duration;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpStream;
+use tokio::time::timeout;
+
+// Wire byte pinned to `EvictionReason::MalformedLogin` in consensus::header.
+const EVICTION_REASON_MALFORMED_LOGIN: u8 = 15;
+
+#[iggy_harness]
+async fn given_legacy_login_user_code_when_sent_raw_should_evict_malformed_login(
+    harness: &TestHarness,
+) {
+    assert_legacy_login_code_evicted(harness, LOGIN_USER_CODE).await;
+}
+
+#[iggy_harness]
+async fn given_legacy_pat_login_code_when_sent_raw_should_evict_malformed_login(
+    harness: &TestHarness,
+) {
+    assert_legacy_login_code_evicted(harness, LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE).await;
+}
+
+/// Send a header-only non-replicated frame carrying `code` in the reserved
+/// command slot and assert the server answers with a `MalformedLogin`
+/// eviction. The reject runs before the session gate, so this unbound socket
+/// exercises the same path a bound connection would.
+async fn assert_legacy_login_code_evicted(harness: &TestHarness, code: u32) {
+    let mut header = RequestHeader {
+        command: Command2::Request,
+        operation: Operation::NonReplicated,
+        size: u32::try_from(HEADER_SIZE).unwrap(),
+        // NonReplicated leaves session / request unchecked, but the header
+        // validator still requires a nonzero client id.
+        client: 0xC0FFEE,
+        session: 0,
+        request: 0,
+        namespace: METADATA_CONSENSUS_NAMESPACE,
+        ..Default::default()
+    };
+    // A non-replicated command code travels in the first 4 reserved bytes.
+    header.reserved[..4].copy_from_slice(&code.to_le_bytes());
+
+    let addr = harness
+        .server()
+        .tcp_addr()
+        .expect("server must expose a TCP address");
+    let mut stream = TcpStream::connect(addr).await.unwrap();
+    stream.write_all(bytemuck::bytes_of(&header)).await.unwrap();
+
+    // Eviction is header-only: exactly 256 bytes. The timeout makes the
+    // fail-fast contract explicit -- a regression that silently drops the
+    // frame trips this instead of hanging until the test wall clock.
+    let mut reply = [0u8; HEADER_SIZE];
+    timeout(Duration::from_secs(5), stream.read_exact(&mut reply))
+        .await
+        .expect("server must answer a legacy login code within 5s, not stall")
+        .expect("reading the eviction frame must succeed");
+
+    let command_offset = offset_of!(RequestHeader, command);
+    assert_eq!(
+        reply[command_offset],
+        Command2::Eviction as u8,
+        "expected an Eviction frame for legacy login code {code}, not a Reply"
+    );
+    assert_eq!(
+        reply[HEADER_SIZE - 1],
+        EVICTION_REASON_MALFORMED_LOGIN,
+        "legacy login code {code} must evict with MalformedLogin"
+    );
+}
diff --git a/core/integration/tests/server/mod.rs b/core/integration/tests/server/mod.rs
index eaed6e591..dde3e6019 100644
--- a/core/integration/tests/server/mod.rs
+++ b/core/integration/tests/server/mod.rs
@@ -15,7 +15,8 @@
 // specific language governing permissions and limitations
 // under the License.
 
-// a2a_jwt is HTTP-only (JWT against the HTTP transport); vsr has no HTTP.
+// a2a_jwt exercises trusted-issuer (JWKS) tokens; server-ng's HTTP JWT
+// verifier has no trusted-issuer path.
 #[cfg(not(feature = "vsr"))]
 mod a2a_jwt;
 // Polling-based consumer-group scenarios are not implemented under vsr yet.
@@ -24,6 +25,27 @@ mod cg;
 // The ported round-robin membership join scenario runs against server-ng.
 #[cfg(feature = "vsr")]
 mod cg_vsr;
+// Flush (FLUSH_UNSAVED_BUFFER) has no server-ng primitive; it must deny typed.
+#[cfg(feature = "vsr")]
+mod flush_vsr;
+// Legacy login codes (LOGIN_USER / LOGIN_WITH_PAT) have no server-ng handler;
+// they must evict typed (MalformedLogin), not stall or reply empty-ok.
+#[cfg(feature = "vsr")]
+mod legacy_login_vsr;
+// Shared HTTP transport plumbing (session + verb helpers) for the raw-HTTP
+// server-ng suites below.
+#[cfg(feature = "vsr")]
+mod http_client;
+// Raw-HTTP data-plane contract against server-ng's shard-0 listener.
+#[cfg(feature = "vsr")]
+mod http_vsr;
+// Raw-HTTP wire-contract residue against server-ng (status codes + typed error
+// bodies); the RBAC matrix lives in permissions_scenario.
+#[cfg(feature = "vsr")]
+mod http_rbac;
+// Binary GetClusterMetadata must serve the real roster from a VSR cluster.
+#[cfg(feature = "vsr")]
+mod cluster_metadata_vsr;
 // 80-case race matrix with hardcoded HTTP variants (test_matrix bypasses
 // the harness transport filter).
 mod concurrent_addition;
@@ -34,8 +56,8 @@ mod message_cleanup;
 mod message_retrieval;
 // Server restarts, consumer-group barriers, and DeleteSegments maintenance.
 // `should_delete_segments_without_consumers` is framing-agnostic + async-aware
-// and runs under server-ng; the consumer-group / restart variants stay
-// legacy-shaped (cg polling has its own vsr gaps) and are filtered out there.
+// and runs fully (both restart variants) under server-ng; the consumer-group
+// variants stay legacy-shaped or vsr-gated (cg polling has its own vsr gaps).
 mod purge_delete;
 mod scenarios;
 mod specific;
diff --git a/core/integration/tests/server/purge_delete.rs b/core/integration/tests/server/purge_delete.rs
index d190de21f..92dfe3539 100644
--- a/core/integration/tests/server/purge_delete.rs
+++ b/core/integration/tests/server/purge_delete.rs
@@ -46,12 +46,9 @@ async fn should_delete_segments_and_validate_filesystem(
 ))]
 // Recovery on restart is fixed -- server-ng reads its own 24-byte segment index
 // via `segment_recovery` (no more "Index data must be exactly 16 bytes" panic;
-// the server restarts + re-meshes cleanly). restart_on stays vsr-gated on a
-// CLIENT blocker: the SDK `ConsensusSession` cannot reconnect after restart --
-// `register_request_id already called` (core/sdk/src/session.rs:121), the same
-// reconnect/re-register lifecycle `user_scenario` hits. Re-enable once that lands.
-#[cfg_attr(not(feature = "vsr"), test_matrix([restart_off(), restart_on()]))]
-#[cfg_attr(feature = "vsr", test_matrix([restart_off()]))]
+// the server restarts + re-meshes cleanly). The SDK reconnect-after-restart
+// re-login lifecycle is fixed too, so restart_on runs under vsr.
+#[test_matrix([restart_off(), restart_on()])]
 async fn should_delete_segments_without_consumers(harness: &mut TestHarness, restart_server: bool) {
     purge_delete_scenario::run_no_consumers(harness, restart_server).await;
 }
@@ -101,9 +98,12 @@ async fn should_block_deletion_until_all_consumers_pass_segment(
 ))]
 // The scenario asserts the exact [0, 7, 14, 21] layout only on the legacy path;
 // under vsr it verifies the framing-agnostic purge outcome (offsets cleared,
-// files deleted, partition reset to a single segment at offset 0). restart_on
-// stays vsr-gated on the SDK reconnect-after-restart lifecycle (see
-// `should_delete_segments_without_consumers`).
+// files deleted, partition reset to a single segment at offset 0). The SDK
+// re-login panic is fixed, but restart_on stays vsr-gated on a separate
+// consumer-group blocker: after restart the heartbeat's group-assignment
+// refresh reconnect path re-binds an already-bound VSR session and fails
+// `AlreadyAuthenticated` (the consumerless `should_delete_segments_without_consumers`
+// restart_on passes, so this is CG-reconnect specific, not the login re-arm).
 #[cfg_attr(not(feature = "vsr"), test_matrix([restart_off(), restart_on()]))]
 #[cfg_attr(feature = "vsr", test_matrix([restart_off()]))]
 async fn should_purge_topic_and_clear_consumer_offsets(
@@ -117,9 +117,6 @@ fn restart_off() -> bool {
     false
 }
 
-// Only the `not(vsr)` matrix cell uses restart_on (restart is vsr-gated on the
-// SDK reconnect lifecycle above), so it is unused under the vsr feature.
-#[cfg(not(feature = "vsr"))]
 fn restart_on() -> bool {
     true
 }
diff --git a/core/integration/tests/server/scenarios/mod.rs b/core/integration/tests/server/scenarios/mod.rs
index be99a9ad9..b36884ae1 100644
--- a/core/integration/tests/server/scenarios/mod.rs
+++ b/core/integration/tests/server/scenarios/mod.rs
@@ -31,7 +31,10 @@ pub mod consumer_group_with_multiple_clients_polling_messages_scenario;
 pub mod consumer_group_with_single_client_polling_messages_scenario;
 pub mod consumer_timestamp_polling_scenario;
 pub mod create_message_payload;
-#[cfg(not(feature = "vsr"))]
+// Cross-protocol PAT visibility (create via HTTP, list via TCP across shards,
+// and the reverse). Runs under vsr too: server-ng serves the PAT routes on its
+// shard-0 HTTP listener and the create/delete commit through the metadata STM,
+// so the token replicates to every shard a TCP client may land on.
 pub mod cross_protocol_pat_scenario;
 #[cfg(not(feature = "vsr"))]
 pub mod encryption_scenario;
@@ -45,7 +48,6 @@ pub mod message_cleanup_scenario;
 pub mod message_headers_scenario;
 pub mod message_size_scenario;
 pub mod offset_scenario;
-#[cfg(not(feature = "vsr"))]
 pub mod permissions_scenario;
 pub mod purge_delete_scenario;
 pub mod read_during_persistence_scenario;
@@ -55,7 +57,6 @@ pub mod reconnect_after_restart_scenario;
 pub mod restart_offset_skip_scenario;
 pub mod segment_rotation_race_scenario;
 pub mod single_message_per_batch_scenario;
-#[cfg(not(feature = "vsr"))]
 pub mod snapshot_scenario;
 pub mod stale_client_consumer_group_scenario;
 pub mod stream_size_validation_scenario;
@@ -64,7 +65,6 @@ pub mod stress_produce_consume_scenario;
 pub mod system_scenario;
 pub mod tcp_tls_scenario;
 pub mod timestamp_scenario;
-#[cfg(not(feature = "vsr"))]
 pub mod user_scenario;
 pub mod websocket_tls_scenario;
 
diff --git a/core/integration/tests/server/scenarios/permissions_scenario.rs b/core/integration/tests/server/scenarios/permissions_scenario.rs
index f16c168f3..76df9d80f 100644
--- a/core/integration/tests/server/scenarios/permissions_scenario.rs
+++ b/core/integration/tests/server/scenarios/permissions_scenario.rs
@@ -69,6 +69,15 @@ pub async fn run(harness: &TestHarness) {
     // Missing resource behavior tests
     test_missing_resource_behavior(harness, &root_client).await;
 
+    // RBAC surface ported from the raw-HTTP server-ng suite (server::http_rbac),
+    // asserting the exact typed IggyError over every transport.
+    test_consumer_offset_permissions(harness, &root_client).await;
+    test_change_password_reply_path(harness, &root_client).await;
+    test_root_user_is_protected(harness, &root_client).await;
+    test_live_permission_revocation(harness, &root_client).await;
+    test_deleted_user_session_denied(harness, &root_client).await;
+    test_personal_access_token_lifecycle(harness, &root_client).await;
+
     cleanup(&root_client).await;
 }
 
@@ -2435,10 +2444,374 @@ async fn test_missing_resource_behavior(harness: &TestHarness, root_client: &Igg
     delete_test_user(root_client, SCOPED_USER).await;
 }
 
+// =============================================================================
+// Test: Consumer-offset ops honor RBAC (ported from server::http_rbac).
+// A no-permission user is denied on the writes (store/delete surface the exact
+// Unauthorized); the GET is an enumeration-safe read the legacy server answers
+// with Ok(None), so it stays on the lenient helper.
+// =============================================================================
+
+async fn test_consumer_offset_permissions(harness: &TestHarness, root_client: &IggyClient) {
+    const USER: &str = "offset-no-perms-user";
+    create_test_user(root_client, USER, Some(no_permissions())).await;
+    let client = login_user(harness, USER).await;
+
+    let stream_id = Identifier::named(STREAM_1).unwrap();
+    let topic_id = Identifier::named(TOPIC_1).unwrap();
+    let consumer = Consumer::default();
+
+    assert_error_code(
+        client
+            .store_consumer_offset(&consumer, &stream_id, &topic_id, Some(0), 0)
+            .await,
+        IggyError::Unauthorized,
+        "no perms: store_consumer_offset",
+    );
+    assert_error_code(
+        client
+            .delete_consumer_offset(&consumer, &stream_id, &topic_id, Some(0))
+            .await,
+        IggyError::Unauthorized,
+        "no perms: delete_consumer_offset",
+    );
+    // GET is enumeration-safe: legacy answers Ok(None), server-ng denies typed.
+    assert_unauthorized(
+        client
+            .get_consumer_offset(&consumer, &stream_id, &topic_id, Some(0))
+            .await,
+        "no perms: get_consumer_offset",
+    );
+
+    delete_test_user(root_client, USER).await;
+}
+
+// =============================================================================
+// Test: Root (user id 0) is business-rule protected even from a manage_users
+// admin: delete -> CannotDeleteUser, re-permission -> CannotChangePermissions.
+// Both fire AFTER the RBAC gate admits the acting admin.
+// =============================================================================
+
+async fn test_root_user_is_protected(harness: &TestHarness, root_client: &IggyClient) {
+    const ADMIN: &str = "root-guard-admin";
+    create_test_user(
+        root_client,
+        ADMIN,
+        Some(Permissions {
+            global: GlobalPermissions {
+                manage_users: true,
+                ..Default::default()
+            },
+            streams: None,
+        }),
+    )
+    .await;
+    let admin = login_user(harness, ADMIN).await;
+    let root_id = Identifier::numeric(0).unwrap();
+
+    assert_error_code(
+        admin.delete_user(&root_id).await,
+        IggyError::CannotDeleteUser(0),
+        "admin delete root",
+    );
+    assert_error_code(
+        admin
+            .update_permissions(&root_id, Some(no_permissions()))
+            .await,
+        IggyError::CannotChangePermissions(0),
+        "admin re-permission root",
+    );
+
+    delete_test_user(root_client, ADMIN).await;
+}
+
+// =============================================================================
+// Test: Live revocation on a single node. Stripping send from a granted user
+// denies the next send immediately (exact Unauthorized) while the retained poll
+// grant still succeeds.
+// =============================================================================
+
+async fn test_live_permission_revocation(harness: &TestHarness, root_client: &IggyClient) {
+    const USER: &str = "revocation-user";
+    create_test_user(
+        root_client,
+        USER,
+        Some(Permissions {
+            global: GlobalPermissions {
+                poll_messages: true,
+                send_messages: true,
+                ..Default::default()
+            },
+            streams: None,
+        }),
+    )
+    .await;
+    let client = login_user(harness, USER).await;
+
+    let stream_id = Identifier::named(STREAM_1).unwrap();
+    let topic_id = Identifier::named(TOPIC_1).unwrap();
+
+    let mut msgs = test_messages();
+    client
+        .send_messages(
+            &stream_id,
+            &topic_id,
+            &Partitioning::partition_id(0),
+            &mut msgs,
+        )
+        .await
+        .expect("send must work before revocation");
+
+    root_client
+        .update_permissions(
+            &Identifier::named(USER).unwrap(),
+            Some(Permissions {
+                global: GlobalPermissions {
+                    poll_messages: true,
+                    send_messages: false,
+                    ..Default::default()
+                },
+                streams: None,
+            }),
+        )
+        .await
+        .expect("root re-permission (strip send)");
+
+    let mut msgs = test_messages();
+    assert_error_code(
+        client
+            .send_messages(
+                &stream_id,
+                &topic_id,
+                &Partitioning::partition_id(0),
+                &mut msgs,
+            )
+            .await,
+        IggyError::Unauthorized,
+        "send must be denied after send is stripped",
+    );
+    client
+        .poll_messages(
+            &stream_id,
+            &topic_id,
+            Some(0),
+            &Consumer::default(),
+            &PollingStrategy::offset(0),
+            1,
+            false,
+        )
+        .await
+        .expect("poll must still work (poll grant retained)");
+
+    delete_test_user(root_client, USER).await;
+}
+
+// =============================================================================
+// Test: Deleting a user with a live session denies that session's next read.
+// A read_servers grant makes the pre-delete read a real Ok (get_stats is not
+// enumeration-safe), so the post-delete denial is unambiguous. The binary
+// transports may answer Unauthenticated (session eviction) rather than
+// Unauthorized, so accept either.
+// =============================================================================
+
+async fn test_deleted_user_session_denied(harness: &TestHarness, root_client: &IggyClient) {
+    const USER: &str = "deleted-session-user";
+    create_test_user(
+        root_client,
+        USER,
+        Some(Permissions {
+            global: GlobalPermissions {
+                read_servers: true,
+                ..Default::default()
+            },
+            streams: None,
+        }),
+    )
+    .await;
+    let client = login_user(harness, USER).await;
+    client
+        .get_stats()
+        .await
+        .expect("read_servers get_stats must work before deletion");
+
+    root_client
+        .delete_user(&Identifier::named(USER).unwrap())
+        .await
+        .expect("root delete user");
+
+    let result = client.get_stats().await;
+    assert!(
+        matches!(&result, Err(e)
+            if e.as_code() == IggyError::Unauthorized.as_code()
+                || e.as_code() == IggyError::Unauthenticated.as_code()),
+        "deleted user's live session must be denied (Unauthorized or Unauthenticated), got {result:?}"
+    );
+}
+
+// =============================================================================
+// Test: PAT lifecycle over the SDK. A no-permission user self-manages PATs
+// (create/list/delete bypass RBAC); a fresh session authenticating WITH the raw
+// PAT inherits the owner's empty rule set, so a subsequent op is denied; and the
+// listing is caller-scoped (only the caller's own tokens).
+// =============================================================================
+
+async fn test_personal_access_token_lifecycle(harness: &TestHarness, root_client: &IggyClient) {
+    const USER: &str = "pat-lifecycle-user";
+    const OTHER: &str = "pat-other-user";
+    create_test_user(root_client, USER, Some(no_permissions())).await;
+    create_test_user(root_client, OTHER, Some(no_permissions())).await;
+    let client = login_user(harness, USER).await;
+    let other = login_user(harness, OTHER).await;
+
+    // Self-scoped create/delete bypass RBAC even with no permissions.
+    client
+        .create_personal_access_token("pat-to-delete", PersonalAccessTokenExpiry::NeverExpire)
+        .await
+        .expect("no perms: create own PAT should work");
+    client
+        .delete_personal_access_token("pat-to-delete")
+        .await
+        .expect("no perms: delete own PAT should work");
+
+    // A PAT resolves to its owner, inheriting the owner's (empty) rules.
+    let raw = client
+        .create_personal_access_token("pat-identity", PersonalAccessTokenExpiry::NeverExpire)
+        .await
+        .expect("no perms: create identity PAT should work");
+    let via_pat = create_client(harness).await;
+    via_pat
+        .login_with_personal_access_token(&raw.token)
+        .await
+        .expect("PAT login should succeed");
+    assert_unauthorized(
+        via_pat.get_streams().await,
+        "PAT-bound session inherits the owner's empty rules",
+    );
+
+    // Listing is caller-scoped: OTHER never sees USER's tokens.
+    other
+        .create_personal_access_token("other-pat", PersonalAccessTokenExpiry::NeverExpire)
+        .await
+        .expect("other: create own PAT should work");
+    let owner_tokens: Vec<String> = client
+        .get_personal_access_tokens()
+        .await
+        .expect("owner PAT list")
+        .into_iter()
+        .map(|token| token.name)
+        .collect();
+    assert_eq!(
+        owner_tokens,
+        vec!["pat-identity".to_string()],
+        "owner must list exactly their own live token"
+    );
+    let other_tokens: Vec<String> = other
+        .get_personal_access_tokens()
+        .await
+        .expect("other PAT list")
+        .into_iter()
+        .map(|token| token.name)
+        .collect();
+    assert_eq!(
+        other_tokens,
+        vec!["other-pat".to_string()],
+        "other must never see the owner's tokens"
+    );
+
+    delete_test_user(root_client, USER).await;
+    delete_test_user(root_client, OTHER).await;
+}
+
+// =============================================================================
+// Test: change-password over the consensus reply path (every transport).
+//
+// A wrong current password returns the typed `InvalidCredentials` and a missing
+// target returns `ResourceNotFound`, and in EVERY case the caller's connection
+// stays usable for the next replicated op. Regression guard: the binary
+// transports used to deny a wrong current password pre-consensus, consuming the
+// client's request id without advancing the replicated `ClientTable`, so the
+// next replicated request hit a `RequestGap` and was silently dropped until the
+// socket timed out. The rejection now commits as a no-op, keeping the sequence
+// contiguous.
+// =============================================================================
+
+async fn test_change_password_reply_path(harness: &TestHarness, root_client: &IggyClient) {
+    const USER: &str = "change-password-user";
+    const ADMIN: &str = "change-password-admin";
+    create_test_user(root_client, USER, Some(no_permissions())).await;
+    create_test_user(root_client, ADMIN, Some(manage_users_permissions())).await;
+
+    let user_id = Identifier::named(USER).unwrap();
+    let client = login_user(harness, USER).await;
+
+    // Wrong current password: typed rejection, connection still usable.
+    assert_error_code(
+        client
+            .change_password(&user_id, "wrong-current", "password456")
+            .await,
+        IggyError::InvalidCredentials,
+        "self change with a wrong current password",
+    );
+    client
+        .create_personal_access_token(
+            "change-password-probe-1",
+            PersonalAccessTokenExpiry::NeverExpire,
+        )
+        .await
+        .expect("connection stays usable after a wrong-current rejection");
+
+    // Correct current password: succeeds, session stays live.
+    client
+        .change_password(&user_id, "password123", "password456")
+        .await
+        .expect("self change with the correct current password");
+    client
+        .create_personal_access_token(
+            "change-password-probe-2",
+            PersonalAccessTokenExpiry::NeverExpire,
+        )
+        .await
+        .expect("connection stays usable after a successful self rotation");
+
+    // Admin changing a missing target: typed not-found, connection still usable.
+    let admin = login_user(harness, ADMIN).await;
+    assert_error_code(
+        admin
+            .change_password(
+                &Identifier::named("change-password-ghost").unwrap(),
+                "irrelevant",
+                "password456",
+            )
+            .await,
+        IggyError::ResourceNotFound(String::new()),
+        "admin change for a non-existent target",
+    );
+    admin
+        .create_personal_access_token(
+            "change-password-probe-3",
+            PersonalAccessTokenExpiry::NeverExpire,
+        )
+        .await
+        .expect("connection stays usable after a missing-target rejection");
+
+    delete_test_user(root_client, USER).await;
+    delete_test_user(root_client, ADMIN).await;
+}
+
 // =============================================================================
 // Helpers
 // =============================================================================
 
+fn manage_users_permissions() -> Permissions {
+    Permissions {
+        global: GlobalPermissions {
+            manage_users: true,
+            read_users: true,
+            ..GlobalPermissions::default()
+        },
+        streams: None,
+    }
+}
+
 fn test_messages() -> Vec<IggyMessage> {
     vec![
         IggyMessage::builder()
@@ -2475,6 +2848,35 @@ fn assert_unauthorized<T: std::fmt::Debug>(result: Result<T, IggyError>, context
     }
 }
 
+/// Assert an exact typed `IggyError`. Ported RBAC cases assert the precise code
+/// the server returns, not the enumeration-safe set of [`assert_unauthorized`].
+///
+/// The HTTP SDK reconstructs only 401/403/404 as typed errors and collapses
+/// every other status (notably the 400s carrying `InvalidCredentials`,
+/// `CannotDeleteUser`, and `CannotChangePermissions`) into a generic
+/// `HttpResponseError` wrapping the typed JSON body. Read the exact code back
+/// from that body's `id` so the assertion stays exact over HTTP as well.
+fn assert_error_code<T: std::fmt::Debug>(
+    result: Result<T, IggyError>,
+    expected: IggyError,
+    context: &str,
+) {
+    let expected_code = expected.as_code();
+    match &result {
+        Err(e) if e.as_code() == expected_code => {}
+        Err(IggyError::HttpResponseError(_, body))
+            if http_error_id(body) == Some(expected_code) => {}
+        _ => panic!("{context}: expected error code {expected_code}, got {result:?}"),
+    }
+}
+
+/// The `id` field of an HTTP `ErrorResponse` body is the wire `IggyError` code,
+/// so a status-collapsed `HttpResponseError` can still be matched exactly.
+fn http_error_id(body: &str) -> Option<u32> {
+    let value: serde_json::Value = serde_json::from_str(body).ok()?;
+    value.get("id")?.as_u64().map(|id| id as u32)
+}
+
 fn assert_not_found_or_related<T: std::fmt::Debug>(result: Result<T, IggyError>, context: &str) {
     // Accept various "not found" error codes that indicate resource absence.
     // This is used when an authorized user tries to access a non-existent resource.
diff --git a/core/message_bus/src/lib.rs b/core/message_bus/src/lib.rs
index fc96d7afb..450873350 100644
--- a/core/message_bus/src/lib.rs
+++ b/core/message_bus/src/lib.rs
@@ -95,13 +95,14 @@ pub use installer::conn_info::{
 };
 pub use lifecycle::{
     BusMessage, BusReceiver, BusSender, ConnectionRegistry, DrainOutcome, FusedShutdown,
-    ReplicaRegistry, Shutdown, ShutdownToken,
+    InstanceToken, ReplicaRegistry, ReplyRoute, ReplySlotError, ReplySlotGuard, Shutdown,
+    ShutdownToken,
 };
 pub use transports::tls::TlsServerCredentials;
 
 pub use compio::runtime::JoinHandle;
 use configs::server_ng::ServerNgConfig;
-use iggy_binary_protocol::GenericHeader;
+use iggy_binary_protocol::{GenericHeader, ReplyHeader};
 use server_common::{MESSAGE_ALIGN, Message, iobuf::Frozen};
 use std::array;
 use std::cell::{Cell, OnceCell, RefCell};
@@ -1225,13 +1226,21 @@ impl MessageBus for IggyMessageBus {
         // Owning shard is encoded in the top 16 bits of client_id.
         let owning_shard = client_id_owning_shard(client_id);
         if owning_shard == self.shard_id {
-            // Fast path: move `message` straight into `try_send`. On no-slot
-            // the registry hands it back; we drop it and surface
-            // ClientNotFound (matches prior behaviour: SendError did not
-            // preserve payload either).
+            // Route by the entry's reply target. Socket is the unchanged
+            // fast path and never decodes. InProcess resolves the waiting
+            // request id from the reply header and fires its oneshot; that
+            // decode stays off the socket path. No slot surfaces
+            // ClientNotFound (prior behaviour: SendError did not preserve
+            // the payload either).
             return match self.clients.try_send_or_return(client_id, message) {
-                Ok(send_result) => send_result.map_err(map_try_send_err),
-                Err(_msg) => Err(SendError::ClientNotFound(client_id)),
+                ReplyRoute::Delivered(send_result) => send_result.map_err(map_try_send_err),
+                ReplyRoute::InProcess(message) => {
+                    let request = reply_request_id(&message);
+                    self.clients
+                        .fire_in_process(client_id, request, message)
+                        .map_err(|_| SendError::ClientNotFound(client_id))
+                }
+                ReplyRoute::NoSlot(_message) => Err(SendError::ClientNotFound(client_id)),
             };
         }
         let forward = self
@@ -1313,6 +1322,22 @@ pub const fn client_id_owning_shard(client_id: u128) -> u16 {
     (client_id >> 112) as u16
 }
 
+/// Reserved client id stamped on server-generated auto-commit
+/// `StoreConsumerOffset2` ops (a poll's `auto_commit` replicated for failover).
+///
+/// Never belongs to a live connection: `mint_client_id` produces
+/// `(shard << 112) | seq` and no real shard is `u16::MAX`, so `u128::MAX` is
+/// unreachable. The commit path recognises it and skips the (unwaited) reply,
+/// keeping an unrequested frame off a real client's lockstep stream. Nonzero,
+/// so it still satisfies the wire header's `client != 0` validation.
+pub const AUTO_COMMIT_CLIENT_ID: u128 = u128::MAX;
+
+/// Whether `client_id` is the reserved [`AUTO_COMMIT_CLIENT_ID`] sentinel.
+#[must_use]
+pub const fn is_auto_commit_client(client_id: u128) -> bool {
+    client_id == AUTO_COMMIT_CLIENT_ID
+}
+
 /// Map an `async_channel::TrySendError` onto the bus-level [`SendError`].
 ///
 /// Shape-matches `Result::map_err` (takes the error by value) so it can be
@@ -1325,6 +1350,21 @@ fn map_try_send_err(e: async_channel::TrySendError<Frozen<MESSAGE_ALIGN>>) -> Se
     }
 }
 
+/// Peek `ReplyHeader.request` (the originating request id) from a reply
+/// buffer at its fixed header offset. Only the in-process reply path calls
+/// this; the socket path never decodes.
+fn reply_request_id(reply: &Frozen<MESSAGE_ALIGN>) -> u64 {
+    const OFFSET: usize = std::mem::offset_of!(ReplyHeader, request);
+    reply
+        .as_slice()
+        .get(OFFSET..OFFSET + std::mem::size_of::<u64>())
+        .and_then(|bytes| bytes.try_into().ok())
+        // Native-endian is safe: this id never leaves the process, so writer and
+        // reader share endianness. A wire reader uses little-endian instead (see
+        // the SDK's `read_reply_status`).
+        .map_or(0, u64::from_ne_bytes)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1375,6 +1415,56 @@ mod tests {
         assert!(matches!(err, SendError::ClientNotFound(_)));
     }
 
+    #[allow(clippy::cast_possible_truncation)]
+    fn reply_message(request: u64) -> Message<ReplyHeader> {
+        Message::<ReplyHeader>::new(HEADER_SIZE).transmute_header(|_, h: &mut ReplyHeader| {
+            h.command = Command2::Reply;
+            h.size = HEADER_SIZE as u32;
+            h.request = request;
+        })
+    }
+
+    /// Full in-process seam: entry + slot installed, `send_to_client`
+    /// resolves the request id from the reply header and fires the oneshot.
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn send_to_client_in_process_fires_matching_reply_slot() {
+        let bus = IggyMessageBus::new(0);
+        let client_id = 1u128;
+        bus.clients()
+            .insert_in_process(client_id)
+            .expect("fresh key");
+        let (_guard, reply_rx) = bus
+            .clients()
+            .install_reply_slot(client_id, 42)
+            .expect("slot installs");
+
+        bus.send_to_client(client_id, reply_message(42).into_generic().into_frozen())
+            .await
+            .expect("in-process delivery");
+
+        let received = reply_rx.await.expect("reply delivered");
+        assert_eq!(reply_request_id(&received), 42);
+    }
+
+    /// Reply whose request id has no waiter (timed-out caller already
+    /// removed its slot): shed as `ClientNotFound`, nothing panics.
+    #[compio::test]
+    #[allow(clippy::future_not_send)]
+    async fn send_to_client_in_process_sheds_reply_without_waiter() {
+        let bus = IggyMessageBus::new(0);
+        let client_id = 1u128;
+        bus.clients()
+            .insert_in_process(client_id)
+            .expect("fresh key");
+
+        let err = bus
+            .send_to_client(client_id, reply_message(42).into_generic().into_frozen())
+            .await
+            .unwrap_err();
+        assert!(matches!(err, SendError::ClientNotFound(_)));
+    }
+
     #[compio::test]
     #[allow(clippy::future_not_send)]
     async fn send_to_client_returns_route_missing_when_remote_and_no_forward_fn() {
diff --git a/core/message_bus/src/lifecycle/connection_registry.rs b/core/message_bus/src/lifecycle/connection_registry.rs
index 162d139df..8dc22ad0f 100644
--- a/core/message_bus/src/lifecycle/connection_registry.rs
+++ b/core/message_bus/src/lifecycle/connection_registry.rs
@@ -33,6 +33,7 @@
 //! [`ShutdownToken`]: crate::lifecycle::ShutdownToken
 
 use compio::runtime::JoinHandle;
+use futures::channel::oneshot;
 use server_common::{MESSAGE_ALIGN, iobuf::Frozen};
 use std::cell::{Cell, RefCell};
 use std::collections::HashMap;
@@ -40,6 +41,7 @@ use std::collections::hash_map::Entry as HmEntry;
 use std::fmt::Debug;
 use std::hash::Hash;
 use std::time::{Duration, Instant};
+use thiserror::Error;
 use tracing::{debug, warn};
 
 /// Opaque per-insert generation token.
@@ -128,9 +130,111 @@ pub struct DrainOutcome {
     pub background_force: usize,
 }
 
+/// Where a client reply is delivered.
+///
+/// `Socket` is the wire path taken by every TCP / WS / QUIC client: the
+/// reply is pushed into the per-peer queue exactly as before. `InProcess`
+/// reifies an in-process reply target keyed by request id so a caller
+/// holding the matching [`oneshot::Receiver`] can await its reply without a
+/// socket. Both ends are shard-0-local, hence a single-threaded oneshot.
+#[derive(Debug)]
+enum ReplyTarget {
+    Socket(BusSender),
+    InProcess {
+        by_request: HashMap<u64, oneshot::Sender<BusMessage>>,
+    },
+}
+
+impl ReplyTarget {
+    /// Wire sender for socket targets; `None` for in-process targets.
+    const fn as_socket(&self) -> Option<&BusSender> {
+        match self {
+            Self::Socket(sender) => Some(sender),
+            Self::InProcess { .. } => None,
+        }
+    }
+}
+
+/// Outcome of routing a client reply through an [`Entry`]'s reply target.
+///
+/// `Delivered` is the socket fast path: `try_send` was attempted and its
+/// result is carried through unchanged. `InProcess` hands the message back
+/// so the caller can decode the request id and fire the matching oneshot
+/// via [`ConnectionRegistry::fire_in_process`], keeping that decode off the
+/// socket path. `NoSlot` means no entry exists for the key.
+#[derive(Debug)]
+#[must_use]
+pub enum ReplyRoute {
+    Delivered(Result<(), async_channel::TrySendError<BusMessage>>),
+    InProcess(BusMessage),
+    NoSlot(BusMessage),
+}
+
+/// Rejection reasons for [`ConnectionRegistry::install_reply_slot`].
+#[derive(Debug, Error, PartialEq, Eq)]
+pub enum ReplySlotError {
+    /// No registry entry exists for the key: the in-process entry was
+    /// never installed or has already been torn down.
+    #[error("no registry entry for this key")]
+    EntryNotFound,
+    /// The entry is a socket target. Socket replies go through the
+    /// per-peer queue; a reply slot on such an entry could never fire.
+    #[error("registry entry is a socket target")]
+    NotInProcess,
+    /// A waiter is already registered under this request id. Replacing it
+    /// would drop the first waiter's sender and could deliver its reply
+    /// to the wrong caller, so the second install is rejected instead.
+    #[error("a reply slot for this request id is already installed")]
+    DuplicateRequest,
+}
+
+/// Cancel-safety handle for an installed in-process reply slot.
+///
+/// Dropping the guard removes the slot unless the reply already fired
+/// ([`ConnectionRegistry::fire_in_process`] consumes the slot on delivery,
+/// making the drop a no-op) or the whole entry was replaced (fenced by the
+/// entry's [`InstanceToken`], mirroring `remove_if_token_matches`). A
+/// caller that times out or is cancelled therefore never leaks its oneshot
+/// sender.
+///
+/// Contract: a request id must not be reused under the same key while a
+/// previous guard for that id is still live; the stale guard's drop would
+/// remove the new slot. Callers mint monotonically increasing request ids,
+/// which rules this out.
+#[must_use = "dropping the guard removes the reply slot"]
+#[derive(Debug)]
+pub struct ReplySlotGuard<'a, K>
+where
+    K: Eq + Hash + Copy + Debug + 'static,
+{
+    registry: &'a ConnectionRegistry<K>,
+    key: K,
+    request: u64,
+    token: InstanceToken,
+}
+
+impl<K> Drop for ReplySlotGuard<'_, K>
+where
+    K: Eq + Hash + Copy + Debug + 'static,
+{
+    fn drop(&mut self) {
+        let mut entries = self.registry.entries.borrow_mut();
+        let Some(entry) = entries.get_mut(&self.key) else {
+            return;
+        };
+        if entry.token != self.token {
+            return;
+        }
+        let ReplyTarget::InProcess { by_request } = &mut entry.reply else {
+            return;
+        };
+        by_request.remove(&self.request);
+    }
+}
+
 #[derive(Debug)]
 struct Entry {
-    sender: BusSender,
+    reply: ReplyTarget,
     writer_handle: Option<JoinHandle<()>>,
     reader_handle: Option<JoinHandle<()>>,
     /// Generation token minted by the registry on insert. Entries that
@@ -215,43 +319,105 @@ where
     /// `close_peer`.
     pub fn with_sender<R>(&self, key: K, f: impl FnOnce(&BusSender) -> R) -> Option<R> {
         let entries = self.entries.borrow();
-        entries.get(&key).map(|entry| f(&entry.sender))
+        entries
+            .get(&key)
+            .and_then(|entry| entry.reply.as_socket())
+            .map(f)
     }
 
-    /// Try to send `msg` to the per-peer queue, returning the message back
-    /// when no slot exists.
+    /// Route `msg` to the entry's reply target, handing it back when no
+    /// live target accepts it.
     ///
-    /// Lets `send_to_*` skip the unconditional `Frozen::clone()` the
-    /// `with_sender` shape forced (closure consumes a clone, outer caller
-    /// retains the original for the slow path the borrow checker cannot
-    /// prove unreachable). The consuming variant moves `msg` into
-    /// `try_send` on the fast path; on no-slot the message is handed back
-    /// so the caller can route it via the slow path or drop it.
+    /// Lets `send_to_client` skip the unconditional `Frozen::clone()` the
+    /// `with_sender` shape forced. Socket targets move `msg` straight into
+    /// `try_send`; in-process targets hand it back via
+    /// [`ReplyRoute::InProcess`] so the caller can resolve the request id
+    /// and fire the oneshot without decoding on the socket path.
     ///
     /// Returns:
-    /// - `Ok(Ok(()))`: slot exists and the queue accepted `msg`.
-    /// - `Ok(Err(TrySendError))`: slot exists but `try_send` failed
-    ///   (`Full` or `Closed`); the inner error carries `msg` back per
-    ///   `async_channel`'s contract.
-    /// - `Err(msg)`: no slot for `key`; `msg` returned to the caller.
+    /// - [`ReplyRoute::Delivered`]: socket target; carries the `try_send`
+    ///   result (`Full` / `Closed` errors carry `msg` back per
+    ///   `async_channel`'s contract).
+    /// - [`ReplyRoute::InProcess`]: in-process target; `msg` handed back.
+    /// - [`ReplyRoute::NoSlot`]: no entry for `key`; `msg` handed back.
+    pub fn try_send_or_return(&self, key: K, msg: BusMessage) -> ReplyRoute {
+        let entries = self.entries.borrow();
+        let Some(entry) = entries.get(&key) else {
+            return ReplyRoute::NoSlot(msg);
+        };
+        match &entry.reply {
+            ReplyTarget::Socket(sender) => ReplyRoute::Delivered(sender.try_send(msg)),
+            ReplyTarget::InProcess { .. } => ReplyRoute::InProcess(msg),
+        }
+    }
+
+    /// Fire the in-process reply registered under `request` for `key`.
+    ///
+    /// Companion to [`ReplyRoute::InProcess`]; the socket path never reaches
+    /// here. Removes the matching oneshot and delivers `msg` on it.
     ///
     /// # Errors
     ///
-    /// The outer `Err(msg)` reports that no entry exists for `key`. The
-    /// inner `Err(TrySendError)` reports that the entry's queue is full
-    /// or closed.
-    pub fn try_send_or_return(
-        &self,
-        key: K,
-        msg: BusMessage,
-    ) -> Result<Result<(), async_channel::TrySendError<BusMessage>>, BusMessage> {
-        let entries = self.entries.borrow();
-        match entries.get(&key) {
-            Some(entry) => Ok(entry.sender.try_send(msg)),
+    /// Hands `msg` back when `key` has no in-process target or no waiter is
+    /// registered for `request`, so the caller surfaces the same not-found
+    /// outcome as a missing socket.
+    pub fn fire_in_process(&self, key: K, request: u64, msg: BusMessage) -> Result<(), BusMessage> {
+        let mut entries = self.entries.borrow_mut();
+        let Some(entry) = entries.get_mut(&key) else {
+            return Err(msg);
+        };
+        let ReplyTarget::InProcess { by_request } = &mut entry.reply else {
+            return Err(msg);
+        };
+        match by_request.remove(&request) {
+            Some(reply_tx) => reply_tx.send(msg),
             None => Err(msg),
         }
     }
 
+    /// Register a oneshot waiter for the reply to `request` on `key`'s
+    /// in-process entry.
+    ///
+    /// The returned receiver resolves when `send_to_client` routes a reply
+    /// carrying this request id (via [`Self::fire_in_process`]), or with
+    /// `Canceled` when the entry is torn down first. The guard removes the
+    /// slot on drop; see [`ReplySlotGuard`] for the exact semantics.
+    ///
+    /// # Errors
+    ///
+    /// [`ReplySlotError::EntryNotFound`] when `key` has no entry,
+    /// [`ReplySlotError::NotInProcess`] when the entry is a socket target,
+    /// [`ReplySlotError::DuplicateRequest`] when a waiter is already
+    /// registered under `request`.
+    pub fn install_reply_slot(
+        &self,
+        key: K,
+        request: u64,
+    ) -> Result<(ReplySlotGuard<'_, K>, oneshot::Receiver<BusMessage>), ReplySlotError> {
+        let mut entries = self.entries.borrow_mut();
+        let Some(entry) = entries.get_mut(&key) else {
+            return Err(ReplySlotError::EntryNotFound);
+        };
+        let token = entry.token;
+        let ReplyTarget::InProcess { by_request } = &mut entry.reply else {
+            return Err(ReplySlotError::NotInProcess);
+        };
+        let HmEntry::Vacant(slot) = by_request.entry(request) else {
+            return Err(ReplySlotError::DuplicateRequest);
+        };
+        let (reply_tx, reply_rx) = oneshot::channel();
+        slot.insert(reply_tx);
+        Ok((
+            ReplySlotGuard {
+                registry: self,
+                key,
+                request,
+                token,
+            },
+            reply_rx,
+        ))
+    }
+
     /// Register a new connection. Stores the producer side of the per-peer
     /// queue alongside the writer + reader task handles so graceful shutdown
     /// can drain everything.
@@ -294,7 +460,7 @@ where
         entries.insert(
             key,
             Entry {
-                sender,
+                reply: ReplyTarget::Socket(sender),
                 writer_handle: Some(writer_handle),
                 reader_handle: Some(reader_handle),
                 token,
@@ -304,6 +470,38 @@ where
         Ok(token)
     }
 
+    /// Register an in-process reply target for `key`.
+    ///
+    /// In-process peers have no socket and no reader / writer tasks:
+    /// replies routed to `key` resolve request-keyed oneshots installed
+    /// via [`Self::install_reply_slot`]. Teardown goes through
+    /// [`Self::remove`] / [`Self::remove_if_token_matches`]; dropping the
+    /// entry drops every pending sender, so all outstanding receivers
+    /// resolve `Canceled`.
+    ///
+    /// Returns `None` when `key` is already registered (socket or
+    /// in-process); the caller must tear the existing entry down first.
+    pub fn insert_in_process(&self, key: K) -> Option<InstanceToken> {
+        let mut entries = self.entries.borrow_mut();
+        let HmEntry::Vacant(slot) = entries.entry(key) else {
+            return None;
+        };
+        let token = self.mint_token();
+        // Nothing ever waits on this shutdown (no reader to wake); it
+        // exists only because every entry owns one.
+        let (conn_shutdown, _) = super::Shutdown::new();
+        slot.insert(Entry {
+            reply: ReplyTarget::InProcess {
+                by_request: HashMap::new(),
+            },
+            writer_handle: None,
+            reader_handle: None,
+            token,
+            _conn_shutdown: conn_shutdown,
+        });
+        Some(token)
+    }
+
     /// Unregister a connection without awaiting its tasks.
     ///
     /// Returns `true` if an entry was removed. Dropping the entry drops the
@@ -360,7 +558,9 @@ where
         let Some(mut entry) = self.entries.borrow_mut().remove(&key) else {
             return;
         };
-        entry.sender.close();
+        if let Some(sender) = entry.reply.as_socket() {
+            sender.close();
+        }
         if let Some(writer_handle) = entry.writer_handle.take() {
             let _ = compio::time::timeout(timeout, writer_handle).await;
         }
@@ -389,7 +589,9 @@ where
             }
             slot.remove()
         };
-        entry.sender.close();
+        if let Some(sender) = entry.reply.as_socket() {
+            sender.close();
+        }
         if let Some(writer_handle) = entry.writer_handle.take() {
             let _ = compio::time::timeout(timeout, writer_handle).await;
         }
@@ -447,7 +649,9 @@ where
             // the shutdown token has not been triggered. Writer is
             // awaited before the reader handle is dropped so a
             // mid-writev cancellation cannot truncate a frame.
-            entry.sender.close();
+            if let Some(sender) = entry.reply.as_socket() {
+                sender.close();
+            }
             // Writer gets the full shared deadline so a legitimately-
             // flushing `write_vectored_all` never loses budget to any
             // other entry in the same drain batch.
@@ -581,22 +785,34 @@ impl ReplicaRegistry {
         let slots = self.slots.borrow();
         slots[usize::from(key)]
             .as_ref()
-            .map(|entry| f(&entry.sender))
+            .and_then(|entry| entry.reply.as_socket())
+            .map(f)
     }
 
-    /// See [`ConnectionRegistry::try_send_or_return`].
+    /// Try to send `msg` to the replica socket registered under `key`, handing
+    /// `msg` back as `Err` when no slot is present.
+    ///
+    /// Replicas only ever register socket reply targets, so unlike
+    /// [`ConnectionRegistry::try_send_or_return`] (which returns a [`ReplyRoute`]
+    /// also covering the in-process case) this stays a flat
+    /// `Result<try-send outcome, BusMessage>`.
     ///
     /// # Errors
     ///
-    /// Same as [`ConnectionRegistry::try_send_or_return`].
+    /// `Err(msg)` when `key` has no registered slot. The inner `Err` carries a
+    /// `try_send` failure (`Full` / `Closed`), which returns `msg` per
+    /// `async_channel`'s contract.
     pub fn try_send_or_return(
         &self,
         key: u8,
         msg: BusMessage,
     ) -> Result<Result<(), async_channel::TrySendError<BusMessage>>, BusMessage> {
         let slots = self.slots.borrow();
-        match slots[usize::from(key)].as_ref() {
-            Some(entry) => Ok(entry.sender.try_send(msg)),
+        match slots[usize::from(key)]
+            .as_ref()
+            .and_then(|entry| entry.reply.as_socket())
+        {
+            Some(sender) => Ok(sender.try_send(msg)),
             None => Err(msg),
         }
     }
@@ -627,7 +843,7 @@ impl ReplicaRegistry {
         }
         let token = self.mint_token();
         *slot = Some(Entry {
-            sender,
+            reply: ReplyTarget::Socket(sender),
             writer_handle: Some(writer_handle),
             reader_handle: Some(reader_handle),
             token,
@@ -672,7 +888,9 @@ impl ReplicaRegistry {
             self.len.set(self.len.get() - 1);
             entry
         };
-        entry.sender.close();
+        if let Some(sender) = entry.reply.as_socket() {
+            sender.close();
+        }
         if let Some(writer_handle) = entry.writer_handle.take() {
             let _ = compio::time::timeout(timeout, writer_handle).await;
         }
@@ -697,7 +915,9 @@ impl ReplicaRegistry {
             self.len.set(self.len.get() - 1);
             removed_entry
         };
-        entry.sender.close();
+        if let Some(sender) = entry.reply.as_socket() {
+            sender.close();
+        }
         if let Some(writer_handle) = entry.writer_handle.take() {
             let _ = compio::time::timeout(timeout, writer_handle).await;
         }
@@ -1065,7 +1285,7 @@ mod tests {
             .expect("insert ok");
 
         let outer = reg.try_send_or_return(1u8, make_bus_msg());
-        assert!(matches!(outer, Ok(Ok(()))));
+        assert!(matches!(outer, ReplyRoute::Delivered(Ok(()))));
         // Receiver drains the message; closing the writer's rx end via
         // close_peer is not necessary for this assertion.
         let received = rx.recv().await.expect("queue had message");
@@ -1081,7 +1301,9 @@ mod tests {
         let original_len = msg.len();
 
         let outcome = reg.try_send_or_return(99u8, msg);
-        let returned = outcome.expect_err("missing slot should return msg");
+        let ReplyRoute::NoSlot(returned) = outcome else {
+            panic!("missing slot should return msg");
+        };
         assert_eq!(returned.len(), original_len);
     }
 
@@ -1104,14 +1326,16 @@ mod tests {
             .expect("insert ok");
 
         // Saturate the queue.
-        reg.try_send_or_return(1u8, make_bus_msg())
-            .expect("slot present")
-            .expect("first send accepted");
+        assert!(matches!(
+            reg.try_send_or_return(1u8, make_bus_msg()),
+            ReplyRoute::Delivered(Ok(()))
+        ));
         // Second send: slot present, queue full.
-        let outcome = reg
-            .try_send_or_return(1u8, make_bus_msg())
-            .expect("slot still present");
-        assert!(matches!(outcome, Err(async_channel::TrySendError::Full(_))));
+        let outcome = reg.try_send_or_return(1u8, make_bus_msg());
+        assert!(matches!(
+            outcome,
+            ReplyRoute::Delivered(Err(async_channel::TrySendError::Full(_)))
+        ));
     }
 
     /// Mirror of `try_send_or_return_returns_msg_when_slot_missing` for the
@@ -1127,4 +1351,152 @@ mod tests {
         let returned = outcome.expect_err("missing slot should return msg");
         assert_eq!(returned.len(), original_len);
     }
+
+    /// End-to-end registry seam: install entry + slot, route, fire, await.
+    #[compio::test]
+    async fn in_process_slot_receives_fired_reply() {
+        let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+        reg.insert_in_process(1u8).expect("fresh key");
+
+        let (guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("slot installs");
+        let msg = match reg.try_send_or_return(1u8, make_bus_msg()) {
+            ReplyRoute::InProcess(msg) => msg,
+            other => panic!("expected InProcess route, got {other:?}"),
+        };
+        reg.fire_in_process(1u8, 42, msg).expect("waiter present");
+
+        let received = reply_rx.await.expect("reply delivered");
+        assert_eq!(received.len(), HEADER_SIZE);
+        drop(guard);
+        assert!(reg.contains(1u8), "guard drop must not remove the entry");
+    }
+
+    #[compio::test]
+    async fn insert_in_process_duplicate_key_returns_none() {
+        let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+        assert!(reg.insert_in_process(1u8).is_some());
+        assert!(reg.insert_in_process(1u8).is_none());
+        assert_eq!(reg.len(), 1);
+    }
+
+    #[compio::test]
+    async fn install_reply_slot_rejects_missing_and_socket_entries() {
+        let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+        assert_eq!(
+            reg.install_reply_slot(1u8, 1).unwrap_err(),
+            ReplySlotError::EntryNotFound
+        );
+
+        let (_shutdown, token) = Shutdown::new();
+        let (tx, rx) = async_channel::bounded(8);
+        reg.insert(
+            1u8,
+            tx,
+            spawn_dummy_writer(rx),
+            spawn_dummy_reader(token),
+            dummy_conn_shutdown(),
+        )
+        .expect("socket insert ok");
+        assert_eq!(
+            reg.install_reply_slot(1u8, 1).unwrap_err(),
+            ReplySlotError::NotInProcess
+        );
+    }
+
+    /// Second install under a live request id must be rejected, and the
+    /// first waiter must stay wired (the reply still reaches it).
+    #[compio::test]
+    async fn install_reply_slot_rejects_duplicate_request() {
+        let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+        reg.insert_in_process(1u8).expect("fresh key");
+
+        let (_guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("first install");
+        assert_eq!(
+            reg.install_reply_slot(1u8, 42).unwrap_err(),
+            ReplySlotError::DuplicateRequest
+        );
+
+        reg.fire_in_process(1u8, 42, make_bus_msg())
+            .expect("first waiter still registered");
+        assert_eq!(reply_rx.await.expect("reply delivered").len(), HEADER_SIZE);
+    }
+
+    /// Reply for a request id nobody waits on: handed back, no panic, and
+    /// the unrelated slot stays registered.
+    #[compio::test]
+    async fn fire_in_process_unknown_request_returns_msg() {
+        let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+        reg.insert_in_process(1u8).expect("fresh key");
+        let (_guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("slot installs");
+
+        let msg = make_bus_msg();
+        let original_len = msg.len();
+        let returned = reg
+            .fire_in_process(1u8, 7, msg)
+            .expect_err("no waiter for request 7");
+        assert_eq!(returned.len(), original_len);
+
+        reg.fire_in_process(1u8, 42, make_bus_msg())
+            .expect("slot 42 untouched");
+        assert_eq!(reply_rx.await.expect("reply delivered").len(), HEADER_SIZE);
+    }
+
+    /// Timeout / cancellation path: dropping the guard removes the slot,
+    /// cancels the receiver, and a later reply is shed as no-waiter.
+    #[compio::test]
+    async fn dropped_guard_removes_slot_and_cancels_receiver() {
+        let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+        reg.insert_in_process(1u8).expect("fresh key");
+
+        let (guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("slot installs");
+        drop(guard);
+
+        assert!(reply_rx.await.is_err(), "sender dropped with the slot");
+        assert!(
+            reg.fire_in_process(1u8, 42, make_bus_msg()).is_err(),
+            "late reply must be handed back, not delivered"
+        );
+        let (_guard, _reply_rx) = reg
+            .install_reply_slot(1u8, 42)
+            .expect("request id reusable after guard drop");
+    }
+
+    /// After the reply fired the slot is gone; the guard's drop is a no-op
+    /// and the request id becomes reusable.
+    #[compio::test]
+    async fn guard_drop_is_noop_after_reply_fired() {
+        let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+        reg.insert_in_process(1u8).expect("fresh key");
+
+        let (guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("slot installs");
+        reg.fire_in_process(1u8, 42, make_bus_msg())
+            .expect("waiter present");
+        drop(guard);
+
+        assert_eq!(reply_rx.await.expect("reply delivered").len(), HEADER_SIZE);
+        let (_guard, _reply_rx) = reg
+            .install_reply_slot(1u8, 42)
+            .expect("request id reusable after fire + drop");
+    }
+
+    /// Entry teardown with a pending waiter: the receiver resolves
+    /// `Canceled` instead of hanging, and the stale guard's later drop
+    /// must not disturb a reinstalled entry's slot (token fence).
+    #[compio::test]
+    async fn entry_teardown_cancels_pending_receiver_and_fences_stale_guard() {
+        let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+        reg.insert_in_process(1u8).expect("fresh key");
+        let (stale_guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("slot installs");
+
+        assert!(reg.remove(1u8));
+        assert!(reply_rx.await.is_err(), "teardown cancels pending waiter");
+
+        reg.insert_in_process(1u8)
+            .expect("reinstall after teardown");
+        let (_guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("slot installs");
+        drop(stale_guard);
+        reg.fire_in_process(1u8, 42, make_bus_msg())
+            .expect("stale guard must not evict the new slot");
+        assert_eq!(reply_rx.await.expect("reply delivered").len(), HEADER_SIZE);
+    }
 }
diff --git a/core/message_bus/src/lifecycle/mod.rs b/core/message_bus/src/lifecycle/mod.rs
index 4fa15b9e0..5081a516a 100644
--- a/core/message_bus/src/lifecycle/mod.rs
+++ b/core/message_bus/src/lifecycle/mod.rs
@@ -26,6 +26,6 @@ pub mod shutdown;
 
 pub use connection_registry::{
     BusMessage, BusReceiver, BusSender, ConnectionRegistry, DrainOutcome, InstanceToken,
-    RejectedRegistration, ReplicaRegistry,
+    RejectedRegistration, ReplicaRegistry, ReplyRoute, ReplySlotError, ReplySlotGuard,
 };
 pub use shutdown::{FusedShutdown, Shutdown, ShutdownToken};
diff --git a/core/metadata/src/impls/metadata.rs b/core/metadata/src/impls/metadata.rs
index b331cafc9..0e3b3aec9 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -16,6 +16,7 @@
 // under the License.
 
 use crate::MuxStateMachine;
+use crate::stm::authz::gated_apply;
 use crate::stm::consumer_group::CompleteConsumerGroupRevocationRequest;
 use crate::stm::snapshot::{FillSnapshot, MetadataSnapshot, Snapshot, SnapshotError};
 use crate::stm::stream::{Streams, TruncatePartitionRequest};
@@ -342,8 +343,8 @@ fn require_shard_zero<'a, T>(
     slot
 }
 
-/// Late-bound callback invoked after every successful
-/// `mux_stm.update(prepare)` on shard 0's metadata commit path.
+/// Late-bound callback invoked after every committed op on shard 0's metadata
+/// commit path (via `gated_apply`, including a gated no-op).
 ///
 /// Wired by server-ng bootstrap once the metadata bundle has broadcast;
 /// receives the committed [`Operation`] so the recipient can filter (the
@@ -376,9 +377,10 @@ pub struct IggyMetadata<C, J, S, M> {
     pub coordinator: Option<SnapshotCoordinator<M>>,
     /// Per-client session state (sessions, dedup, eviction). Metadata-only.
     pub client_table: RefCell<ClientTable>,
-    /// Late-bound post-commit notifier. Fires once per committed
-    /// operation after [`crate::stm::StateMachine::update`] succeeds in
-    /// both [`Plane::on_ack`] and [`Self::commit_journal`]. `None` until
+    /// Late-bound post-commit notifier. Fires once per committed normal op
+    /// after `gated_apply` returns (including a gated `Unauthorized` no-op that
+    /// never reaches [`crate::stm::StateMachine::update`]) in both
+    /// [`Plane::on_ack`] and [`Self::commit_journal`]. `None` until
     /// [`Self::set_commit_notifier`] runs (server-ng bootstrap on shard
     /// 0 sets it; peer shards and tests leave it `None`).
     commit_notifier: RefCell<Option<CommitNotifier>>,
@@ -825,6 +827,7 @@ where
                         |c: u128| consensus.pipeline().borrow().has_message_from_client(c);
                     self.client_table.borrow_mut().commit_register(
                         prepare_header.client,
+                        prepare_header.user_id,
                         reply.clone(),
                         in_flight,
                     );
@@ -847,7 +850,7 @@ where
                     // Normal op: apply SM, commit_reply. `Err` is decode/corruption
                     // only; a business rejection commits as a deterministic no-op
                     // whose `code` rides the reply body, replayed on retry.
-                    let apply = self.mux_stm.update(prepare).unwrap_or_else(|err| {
+                    let apply = gated_apply(&self.mux_stm, prepare).unwrap_or_else(|err| {
                         panic!(
                             "on_ack: committed metadata op={} failed to apply: {err}",
                             prepare_header.op
@@ -985,6 +988,7 @@ where
     pub async fn submit_register_in_process(
         &self,
         client_id: u128,
+        user_id: u32,
     ) -> Result<u64, MetadataSubmitError> {
         assert!(client_id != 0, "client_id 0 is reserved for internal use");
         let consensus = self
@@ -1025,7 +1029,7 @@ where
             return Err(MetadataSubmitError::PipelineFull);
         }
 
-        let request = build_register_request_message(consensus, client_id);
+        let request = build_register_request_message(consensus, client_id, user_id);
         // Wire path runs `RequestHeader::validate` at network boundary;
         // in-process skips it. debug_assert pins drift.
         debug_assert!(
@@ -1410,6 +1414,9 @@ where
             .into_generic());
         }
 
+        // The acting-user RBAC stamp lives in the shared `prepare_request`
+        // (op-guarded, fail-closed on an unknown session). `request_preflight`
+        // above proved this client's session is live, so it resolves there.
         let Ok(prepare) = self.prepare_request(message) else {
             // Structurally-invalid request. Return an eviction frame (relayed to
             // the socket by the home shard) rather than `Canceled`, which leaves
@@ -1707,10 +1714,11 @@ where
     #[allow(clippy::too_many_lines)]
     fn prepare_request(
         &self,
-        message: Message<RequestHeader>,
+        mut message: Message<RequestHeader>,
     ) -> Result<Message<PrepareHeader>, iggy_common::IggyError> {
         let consensus = self.consensus.as_ref().unwrap();
-        let header = *message.header();
+        let operation = message.header().operation;
+        let client_id = message.header().client;
         // `TruncatePartition` is server-originated (the owning shard resolves a
         // client `DeleteSegments` count to a concrete offset) but replicated AS
         // the client's own request, so the commit records the client's request
@@ -1718,10 +1726,39 @@ where
         // maps to it, so a client cannot construct one directly -- hence the
         // `is_client_allowed` gate excludes it; admit it explicitly. The default
         // match arm below projects it through unchanged.
-        if !header.operation.is_client_allowed() && header.operation != Operation::TruncatePartition
-        {
+        if !operation.is_client_allowed() && operation != Operation::TruncatePartition {
             return Err(IggyError::InvalidCommand);
         }
+
+        // Stamp the acting user id into the replicated header so the in-apply
+        // RBAC gate (`crate::stm::authz`) resolves the same identity on every
+        // replica, WAL replay included (no session table there). Every client-op
+        // prepare funnels through here, primary-only by construction, so stamping
+        // here -- not in the in-process client path alone -- also covers the
+        // wire-plane ingresses (`on_request` and the request-queue drain). The
+        // wire `user_id` is never trusted: it is overwritten for every gated
+        // client op, and a client whose session is unknown is denied here
+        // (fail-closed), never defaulted to root. `Register` / `Logout` are exempt
+        // (see `resolve_acting_user_id`); server-originated internal ops
+        // (`CompleteConsumerGroupRevocation`, the PAT-cleaner delete) build their
+        // prepare directly, bypassing this path, and keep `user_id` 0 (gate skips).
+        if let Some(acting_user_id) =
+            resolve_acting_user_id(operation, client_id, &self.client_table)?
+        {
+            let request_header = bytemuck::checked::from_bytes_mut::<RequestHeader>(
+                &mut message.as_mut_slice()[..size_of::<RequestHeader>()],
+            );
+            request_header.user_id = acting_user_id;
+        }
+
+        // Must be read AFTER the stamp: the `CreateTopic` / `CreatePartitions`
+        // arms hand this `header` copy to `build_prepare_message`, so it has to
+        // carry the stamped acting user. Reading it before the stamp would ship
+        // those prepares with the untrusted wire `user_id` (0 = root from
+        // well-behaved SDKs, attacker-chosen otherwise), silently bypassing the
+        // authz gate. The default arm projects the mutated buffer directly and
+        // is order-independent.
+        let header = *message.header();
         let body = &message.as_slice()[size_of::<RequestHeader>()..header.size as usize];
 
         match header.operation {
@@ -1893,9 +1930,12 @@ where
                 // Register: commit_register creates session, no SM.
                 let reply = build_reply_message(&header, &bytes::Bytes::new());
                 let in_flight = |c: u128| consensus.pipeline().borrow().has_message_from_client(c);
-                self.client_table
-                    .borrow_mut()
-                    .commit_register(header.client, reply, in_flight);
+                self.client_table.borrow_mut().commit_register(
+                    header.client,
+                    header.user_id,
+                    reply,
+                    in_flight,
+                );
             } else if header.operation == Operation::Logout {
                 self.client_table.borrow_mut().remove_client(header.client);
                 // Mirror the on_ack path: drop the disconnected client from
@@ -1908,7 +1948,7 @@ where
                 // Normal op: apply SM, commit_reply. `Err` is decode/corruption
                 // only; a business rejection commits as a deterministic no-op
                 // whose `code` rides the reply body, replayed on retry.
-                let apply = self.mux_stm.update(prepare).unwrap_or_else(|err| {
+                let apply = gated_apply(&self.mux_stm, prepare).unwrap_or_else(|err| {
                     panic!("commit_journal: committed metadata op={op} failed to apply: {err}");
                 });
                 // Post-commit notifier (e.g. partition reconciler
@@ -1993,6 +2033,7 @@ where
 fn build_register_request_message<B, P>(
     consensus: &VsrConsensus<B, P>,
     client_id: u128,
+    user_id: u32,
 ) -> Message<RequestHeader>
 where
     B: MessageBus,
@@ -2014,6 +2055,8 @@ where
         client: client_id,
         session: 0,
         request: 0,
+        // Replicated on the prepare so every replica resolves session -> user.
+        user_id,
         // Route through the metadata consensus group. The chain-forwarded
         // prepare is re-routed on each peer by namespace; a `0` here would
         // hash to a non-zero shard with no metadata consensus and be
@@ -2179,10 +2222,11 @@ where
     // `created_at` on every CreateStream/CreateTopic/CreatePartitions. The
     // in-process callers that bypass `Project::project` build their prepare
     // through this helper directly (the CreateTopic/CreatePartitions
-    // assignment rewrites, and the PAT-cleaner delete); the stamp is
-    // load-bearing for the creates and inert for the delete, whose apply
-    // ignores it. Shared `next_monotonic_timestamp` keeps the in-process path
-    // on the same monotonic-clock guard as the wire path.
+    // assignment rewrites, the UpdateTopic default-size rewrite, and the
+    // PAT-cleaner delete); the stamp is load-bearing for the creates and inert
+    // for the UpdateTopic rewrite and the delete, whose applies ignore it.
+    // Shared `next_monotonic_timestamp` keeps the in-process path on the same
+    // monotonic-clock guard as the wire path.
     let timestamp = consensus.next_monotonic_timestamp();
     *new_header = PrepareHeader {
         cluster: consensus.cluster(),
@@ -2200,6 +2244,13 @@ where
         timestamp,
         operation,
         namespace: request.namespace,
+        // Carry the acting user id so the in-apply RBAC gate sees the same
+        // identity on every replica. The default projection copies it (see
+        // `Project::project`); this helper builds prepares for the ops it
+        // rewrites (the CreateTopic/CreatePartitions assignment rewrites, the
+        // UpdateTopic default-size rewrite, and the PAT-cleaner delete), which
+        // would otherwise reset it to 0 via `..Default::default()`.
+        user_id: request.user_id,
         ..Default::default()
     };
 
@@ -2216,12 +2267,48 @@ const fn eviction_reason_for_invalid(operation: Operation) -> EvictionReason {
     }
 }
 
+/// Resolve the acting user id to stamp into a client op's replicated
+/// `RequestHeader`, so the in-apply RBAC gate (`crate::stm::authz`) reads the
+/// same identity on every replica (WAL replay has no session table).
+///
+/// - `Ok(Some(id))`: overwrite the header's `user_id` with the committed
+///   session's user; the wire-supplied value is never trusted.
+/// - `Ok(None)`: leave it untouched. `Register` carries the credential-verified
+///   user from login (a mid-`Register` client has no session yet, so resolution
+///   would miss and fail-close, denying every login) and `Logout` is not gated
+///   -- both keep their builder value.
+/// - `Err(Unauthenticated)`: fail-closed. A client op whose `client_id` has no
+///   session cannot be attributed, so it is denied rather than defaulted to
+///   root (`user_id` 0 is the gate's all-allow short-circuit). Every caller
+///   preflights a live session first (`request_preflight` dispatches only on
+///   `New`), so this guards a future ingress that reaches here without one.
+fn resolve_acting_user_id(
+    operation: Operation,
+    client_id: u128,
+    client_table: &RefCell<ClientTable>,
+) -> Result<Option<u32>, IggyError> {
+    if matches!(operation, Operation::Register | Operation::Logout) {
+        return Ok(None);
+    }
+    client_table
+        .borrow()
+        .get_user_id(client_id)
+        .ok_or(IggyError::Unauthenticated)
+        .map(Some)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
     use crate::stm::stream::Streams;
     use crate::stm::user::Users;
+    use consensus::LocalPipeline;
+    use iggy_binary_protocol::requests::topics::CreateTopicRequest;
     use iggy_common::variadic;
+    use journal::prepare_journal::PrepareJournal;
+    use message_bus::{ClientForwardFn, ConnectionLostFn, JoinHandle, ReplicaForwardFn, SendError};
+    use server_common::MESSAGE_ALIGN;
+    use server_common::iobuf::Frozen;
     use std::cell::RefCell;
     use std::rc::Rc;
 
@@ -2317,4 +2404,181 @@ mod tests {
             "cleared notifier must stay quiet"
         );
     }
+
+    /// Minimal committed `Register` reply for `ClientTable::commit_register`,
+    /// which reads only `client` and `commit` (the assigned session).
+    fn register_reply(client: u128, session: u64) -> Message<ReplyHeader> {
+        let header_size = size_of::<ReplyHeader>();
+        let mut reply = Message::<ReplyHeader>::new(header_size);
+        let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(
+            &mut reply.as_mut_slice()[..header_size],
+        )
+        .expect("zeroed bytes are a valid ReplyHeader");
+        *header = ReplyHeader {
+            client,
+            request: 0,
+            commit: session,
+            command: Command2::Reply,
+            operation: Operation::Register,
+            ..Default::default()
+        };
+        reply
+    }
+
+    #[test]
+    fn resolve_acting_user_id_skips_register_and_logout() {
+        // Session-lifecycle ops keep the user id their request builder set
+        // (Register's login identity, Logout's ungated value); the ClientTable
+        // is never consulted, so an empty table still yields `Ok(None)`.
+        let client_table = RefCell::new(ClientTable::new(CLIENTS_TABLE_MAX));
+        for operation in [Operation::Register, Operation::Logout] {
+            assert!(
+                matches!(
+                    resolve_acting_user_id(operation, 1, &client_table),
+                    Ok(None)
+                ),
+                "{operation:?} must not be stamped"
+            );
+        }
+    }
+
+    #[test]
+    fn resolve_acting_user_id_stamps_from_client_table() {
+        // A gated client op takes the acting user from the committed session,
+        // independent of any wire-supplied header value.
+        const CLIENT: u128 = 1;
+        const SESSION: u64 = 10;
+        const ACTING_USER: u32 = 7;
+        let mut table = ClientTable::new(CLIENTS_TABLE_MAX);
+        table.commit_register(CLIENT, ACTING_USER, register_reply(CLIENT, SESSION), |_| {
+            false
+        });
+        let client_table = RefCell::new(table);
+
+        match resolve_acting_user_id(Operation::CreateStream, CLIENT, &client_table) {
+            Ok(Some(user_id)) => assert_eq!(user_id, ACTING_USER),
+            other => panic!("expected Ok(Some({ACTING_USER})), got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn resolve_acting_user_id_fails_closed_for_unknown_session() {
+        // A gated client op with no committed session is denied, never
+        // defaulted to root (user id 0).
+        let client_table = RefCell::new(ClientTable::new(CLIENTS_TABLE_MAX));
+        match resolve_acting_user_id(Operation::CreateStream, 999, &client_table) {
+            Err(IggyError::Unauthenticated) => {}
+            other => panic!("expected Err(Unauthenticated), got {other:?}"),
+        }
+    }
+
+    /// No-op bus: `prepare_request` builds a prepare without ever sending, so
+    /// every method is an unused stub.
+    #[derive(Debug, Default)]
+    struct NoopBus;
+
+    impl MessageBus for NoopBus {
+        fn track_background(&self, _handle: JoinHandle<()>) {}
+        async fn send_to_client(
+            &self,
+            _client_id: u128,
+            _data: Frozen<MESSAGE_ALIGN>,
+        ) -> Result<(), SendError> {
+            Ok(())
+        }
+        async fn send_to_replica(
+            &self,
+            _replica: u8,
+            _data: Frozen<MESSAGE_ALIGN>,
+        ) -> Result<(), SendError> {
+            Ok(())
+        }
+        fn set_connection_lost_fn(&self, _f: ConnectionLostFn) {}
+        fn set_replica_forward_fn(&self, _f: ReplicaForwardFn) {}
+        fn set_client_forward_fn(&self, _f: ClientForwardFn) {}
+    }
+
+    /// Single-node metadata plane whose `prepare_request` is callable. `J` is
+    /// `PrepareJournal` in name only (the value is `None`) to satisfy the
+    /// impl-block bound; no journal or snapshot is constructed.
+    fn metadata_plane() -> IggyMetadata<VsrConsensus<NoopBus>, PrepareJournal, (), TestMux> {
+        let consensus = VsrConsensus::new(
+            1,
+            0,
+            1,
+            server_common::sharding::METADATA_CONSENSUS_NAMESPACE,
+            NoopBus,
+            LocalPipeline::new(),
+        );
+        consensus.init();
+        IggyMetadata::new(Some(consensus), None, None, TestMux::default(), None)
+    }
+
+    fn create_topic_request(client: u128, wire_user_id: u32) -> Message<RequestHeader> {
+        let body = CreateTopicRequest {
+            stream_id: WireIdentifier::numeric(1),
+            partitions_count: 1,
+            compression_algorithm: 0,
+            message_expiry: 0,
+            max_topic_size: 0,
+            replication_factor: 1,
+            name: WireName::new("t").unwrap(),
+        }
+        .to_bytes();
+        let header_size = size_of::<RequestHeader>();
+        let total = header_size + body.len();
+        let mut message = Message::<RequestHeader>::new(total);
+        {
+            let slice = message.as_mut_slice();
+            slice[header_size..total].copy_from_slice(&body);
+            let header =
+                bytemuck::checked::from_bytes_mut::<RequestHeader>(&mut slice[..header_size]);
+            *header = RequestHeader {
+                command: Command2::Request,
+                operation: Operation::CreateTopic,
+                size: u32::try_from(total).unwrap(),
+                client,
+                session: 1,
+                request: 1,
+                user_id: wire_user_id,
+                namespace: server_common::sharding::METADATA_CONSENSUS_NAMESPACE,
+                ..Default::default()
+            };
+        }
+        message
+    }
+
+    #[test]
+    fn prepare_request_stamps_create_topic_from_client_table_not_wire() {
+        // `CreateTopic` is the projection that reaches `build_prepare_message`
+        // through the post-stamp `header` copy, so the built prepare must carry
+        // the ClientTable identity, not the (bogus) wire value. This pins the
+        // stamp-then-re-read ordering: a hoist would ship the untrusted wire
+        // value.
+        const CLIENT: u128 = 1;
+        const SESSION: u64 = 10;
+        const ACTING_USER: u32 = 7;
+        const WIRE_USER: u32 = 999;
+        let plane = metadata_plane();
+        plane.client_table.borrow_mut().commit_register(
+            CLIENT,
+            ACTING_USER,
+            register_reply(CLIENT, SESSION),
+            |_| false,
+        );
+
+        let prepare = plane
+            .prepare_request(create_topic_request(CLIENT, WIRE_USER))
+            .expect("CreateTopic is client-allowed");
+        assert_eq!(
+            prepare.header().operation,
+            Operation::CreateTopicWithAssignments,
+            "CreateTopic projects to the enriched form"
+        );
+        assert_eq!(
+            prepare.header().user_id,
+            ACTING_USER,
+            "prepare must carry the ClientTable identity, not the wire value"
+        );
+    }
 }
diff --git a/core/metadata/src/impls/recovery.rs b/core/metadata/src/impls/recovery.rs
index a32e558f9..4268c1633 100644
--- a/core/metadata/src/impls/recovery.rs
+++ b/core/metadata/src/impls/recovery.rs
@@ -17,6 +17,7 @@
 
 use crate::impls::metadata::IggySnapshot;
 use crate::stm::StateMachine;
+use crate::stm::authz::GatedApply;
 use crate::stm::snapshot::{MetadataSnapshot, RestoreSnapshot, Snapshot, SnapshotError};
 use iggy_binary_protocol::consensus::PrepareHeader;
 use iggy_common::IggyError;
@@ -107,6 +108,7 @@ pub struct RecoveredMetadata<M> {
 pub async fn recover<M>(data_dir: &Path) -> Result<RecoveredMetadata<M>, RecoveryError>
 where
     M: StateMachine<Input = Message<PrepareHeader>, Error = IggyError>
+        + GatedApply
         + RestoreSnapshot<MetadataSnapshot>
         + Default,
 {
@@ -148,7 +150,9 @@ where
                 format!("failed to read journal entry for op={}", header.op),
             ))
         })?;
-        mux_stm.update(entry)?;
+        // WAL replay must recompute authorization denials identically to the
+        // primary/backup commit paths, so it goes through the same gate.
+        mux_stm.gated_update(entry)?;
         last_applied_op = Some(header.op);
     }
 
diff --git a/core/metadata/src/permissioner/permissioner_rules/system.rs b/core/metadata/src/permissioner/permissioner_rules/system.rs
index 720067afb..4a11c8c96 100644
--- a/core/metadata/src/permissioner/permissioner_rules/system.rs
+++ b/core/metadata/src/permissioner/permissioner_rules/system.rs
@@ -32,6 +32,10 @@ impl Permissioner {
         self.get_server_info(user_id)
     }
 
+    pub fn get_snapshot(&self, user_id: u32) -> Result<(), IggyError> {
+        self.get_server_info(user_id)
+    }
+
     fn get_server_info(&self, user_id: u32) -> Result<(), IggyError> {
         if let Some(global_permissions) = self.users_permissions.get(&user_id)
             && (global_permissions.manage_servers || global_permissions.read_servers)
diff --git a/core/metadata/src/stm/authz.rs b/core/metadata/src/stm/authz.rs
new file mode 100644
index 000000000..00ea17416
--- /dev/null
+++ b/core/metadata/src/stm/authz.rs
@@ -0,0 +1,760 @@
+// 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-apply control-plane authorization for the metadata state machine.
+//!
+//! Every committed client op is gated here before its `StateHandler::apply`
+//! runs. A denial returns an `Unauthorized` [`ApplyReply`] WITHOUT touching
+//! state, so the op commits as a deterministic no-op whose error rides the
+//! cached reply and replays on retry, exactly like a business rejection.
+//!
+//! Replay-determinism invariant: [`authorize`] is a pure function of the
+//! prepare header (`operation` + the acting `user_id`, stamped into the
+//! replicated header at submit time) and the committed permission/stream
+//! state as of the op immediately before this one. That state is applied in
+//! the same order on every replica, so the primary (`on_ack`), each backup
+//! (`commit_journal`), and WAL replay (`recover`) all recompute the identical
+//! decision. This is why identity must ride the header rather than being
+//! resolved from a local session table: a backup has no session for the
+//! acting client.
+
+use crate::MuxStateMachine;
+use crate::impls::metadata::StreamsFrontend;
+use crate::permissioner::Permissioner;
+use crate::stm::StateMachine;
+use crate::stm::consumer_group::{JoinConsumerGroupRequest, LeaveConsumerGroupRequest};
+use crate::stm::result::ApplyReply;
+use crate::stm::stream::{Streams, TruncatePartitionRequest};
+use crate::stm::user::Users;
+use iggy_binary_protocol::requests::consumer_groups::{
+    CreateConsumerGroupRequest, DeleteConsumerGroupRequest,
+};
+use iggy_binary_protocol::requests::partitions::{
+    CreatePartitionsWithAssignmentsRequest, DeletePartitionsRequest,
+};
+use iggy_binary_protocol::requests::streams::{
+    DeleteStreamRequest, PurgeStreamRequest, UpdateStreamRequest,
+};
+use iggy_binary_protocol::requests::topics::{
+    CreateTopicWithAssignmentsRequest, DeleteTopicRequest, PurgeTopicRequest, UpdateTopicRequest,
+};
+use iggy_binary_protocol::requests::users::ChangePasswordRequest;
+use iggy_binary_protocol::{Operation, PrepareHeader, WireDecode, WireIdentifier};
+use iggy_common::{IggyError, variadic};
+use server_common::Message;
+use std::mem::size_of;
+
+/// Gate a committed prepare, then apply it. A denial commits as an
+/// `Unauthorized` no-op (the gate never mutates state); an allow proceeds to
+/// the normal state-machine dispatch. This is the single funnel every
+/// production apply path uses, so no committed op can bypass the RBAC check.
+///
+/// The primary (`on_ack`) and backup (`commit_journal`) paths hold the concrete
+/// mux and call this directly; WAL replay (`recover`) is generic over the state
+/// machine and reaches it through [`GatedApply`].
+///
+/// # Errors
+/// Propagates the underlying [`StateMachine::update`] error (decode / corruption
+/// of a committed body), which is fatal on the commit path.
+pub(crate) fn gated_apply<M>(
+    mux: &M,
+    prepare: Message<PrepareHeader>,
+) -> Result<ApplyReply, IggyError>
+where
+    M: StreamsFrontend
+        + StateMachine<Input = Message<PrepareHeader>, Output = ApplyReply, Error = IggyError>,
+{
+    if let Some(denied) = authorize(&prepare, mux.users(), mux.streams()) {
+        return Ok(denied);
+    }
+    mux.update(prepare)
+}
+
+/// Generic entry point to [`gated_apply`] for the WAL-replay path.
+///
+/// The replay path is generic over the state machine and cannot name the
+/// concrete accessors, so it dispatches through this trait. Implemented only
+/// for the metadata mux (and, in tests, the empty state machine used by the
+/// recovery harness).
+pub trait GatedApply:
+    StateMachine<Input = Message<PrepareHeader>, Output = ApplyReply, Error = IggyError>
+{
+    /// See [`gated_apply`].
+    ///
+    /// # Errors
+    /// Propagates the underlying [`StateMachine::update`] error.
+    fn gated_update(&self, prepare: Message<PrepareHeader>) -> Result<ApplyReply, IggyError>;
+}
+
+impl GatedApply for MuxStateMachine<variadic!(Users, Streams)> {
+    fn gated_update(&self, prepare: Message<PrepareHeader>) -> Result<ApplyReply, IggyError> {
+        gated_apply(self, prepare)
+    }
+}
+
+/// The root user is always the first user created (slab id 0) and holds every
+/// grant. Short-circuited below rather than checked against the permissioner:
+/// server-originated ops carry `user_id` 0 with no session, and WAL replay
+/// gates committed ops before the unreplicated root bootstrap
+/// (`ensure_root_user`) has populated the permissioner, so the gate must not
+/// depend on permissioner contents.
+const ROOT_USER_ID: u32 = 0;
+
+/// Decides whether the acting user may run this committed op.
+///
+/// `Some(reply)` denies with a committed `Unauthorized` no-op; `None` allows
+/// the op to reach its `StateHandler::apply`. Only control-plane client ops are
+/// checked; internal / server-originated ops (which carry `user_id` 0), the
+/// self-scoped personal-access-token ops (no legacy RBAC), the pre-projection
+/// wire forms, and partition-plane ops all pass through.
+///
+/// Object ids are resolved against committed stream state first; a resolution
+/// miss passes through so `apply` produces its own `NotFound`, preserving the
+/// legacy notfound-before-permission ordering.
+#[allow(clippy::too_many_lines)]
+pub(crate) fn authorize(
+    prepare: &Message<PrepareHeader>,
+    users: &Users,
+    streams: &Streams,
+) -> Option<ApplyReply> {
+    let header = prepare.header();
+    let user_id = header.user_id;
+
+    if user_id == ROOT_USER_ID {
+        return None;
+    }
+    let body = &prepare.as_slice()[size_of::<PrepareHeader>()..header.size as usize];
+
+    match header.operation {
+        // Streams. `create_stream` is unscoped; the rest resolve the stream id.
+        Operation::CreateStream => check(users, |perm| perm.create_stream(user_id)),
+        Operation::UpdateStream => {
+            let Ok(request) = UpdateStreamRequest::decode_from(body) else {
+                return None;
+            };
+            stream_scoped(users, streams, &request.stream_id, |perm, sid| {
+                perm.update_stream(user_id, sid)
+            })
+        }
+        Operation::DeleteStream => {
+            let Ok(request) = DeleteStreamRequest::decode_from(body) else {
+                return None;
+            };
+            stream_scoped(users, streams, &request.stream_id, |perm, sid| {
+                perm.delete_stream(user_id, sid)
+            })
+        }
+        Operation::PurgeStream => {
+            let Ok(request) = PurgeStreamRequest::decode_from(body) else {
+                return None;
+            };
+            stream_scoped(users, streams, &request.stream_id, |perm, sid| {
+                perm.purge_stream(user_id, sid)
+            })
+        }
+
+        // Topics. The wire `CreateTopic` is projected to
+        // `CreateTopicWithAssignments` before it reaches apply, so only the
+        // enriched form is gated. `create_topic` is stream-scoped.
+        Operation::CreateTopicWithAssignments => {
+            let Ok(request) = CreateTopicWithAssignmentsRequest::decode_from(body) else {
+                return None;
+            };
+            stream_scoped(users, streams, &request.request.stream_id, |perm, sid| {
+                perm.create_topic(user_id, sid)
+            })
+        }
+        Operation::UpdateTopic => {
+            let Ok(request) = UpdateTopicRequest::decode_from(body) else {
+                return None;
+            };
+            topic_scoped(
+                users,
+                streams,
+                &request.stream_id,
+                &request.topic_id,
+                |perm, sid, tid| perm.update_topic(user_id, sid, tid),
+            )
+        }
+        Operation::DeleteTopic => {
+            let Ok(request) = DeleteTopicRequest::decode_from(body) else {
+                return None;
+            };
+            topic_scoped(
+                users,
+                streams,
+                &request.stream_id,
+                &request.topic_id,
+                |perm, sid, tid| perm.delete_topic(user_id, sid, tid),
+            )
+        }
+        Operation::PurgeTopic => {
+            let Ok(request) = PurgeTopicRequest::decode_from(body) else {
+                return None;
+            };
+            topic_scoped(
+                users,
+                streams,
+                &request.stream_id,
+                &request.topic_id,
+                |perm, sid, tid| perm.purge_topic(user_id, sid, tid),
+            )
+        }
+
+        // Partitions. Wire `CreatePartitions` is projected to the enriched
+        // form; `DeleteSegments` is resolved server-side to `TruncatePartition`
+        // carrying the original client's id.
+        Operation::CreatePartitionsWithAssignments => {
+            let Ok(request) = CreatePartitionsWithAssignmentsRequest::decode_from(body) else {
+                return None;
+            };
+            topic_scoped(
+                users,
+                streams,
+                &request.request.stream_id,
+                &request.request.topic_id,
+                |perm, sid, tid| perm.create_partitions(user_id, sid, tid),
+            )
+        }
+        Operation::DeletePartitions => {
+            let Ok(request) = DeletePartitionsRequest::decode_from(body) else {
+                return None;
+            };
+            topic_scoped(
+                users,
+                streams,
+                &request.stream_id,
+                &request.topic_id,
+                |perm, sid, tid| perm.delete_partitions(user_id, sid, tid),
+            )
+        }
+        Operation::TruncatePartition => {
+            let Ok(request) = TruncatePartitionRequest::decode_from(body) else {
+                return None;
+            };
+            topic_scoped(
+                users,
+                streams,
+                &request.stream_id,
+                &request.topic_id,
+                |perm, sid, tid| perm.delete_segments(user_id, sid, tid),
+            )
+        }
+
+        // Consumer groups. Join/Leave carry the enriched request types that add
+        // the VSR client id; Create/Delete use the wire types.
+        Operation::CreateConsumerGroup => {
+            let Ok(request) = CreateConsumerGroupRequest::decode_from(body) else {
+                return None;
+            };
+            topic_scoped(
+                users,
+                streams,
+                &request.stream_id,
+                &request.topic_id,
+                |perm, sid, tid| perm.create_consumer_group(user_id, sid, tid),
+            )
+        }
+        Operation::DeleteConsumerGroup => {
+            let Ok(request) = DeleteConsumerGroupRequest::decode_from(body) else {
+                return None;
+            };
+            topic_scoped(
+                users,
+                streams,
+                &request.stream_id,
+                &request.topic_id,
+                |perm, sid, tid| perm.delete_consumer_group(user_id, sid, tid),
+            )
+        }
+        Operation::JoinConsumerGroup => {
+            let Ok(request) = JoinConsumerGroupRequest::decode_from(body) else {
+                return None;
+            };
+            topic_scoped(
+                users,
+                streams,
+                &request.stream_id,
+                &request.topic_id,
+                |perm, sid, tid| perm.join_consumer_group(user_id, sid, tid),
+            )
+        }
+        Operation::LeaveConsumerGroup => {
+            let Ok(request) = LeaveConsumerGroupRequest::decode_from(body) else {
+                return None;
+            };
+            topic_scoped(
+                users,
+                streams,
+                &request.stream_id,
+                &request.topic_id,
+                |perm, sid, tid| perm.leave_consumer_group(user_id, sid, tid),
+            )
+        }
+
+        // Users. Unscoped `manage_users`, except `ChangePassword` which the
+        // legacy execution layer exempts when the target is the acting user.
+        Operation::CreateUser => check(users, |perm| perm.create_user(user_id)),
+        Operation::DeleteUser => check(users, |perm| perm.delete_user(user_id)),
+        Operation::UpdateUser => check(users, |perm| perm.update_user(user_id)),
+        Operation::UpdatePermissions => check(users, |perm| perm.update_permissions(user_id)),
+        Operation::ChangePassword => {
+            let Ok(request) = ChangePasswordRequest::decode_from(body) else {
+                return None;
+            };
+            match users.read(|inner| inner.resolve_user_id(&request.user_id)) {
+                // Self-service password change: allowed without `manage_users`.
+                Some(target_id) if target_id == user_id as usize => None,
+                Some(_) => check(users, |perm| perm.change_password(user_id)),
+                // Unknown target passes through to apply's `UserNotFound`.
+                None => None,
+            }
+        }
+
+        // Not gated (listed exhaustively so a new op cannot silently skip the
+        // gate): VSR-reserved ops (never reach apply as client ops),
+        // server-originated internal ops (user_id 0), self-scoped PAT ops (no
+        // legacy RBAC), the pre-projection wire forms, and every partition op.
+        Operation::Reserved
+        | Operation::Register
+        | Operation::NonReplicated
+        | Operation::Logout
+        | Operation::RemoveConsumerGroupMember
+        | Operation::CompleteConsumerGroupRevocation
+        | Operation::CreateTopic
+        | Operation::CreatePartitions
+        | Operation::DeleteSegments
+        | Operation::CreatePersonalAccessToken
+        | Operation::DeletePersonalAccessToken
+        | Operation::SendMessages
+        | Operation::StoreConsumerOffset
+        | Operation::DeleteConsumerOffset
+        | Operation::StoreConsumerOffset2
+        | Operation::DeleteConsumerOffset2 => None,
+    }
+}
+
+/// Maps a permissioner rule outcome to a gate decision: any `Err` (always
+/// `Unauthorized`) denies with a committed `Unauthorized` reply.
+fn check(
+    users: &Users,
+    rule: impl FnOnce(&Permissioner) -> Result<(), IggyError>,
+) -> Option<ApplyReply> {
+    match users.read(|inner| rule(&inner.permissioner)) {
+        Ok(()) => None,
+        Err(error) => Some(ApplyReply::err(error.as_code())),
+    }
+}
+
+/// Resolves a stream id, then runs a stream-scoped rule. A resolution miss
+/// passes through (returns `None`) so apply surfaces its own `NotFound`.
+fn stream_scoped(
+    users: &Users,
+    streams: &Streams,
+    stream_id: &WireIdentifier,
+    rule: impl FnOnce(&Permissioner, usize) -> Result<(), IggyError>,
+) -> Option<ApplyReply> {
+    let stream_id = streams.read(|inner| inner.resolve_stream_id(stream_id))?;
+    check(users, |perm| rule(perm, stream_id))
+}
+
+/// Resolves a (stream, topic) pair, then runs a topic-scoped rule. A miss on
+/// either passes through so apply surfaces its own `NotFound`.
+fn topic_scoped(
+    users: &Users,
+    streams: &Streams,
+    stream_id: &WireIdentifier,
+    topic_id: &WireIdentifier,
+    rule: impl FnOnce(&Permissioner, usize, usize) -> Result<(), IggyError>,
+) -> Option<ApplyReply> {
+    let (stream_id, topic_id) = streams.read(|inner| {
+        let stream_id = inner.resolve_stream_id(stream_id)?;
+        let topic_id = inner.resolve_topic_id(stream_id, topic_id)?;
+        Some((stream_id, topic_id))
+    })?;
+    check(users, |perm| rule(perm, stream_id, topic_id))
+}
+
+/// The recovery unit tests exercise WAL-replay mechanics with an empty state
+/// list (`MuxStateMachine<()>`), which has no permission state and so needs no
+/// gate. Production always uses the `variadic!(Users, Streams)` machine above.
+#[cfg(test)]
+impl GatedApply for MuxStateMachine<()> {
+    fn gated_update(&self, prepare: Message<PrepareHeader>) -> Result<ApplyReply, IggyError> {
+        self.update(prepare)
+    }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+    use super::*;
+    use iggy_binary_protocol::primitives::partition_assignment::CreatedPartitionAssignment;
+    use iggy_binary_protocol::primitives::permissions::{
+        WireGlobalPermissions, WirePermissions, WireStreamPermissions,
+    };
+    use iggy_binary_protocol::requests::streams::CreateStreamRequest;
+    use iggy_binary_protocol::requests::topics::CreateTopicRequest;
+    use iggy_binary_protocol::requests::users::CreateUserRequest;
+    use iggy_binary_protocol::{Command2, WireEncode, WireName};
+    use iggy_common::UserStatus;
+    use server_common::iobuf::Owned;
+
+    const HEADER_SIZE: usize = size_of::<PrepareHeader>();
+    /// Root is the first user, so it takes slab id 0; the first user created
+    /// after it is id 1.
+    const ROOT: u32 = 0;
+    const ALICE: u32 = 1;
+
+    fn mux() -> MuxStateMachine<variadic!(Users, Streams)> {
+        let users = Users::default();
+        users.ensure_root_user("iggy", "hash");
+        let streams = Streams::default();
+        MuxStateMachine::new(variadic!(users, streams))
+    }
+
+    fn make_prepare(
+        op: u64,
+        operation: Operation,
+        user_id: u32,
+        body: &[u8],
+    ) -> Message<PrepareHeader> {
+        let total = HEADER_SIZE + body.len();
+        let mut buffer = Owned::<4096>::zeroed(total);
+        {
+            let header = bytemuck::checked::from_bytes_mut::<PrepareHeader>(
+                &mut buffer.as_mut_slice()[..HEADER_SIZE],
+            );
+            header.command = Command2::Prepare;
+            header.operation = operation;
+            header.op = op;
+            header.user_id = user_id;
+            header.size = u32::try_from(total).unwrap();
+        }
+        buffer.as_mut_slice()[HEADER_SIZE..].copy_from_slice(body);
+        Message::try_from(buffer).unwrap()
+    }
+
+    fn no_grants() -> WireGlobalPermissions {
+        WireGlobalPermissions {
+            manage_servers: false,
+            read_servers: false,
+            manage_users: false,
+            read_users: false,
+            manage_streams: false,
+            read_streams: false,
+            manage_topics: false,
+            read_topics: false,
+            poll_messages: false,
+            send_messages: false,
+        }
+    }
+
+    fn create_user_body(
+        name: &str,
+        global: WireGlobalPermissions,
+        streams: Vec<WireStreamPermissions>,
+    ) -> bytes::Bytes {
+        CreateUserRequest {
+            username: WireName::new(name).unwrap(),
+            password: "hash".to_string(),
+            status: UserStatus::Active.as_code(),
+            permissions: Some(WirePermissions { global, streams }),
+        }
+        .to_bytes()
+    }
+
+    fn create_stream_body(name: &str) -> bytes::Bytes {
+        CreateStreamRequest {
+            name: WireName::new(name).unwrap(),
+        }
+        .to_bytes()
+    }
+
+    fn create_topic_body(stream_id: u32, name: &str) -> bytes::Bytes {
+        CreateTopicWithAssignmentsRequest {
+            request: CreateTopicRequest {
+                stream_id: WireIdentifier::numeric(stream_id),
+                partitions_count: 1,
+                compression_algorithm: 0,
+                message_expiry: 0,
+                max_topic_size: 0,
+                replication_factor: 1,
+                name: WireName::new(name).unwrap(),
+            },
+            partitions: vec![CreatedPartitionAssignment {
+                partition_id: 0,
+                consensus_group_id: 1,
+            }],
+        }
+        .to_bytes()
+    }
+
+    fn change_password_body(target: &str) -> bytes::Bytes {
+        ChangePasswordRequest {
+            user_id: WireIdentifier::named(target).unwrap(),
+            current_password: String::new(),
+            new_password: "new-hash".to_string(),
+        }
+        .to_bytes()
+    }
+
+    fn stream_manage_topics(stream_id: u32) -> WireStreamPermissions {
+        WireStreamPermissions {
+            stream_id,
+            manage_stream: false,
+            read_stream: false,
+            manage_topics: true,
+            read_topics: false,
+            poll_messages: false,
+            send_messages: false,
+            topics: Vec::new(),
+        }
+    }
+
+    const UNAUTHORIZED: u32 = 41;
+
+    #[test]
+    fn given_ungranted_user_when_create_stream_should_deny_and_leave_state_unchanged() {
+        let mux = mux();
+        assert_eq!(
+            mux.gated_update(make_prepare(
+                1,
+                Operation::CreateUser,
+                ROOT,
+                &create_user_body("alice", no_grants(), Vec::new()),
+            ))
+            .unwrap()
+            .code,
+            0,
+            "root may create a user"
+        );
+
+        let reply = mux
+            .gated_update(make_prepare(
+                2,
+                Operation::CreateStream,
+                ALICE,
+                &create_stream_body("s1"),
+            ))
+            .unwrap();
+
+        assert_eq!(reply.code, UNAUTHORIZED);
+        assert_eq!(
+            mux.streams().read(|inner| inner.items.len()),
+            0,
+            "denied op must not mutate stream state"
+        );
+    }
+
+    #[test]
+    fn given_manage_streams_grant_when_create_stream_should_allow() {
+        let mux = mux();
+        let grants = WireGlobalPermissions {
+            manage_streams: true,
+            ..no_grants()
+        };
+        mux.gated_update(make_prepare(
+            1,
+            Operation::CreateUser,
+            ROOT,
+            &create_user_body("alice", grants, Vec::new()),
+        ))
+        .unwrap();
+
+        let reply = mux
+            .gated_update(make_prepare(
+                2,
+                Operation::CreateStream,
+                ALICE,
+                &create_stream_body("s1"),
+            ))
+            .unwrap();
+
+        assert_eq!(reply.code, 0);
+        assert_eq!(mux.streams().read(|inner| inner.items.len()), 1);
+    }
+
+    #[test]
+    fn given_stream_scoped_topics_grant_when_create_topic_should_allow_only_that_stream() {
+        let mux = mux();
+        // Streams 0 and 1 (StreamsInner slab is separate from the user slab).
+        mux.gated_update(make_prepare(
+            1,
+            Operation::CreateStream,
+            ROOT,
+            &create_stream_body("s0"),
+        ))
+        .unwrap();
+        mux.gated_update(make_prepare(
+            2,
+            Operation::CreateStream,
+            ROOT,
+            &create_stream_body("s1"),
+        ))
+        .unwrap();
+        // Alice may manage topics on stream 0 only.
+        mux.gated_update(make_prepare(
+            3,
+            Operation::CreateUser,
+            ROOT,
+            &create_user_body("alice", no_grants(), vec![stream_manage_topics(0)]),
+        ))
+        .unwrap();
+
+        let allowed = mux
+            .gated_update(make_prepare(
+                4,
+                Operation::CreateTopicWithAssignments,
+                ALICE,
+                &create_topic_body(0, "t"),
+            ))
+            .unwrap();
+        assert_eq!(allowed.code, 0, "grant covers stream 0");
+
+        let denied = mux
+            .gated_update(make_prepare(
+                5,
+                Operation::CreateTopicWithAssignments,
+                ALICE,
+                &create_topic_body(1, "t"),
+            ))
+            .unwrap();
+        assert_eq!(denied.code, UNAUTHORIZED, "grant does not cover stream 1");
+    }
+
+    #[test]
+    fn given_change_password_when_target_is_self_should_allow_else_deny() {
+        let mux = mux();
+        mux.gated_update(make_prepare(
+            1,
+            Operation::CreateUser,
+            ROOT,
+            &create_user_body("alice", no_grants(), Vec::new()),
+        ))
+        .unwrap();
+        mux.gated_update(make_prepare(
+            2,
+            Operation::CreateUser,
+            ROOT,
+            &create_user_body("bob", no_grants(), Vec::new()),
+        ))
+        .unwrap();
+
+        let own = mux
+            .gated_update(make_prepare(
+                3,
+                Operation::ChangePassword,
+                ALICE,
+                &change_password_body("alice"),
+            ))
+            .unwrap();
+        assert_eq!(
+            own.code, 0,
+            "self password change is exempt from manage_users"
+        );
+
+        let other = mux
+            .gated_update(make_prepare(
+                4,
+                Operation::ChangePassword,
+                ALICE,
+                &change_password_body("bob"),
+            ))
+            .unwrap();
+        assert_eq!(
+            other.code, UNAUTHORIZED,
+            "changing another user needs manage_users"
+        );
+    }
+
+    #[test]
+    fn given_internal_revocation_op_when_authorized_should_skip_the_gate() {
+        let mux = mux();
+        let prepare = make_prepare(1, Operation::CompleteConsumerGroupRevocation, ROOT, &[]);
+        assert!(
+            authorize(&prepare, mux.users(), mux.streams()).is_none(),
+            "server-originated revocation must not be gated"
+        );
+    }
+
+    #[test]
+    fn given_non_root_user_when_create_pat_should_skip_the_gate() {
+        let mux = mux();
+        mux.gated_update(make_prepare(
+            1,
+            Operation::CreateUser,
+            ROOT,
+            &create_user_body("alice", no_grants(), Vec::new()),
+        ))
+        .unwrap();
+
+        let prepare = make_prepare(2, Operation::CreatePersonalAccessToken, ALICE, &[]);
+        assert!(
+            authorize(&prepare, mux.users(), mux.streams()).is_none(),
+            "PAT ops are self-scoped (parity: no legacy RBAC)"
+        );
+    }
+
+    // The linchpin: a sequence containing a denied op must produce byte-identical
+    // codes and final state whether applied once (primary on_ack) or replayed
+    // fresh (backup commit_journal / recover). Identity rides the header, so no
+    // session table is consulted.
+    #[test]
+    fn given_sequence_with_denial_when_replayed_fresh_should_be_state_identical() {
+        fn sequence() -> Vec<Message<PrepareHeader>> {
+            vec![
+                make_prepare(
+                    1,
+                    Operation::CreateUser,
+                    ROOT,
+                    &create_user_body("alice", no_grants(), Vec::new()),
+                ),
+                make_prepare(
+                    2,
+                    Operation::CreateStream,
+                    ROOT,
+                    &create_stream_body("s-root"),
+                ),
+                // Alice lacks manage_streams: denied on both passes.
+                make_prepare(
+                    3,
+                    Operation::CreateStream,
+                    ALICE,
+                    &create_stream_body("s-alice"),
+                ),
+            ]
+        }
+
+        let primary = mux();
+        let primary_codes: Vec<u32> = sequence()
+            .into_iter()
+            .map(|prepare| primary.gated_update(prepare).unwrap().code)
+            .collect();
+
+        let replay = mux();
+        let replay_codes: Vec<u32> = sequence()
+            .into_iter()
+            .map(|prepare| replay.gated_update(prepare).unwrap().code)
+            .collect();
+
+        assert_eq!(primary_codes, replay_codes, "replay must be deterministic");
+        assert_eq!(primary_codes[2], UNAUTHORIZED, "alice's create is denied");
+        assert_eq!(primary.streams().read(|inner| inner.items.len()), 1);
+        assert_eq!(replay.streams().read(|inner| inner.items.len()), 1);
+    }
+}
diff --git a/core/metadata/src/stm/consumer_group.rs b/core/metadata/src/stm/consumer_group.rs
index e99b16fdb..c85390ad9 100644
--- a/core/metadata/src/stm/consumer_group.rs
+++ b/core/metadata/src/stm/consumer_group.rs
@@ -505,8 +505,21 @@ impl StateHandler for CreateConsumerGroupRequest {
         state: &mut StreamsInner,
         _timestamp: iggy_common::IggyTimestamp,
     ) -> ApplyReply {
-        let Some(topic) = state.topic_mut(&self.stream_id, &self.topic_id) else {
-            return ApplyReply::ok(Bytes::new());
+        // A missing parent is a committed rejection, mirroring `CreateTopic`
+        // on a missing stream. Resolve level by level so the error names the
+        // level that missed.
+        let Some(stream_id) = state.resolve_stream_id(&self.stream_id) else {
+            return ApplyReply::err(CreateConsumerGroupResult::StreamNotFound);
+        };
+        let Some(topic_id) = state.resolve_topic_id(stream_id, &self.topic_id) else {
+            return ApplyReply::err(CreateConsumerGroupResult::TopicNotFound);
+        };
+        let Some(topic) = state
+            .items
+            .get_mut(stream_id)
+            .and_then(|stream| stream.topics.get_mut(topic_id))
+        else {
+            return ApplyReply::err(CreateConsumerGroupResult::TopicNotFound);
         };
         let name: Arc<str> = Arc::from(self.name.as_str());
         // Per-(stream,topic) name uniqueness. A same-name group already exists:
@@ -958,6 +971,41 @@ mod tests {
         assert!(apply.body.is_empty());
     }
 
+    #[test]
+    fn given_missing_parent_when_apply_create_consumer_group_should_return_not_found() {
+        let mut state = streams_with_topic();
+
+        let missing_stream = StateHandler::apply(
+            &CreateConsumerGroupRequest {
+                stream_id: WireIdentifier::numeric(999),
+                topic_id: WireIdentifier::numeric(0),
+                name: WireName::new("group").unwrap(),
+            },
+            &mut state,
+            IggyTimestamp::now(),
+        );
+        assert_eq!(
+            missing_stream.code,
+            u32::from(CreateConsumerGroupResult::StreamNotFound)
+        );
+        assert!(missing_stream.body.is_empty());
+
+        let missing_topic = StateHandler::apply(
+            &CreateConsumerGroupRequest {
+                stream_id: WireIdentifier::numeric(0),
+                topic_id: WireIdentifier::numeric(999),
+                name: WireName::new("group").unwrap(),
+            },
+            &mut state,
+            IggyTimestamp::now(),
+        );
+        assert_eq!(
+            missing_topic.code,
+            u32::from(CreateConsumerGroupResult::TopicNotFound)
+        );
+        assert!(missing_topic.body.is_empty());
+    }
+
     #[test]
     fn given_missing_group_when_apply_delete_consumer_group_should_return_not_found() {
         let mut state = streams_with_topic();
diff --git a/core/metadata/src/stm/mod.rs b/core/metadata/src/stm/mod.rs
index f86c2d2e3..5fc0c10d1 100644
--- a/core/metadata/src/stm/mod.rs
+++ b/core/metadata/src/stm/mod.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+pub mod authz;
 pub mod consumer_group;
 pub mod mux;
 pub mod result;
diff --git a/core/metadata/src/stm/result.rs b/core/metadata/src/stm/result.rs
index 7874729d8..496bf2049 100644
--- a/core/metadata/src/stm/result.rs
+++ b/core/metadata/src/stm/result.rs
@@ -187,14 +187,27 @@ result_enum!(DeletePartitionsResult {
 
 // Users. No dedicated user-not-found code in `IggyError`; `ResourceNotFound = 20`
 // stands in until one is added (a separate `IggyError` change).
-result_enum!(CreateUserResult { UserAlreadyExists = 46 });
+result_enum!(CreateUserResult {
+    InvalidUsername = 43,
+    UserAlreadyExists = 46,
+});
 result_enum!(UpdateUserResult {
     UserNotFound = 20,
+    InvalidUsername = 43,
     UsernameAlreadyExists = 46,
 });
-result_enum!(DeleteUserResult { UserNotFound = 20 });
-result_enum!(ChangePasswordResult { UserNotFound = 20 });
-result_enum!(UpdatePermissionsResult { UserNotFound = 20 });
+result_enum!(DeleteUserResult {
+    UserNotFound = 20,
+    CannotDeleteUser = 48,
+});
+result_enum!(ChangePasswordResult {
+    UserNotFound = 20,
+    InvalidCredentials = 42,
+});
+result_enum!(UpdatePermissionsResult {
+    UserNotFound = 20,
+    CannotChangePermissions = 49,
+});
 
 // Personal access tokens.
 result_enum!(CreatePersonalAccessTokenResult {
@@ -204,17 +217,34 @@ result_enum!(CreatePersonalAccessTokenResult {
 result_enum!(DeletePersonalAccessTokenResult { NotFound = 20 });
 
 // Consumer groups.
-result_enum!(CreateConsumerGroupResult { NameAlreadyExists = 5004 });
+result_enum!(CreateConsumerGroupResult {
+    StreamNotFound = 1009,
+    TopicNotFound = 2010,
+    NameAlreadyExists = 5004,
+});
 result_enum!(DeleteConsumerGroupResult { NotFound = 5000 });
 
-/// True if `code` is one this op's result enum declares (`Ok = 0` included).
+/// `IggyError::Unauthorized`. Any control-plane op can commit as an in-apply
+/// authorization no-op, so this code is valid for every op regardless of its
+/// own result enum. Pinned to the wire discriminant in
+/// [`tests::given_result_discriminants_then_match_iggy_error_codes`].
+const UNAUTHORIZED_CODE: u32 = 41;
+
+/// True if `code` is one this op's result enum declares (`Ok = 0` included), or
+/// the global `UNAUTHORIZED_CODE`.
 ///
-/// The state machine only commits codes from the op's own enum, so an
+/// The state machine only commits codes from the op's own enum, plus the
+/// in-apply authorization gate's global `Unauthorized` denial, so an
 /// unrecognized one is a server bug, not a race (a race still yields a declared
 /// code). Enriched internal ops map to their client-op enum; ops with no result
 /// section (partition plane) return `true`.
 #[must_use]
 pub const fn result_code_recognized(operation: Operation, code: u32) -> bool {
+    // The in-apply RBAC gate can deny any control-plane op, committing an
+    // `Unauthorized` no-op, so accept it globally rather than per-op enum.
+    if code == UNAUTHORIZED_CODE {
+        return true;
+    }
     match operation {
         Operation::CreateStream => CreateStreamResult::from_u32(code).is_some(),
         Operation::UpdateStream => UpdateStreamResult::from_u32(code).is_some(),
@@ -325,6 +355,20 @@ mod tests {
         ));
         // Partition-plane ops carry no result section, so any code is accepted.
         assert!(result_code_recognized(Operation::SendMessages, 12345));
+        // `Unauthorized` commits for any control-plane op via the in-apply gate,
+        // even though no per-op result enum declares it.
+        assert!(result_code_recognized(
+            Operation::CreateStream,
+            UNAUTHORIZED_CODE
+        ));
+        assert!(result_code_recognized(
+            Operation::CreateUser,
+            UNAUTHORIZED_CODE
+        ));
+        assert!(result_code_recognized(
+            Operation::CreateConsumerGroup,
+            UNAUTHORIZED_CODE
+        ));
     }
 
     // Pins every result-enum discriminant to the `IggyError` variant its doc
@@ -377,6 +421,10 @@ mod tests {
             u32::from(DeletePartitionsResult::StreamNotFound),
             stream_not_found
         );
+        assert_eq!(
+            u32::from(CreateConsumerGroupResult::StreamNotFound),
+            stream_not_found
+        );
 
         // StreamNameAlreadyExists (1012).
         let stream_name_exists = IggyError::StreamNameAlreadyExists(String::new()).as_code();
@@ -402,6 +450,10 @@ mod tests {
             u32::from(DeletePartitionsResult::TopicNotFound),
             topic_not_found
         );
+        assert_eq!(
+            u32::from(CreateConsumerGroupResult::TopicNotFound),
+            topic_not_found
+        );
 
         // TopicNameAlreadyExists (2013).
         let topic_name_exists = IggyError::TopicNameAlreadyExists(String::new(), id()).as_code();
@@ -428,6 +480,18 @@ mod tests {
             user_exists
         );
 
+        // InvalidUsername (43) - username length outside [MIN,MAX] bytes,
+        // rejected in-apply for create and rename alike.
+        let invalid_username = IggyError::InvalidUsername.as_code();
+        assert_eq!(
+            u32::from(CreateUserResult::InvalidUsername),
+            invalid_username
+        );
+        assert_eq!(
+            u32::from(UpdateUserResult::InvalidUsername),
+            invalid_username
+        );
+
         // ResourceNotFound (20) - documented stand-in for user/PAT not-found.
         let resource_not_found = IggyError::ResourceNotFound(String::new()).as_code();
         assert_eq!(
@@ -451,6 +515,26 @@ mod tests {
             resource_not_found
         );
 
+        // CannotDeleteUser (48) - root deletion is rejected in-apply.
+        assert_eq!(
+            u32::from(DeleteUserResult::CannotDeleteUser),
+            IggyError::CannotDeleteUser(0).as_code(),
+        );
+
+        // CannotChangePermissions (49) - root permissions are immutable in-apply.
+        assert_eq!(
+            u32::from(UpdatePermissionsResult::CannotChangePermissions),
+            IggyError::CannotChangePermissions(0).as_code(),
+        );
+
+        // InvalidCredentials (42) - wrong current password on ChangePassword,
+        // committed as a rejecting no-op so the client request sequence stays
+        // contiguous (a pre-consensus deny would gap it).
+        assert_eq!(
+            u32::from(ChangePasswordResult::InvalidCredentials),
+            IggyError::InvalidCredentials.as_code(),
+        );
+
         // PersonalAccessTokenAlreadyExists (51).
         assert_eq!(
             u32::from(CreatePersonalAccessTokenResult::AlreadyExists),
@@ -472,5 +556,8 @@ mod tests {
             u32::from(DeleteConsumerGroupResult::NotFound),
             IggyError::ConsumerGroupIdNotFound(id(), id()).as_code(),
         );
+
+        // Unauthorized (41) - the global in-apply RBAC denial code.
+        assert_eq!(UNAUTHORIZED_CODE, IggyError::Unauthorized.as_code());
     }
 }
diff --git a/core/metadata/src/stm/stream.rs b/core/metadata/src/stm/stream.rs
index 3844706be..defaaf086 100644
--- a/core/metadata/src/stm/stream.rs
+++ b/core/metadata/src/stm/stream.rs
@@ -628,7 +628,7 @@ impl StreamsInner {
         self.pending_revocations_count = count;
     }
 
-    fn resolve_stream_id(&self, identifier: &WireIdentifier) -> Option<usize> {
+    pub(crate) fn resolve_stream_id(&self, identifier: &WireIdentifier) -> Option<usize> {
         match identifier {
             WireIdentifier::Numeric(id) => {
                 let id = *id as usize;
@@ -642,7 +642,11 @@ impl StreamsInner {
         }
     }
 
-    fn resolve_topic_id(&self, stream_id: usize, identifier: &WireIdentifier) -> Option<usize> {
+    pub(crate) fn resolve_topic_id(
+        &self,
+        stream_id: usize,
+        identifier: &WireIdentifier,
+    ) -> Option<usize> {
         let stream = self.items.get(stream_id)?;
         match identifier {
             WireIdentifier::Numeric(id) => {
diff --git a/core/metadata/src/stm/user.rs b/core/metadata/src/stm/user.rs
index cda74d0b1..e7784a81e 100644
--- a/core/metadata/src/stm/user.rs
+++ b/core/metadata/src/stm/user.rs
@@ -35,8 +35,9 @@ use iggy_binary_protocol::requests::users::{
 use iggy_binary_protocol::responses::users::get_user::UserDetailsResponse;
 use iggy_binary_protocol::responses::users::user_response::UserResponse;
 use iggy_binary_protocol::{WireIdentifier, WireName};
+use iggy_common::defaults::{DEFAULT_ROOT_USER_ID, MAX_USERNAME_LENGTH, MIN_USERNAME_LENGTH};
 use iggy_common::{
-    GlobalPermissions, IggyExpiry, IggyTimestamp, Permissions, PersonalAccessToken,
+    GlobalPermissions, IggyError, IggyExpiry, IggyTimestamp, Permissions, PersonalAccessToken,
     StreamPermissions, UserId, UserStatus,
 };
 use serde::{Deserialize, Serialize};
@@ -124,7 +125,7 @@ collect_handlers! {
 }
 
 impl UsersInner {
-    fn resolve_user_id(&self, identifier: &WireIdentifier) -> Option<usize> {
+    pub(crate) fn resolve_user_id(&self, identifier: &WireIdentifier) -> Option<usize> {
         match identifier {
             WireIdentifier::Numeric(id) => {
                 let id = *id as usize;
@@ -138,6 +139,27 @@ impl UsersInner {
         }
     }
 
+    /// Stored password hash of the user named by `identifier`, `None` when
+    /// the user does not resolve.
+    #[must_use]
+    pub fn password_hash_of(&self, identifier: &WireIdentifier) -> Option<Arc<str>> {
+        self.resolve_user_id(identifier)
+            .and_then(|user_id| self.items.get(user_id))
+            .map(|user| Arc::clone(&user.password_hash))
+    }
+
+    /// Clone the committed permissions of the user at slab `id`, if any.
+    ///
+    /// The permissioner is a denormalized index of `User.permissions`; both
+    /// apply paths feed it from here, reading the just-stored user so the two
+    /// never drift.
+    #[must_use]
+    fn permissions_of(&self, id: usize) -> Option<Permissions> {
+        self.items
+            .get(id)
+            .and_then(|user| user.permissions.as_deref().cloned())
+    }
+
     /// Collect `(user_id, name)` for every expired personal access token.
     ///
     /// Walks the expiry-ordered `personal_access_token_expiry_index`,
@@ -155,6 +177,34 @@ impl UsersInner {
             .map(|(_, user_id, name)| (*user_id, Arc::clone(name)))
             .collect()
     }
+
+    /// Collect `(name, expiry_at)` for every live personal access token of
+    /// one user, sorted by name.
+    ///
+    /// Read-only accessor for the non-replicated list path (the caller lists
+    /// its own tokens). The backing per-user map is an `AHashMap`, whose
+    /// iteration order is seeded per process, so the collected list is
+    /// sorted before it is returned. Never lift this iteration into an apply
+    /// handler: apply must stay deterministic across replicas (see the
+    /// invariant on `personal_access_token_index`).
+    #[must_use]
+    pub fn personal_access_tokens_of(
+        &self,
+        user_id: UserId,
+    ) -> Vec<(Arc<str>, Option<IggyTimestamp>)> {
+        let mut tokens: Vec<_> = self
+            .personal_access_tokens
+            .get(&user_id)
+            .map(|user_tokens| {
+                user_tokens
+                    .values()
+                    .map(|pat| (Arc::clone(&pat.name), pat.expiry_at))
+                    .collect()
+            })
+            .unwrap_or_default();
+        tokens.sort_by(|(left, _), (right, _)| left.cmp(right));
+        tokens
+    }
 }
 
 impl Users {
@@ -166,16 +216,45 @@ impl Users {
         self.inner.read(f)
     }
 
+    /// Run a permissioner rule against the committed user permissions and
+    /// return its decision. The dispatch-time authorization surface for the
+    /// checks the in-apply RBAC gate cannot cover: non-replicated reads and
+    /// partition-plane ops, both decided on the connection's own shard rather
+    /// than in a replicated apply. The permissioner stays private; callers pass
+    /// the rule closure.
+    ///
+    /// # Errors
+    /// Propagates the rule's `IggyError` (always `Unauthorized`) on denial.
+    pub fn authorize(
+        &self,
+        rule: impl FnOnce(&Permissioner) -> Result<(), IggyError>,
+    ) -> Result<(), IggyError> {
+        self.read(|inner| rule(&inner.permissioner))
+    }
+
     /// Ensures a root user exists in an empty user set.
     ///
     /// # Panics
     ///
-    /// Panics if `username` is not a valid wire-format username.
+    /// Panics if `username` is not a valid wire-format username or its length is
+    /// outside `MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH`, failing bootstrap fast
+    /// rather than letting the replicated apply reject it as a committed no-op
+    /// that would leave the server with no root user.
     pub fn ensure_root_user(&self, username: &str, password_hash: &str) {
         if self.read(|users| !users.items.is_empty()) {
             return;
         }
 
+        // Fail fast on a misconfigured `IGGY_ROOT_USERNAME`: the replicated apply
+        // enforces this same length bound and would otherwise reject the
+        // bootstrap as a committed no-op (try_apply still returns Ok), leaving the
+        // server running with no root user.
+        let length = username.len();
+        assert!(
+            (MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH).contains(&length),
+            "root username length {length} outside {MIN_USERNAME_LENGTH}..={MAX_USERNAME_LENGTH}; fix IGGY_ROOT_USERNAME"
+        );
+
         // Boot-only invariant: server-ng calls this before listeners and
         // consensus traffic start, on shard 0 initialization. The read/apply
         // split cannot race another user creation in that phase.
@@ -330,6 +409,15 @@ impl StateHandler for CreateUserRequest {
     type State = UsersInner;
     #[allow(clippy::cast_possible_truncation)]
     fn apply(&self, state: &mut UsersInner, timestamp: IggyTimestamp) -> ApplyReply {
+        // `WireName` permits 1..=255 bytes, so a raw binary client bypasses the
+        // length bound the edges (HTTP DTO, SDK, login) enforce. Re-check in the
+        // replicated apply -- the choke point every transport shares -- so state
+        // never holds a username no ingress considers valid. Rejected before any
+        // mutation, committing as a deterministic no-op.
+        if !(MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH).contains(&self.username.as_str().len()) {
+            return ApplyReply::err(CreateUserResult::InvalidUsername);
+        }
+
         let username_arc: Arc<str> = Arc::from(self.username.as_str());
         if state.index.contains_key(&username_arc) {
             return ApplyReply::err(CreateUserResult::UserAlreadyExists);
@@ -360,6 +448,11 @@ impl StateHandler for CreateUserRequest {
             .personal_access_tokens
             .insert(id as UserId, AHashMap::default());
 
+        let user_permissions = state.permissions_of(id);
+        state
+            .permissioner
+            .init_permissions_for_user(id as UserId, user_permissions);
+
         // Reply body: the SDK `create_user` decodes a `UserDetailsResponse`.
         // Serialization local to this state machine.
         ApplyReply::ok(
@@ -390,6 +483,12 @@ impl StateHandler for UpdateUserRequest {
         };
 
         if let Some(new_username) = &self.username {
+            // Same bound as CreateUser apply: a rename must not smuggle in a
+            // username the edges reject. Rejected before any mutation.
+            if !(MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH).contains(&new_username.as_str().len()) {
+                return ApplyReply::err(UpdateUserResult::InvalidUsername);
+            }
+
             let new_username_arc: Arc<str> = Arc::from(new_username.as_str());
             if let Some(&existing_id) = state.index.get(&new_username_arc)
                 && existing_id != user_id as UserId
@@ -419,6 +518,15 @@ impl StateHandler for DeleteUserRequest {
             return ApplyReply::err(DeleteUserResult::UserNotFound);
         };
 
+        // Root (slab id 0) is undeletable, matching legacy `CannotDeleteUser`.
+        // The rejection is deterministic, so every replica keeps slab id 0 as
+        // the root user and the in-apply RBAC gate's `user_id == 0` root
+        // short-circuit can never be inherited by a recreated user (the slab
+        // reuses freed keys, so a deleted root's slot would be reclaimed).
+        if user_id == DEFAULT_ROOT_USER_ID as usize {
+            return ApplyReply::err(DeleteUserResult::CannotDeleteUser);
+        }
+
         if let Some(user) = state.items.get(user_id) {
             let username = user.username.clone();
             state.items.remove(user_id);
@@ -435,6 +543,9 @@ impl StateHandler for DeleteUserRequest {
                     }
                 }
             }
+            state
+                .permissioner
+                .delete_permissions_for_user(user_id as UserId);
         }
         ApplyReply::ok(Bytes::new())
     }
@@ -447,6 +558,16 @@ impl StateHandler for ChangePasswordRequest {
             return ApplyReply::err(ChangePasswordResult::UserNotFound);
         };
 
+        // An empty `new_password` is the primary's signal that the caller's
+        // current password did not match (see server-ng
+        // `verify_and_rewrite_change_password`): the accept path always
+        // replicates a non-empty Argon2 hash, so this is unambiguous. Rejecting
+        // here (rather than denying pre-consensus) commits the op as a no-op,
+        // keeping the client's request sequence contiguous in the ClientTable.
+        if self.new_password.is_empty() {
+            return ApplyReply::err(ChangePasswordResult::InvalidCredentials);
+        }
+
         if let Some(user) = state.items.get_mut(user_id) {
             user.password_hash = Arc::from(self.new_password.as_str());
         }
@@ -456,17 +577,30 @@ impl StateHandler for ChangePasswordRequest {
 
 impl StateHandler for UpdatePermissionsRequest {
     type State = UsersInner;
+    #[allow(clippy::cast_possible_truncation)]
     fn apply(&self, state: &mut UsersInner, _timestamp: IggyTimestamp) -> ApplyReply {
         let Some(user_id) = state.resolve_user_id(&self.user_id) else {
             return ApplyReply::err(UpdatePermissionsResult::UserNotFound);
         };
 
+        // Root (slab id 0) permissions are immutable, matching legacy
+        // `CannotChangePermissions`. Rejected before any mutation, so the op is a
+        // committed no-op: root keeps its full grants and the permissioner index
+        // is untouched.
+        if user_id == DEFAULT_ROOT_USER_ID as usize {
+            return ApplyReply::err(UpdatePermissionsResult::CannotChangePermissions);
+        }
+
         if let Some(user) = state.items.get_mut(user_id) {
             user.permissions = self
                 .permissions
                 .as_ref()
                 .map(|p| Arc::new(Permissions::from(p.clone())));
         }
+        let user_permissions = state.permissions_of(user_id);
+        state
+            .permissioner
+            .update_permissions_for_user(user_id as UserId, user_permissions);
         ApplyReply::ok(Bytes::new())
     }
 }
@@ -650,43 +784,18 @@ impl Snapshotable for Users {
                     })
                     .collect();
 
+            // The permissioner is a denormalized index of `User.permissions`.
+            // Its `AHashMap`/`AHashSet` fields iterate in a per-replica-random
+            // order, so serializing them would make the snapshot bytes differ
+            // across replicas. Persist empty vecs (kept only for the positional
+            // msgpack layout) and rebuild the index from the users on restore.
             let permissioner = PermissionerSnapshot {
-                users_permissions: inner
-                    .permissioner
-                    .users_permissions
-                    .iter()
-                    .map(|(&k, v)| (k, v.clone()))
-                    .collect(),
-                users_streams_permissions: inner
-                    .permissioner
-                    .users_streams_permissions
-                    .iter()
-                    .map(|(&k, v)| (k, v.clone()))
-                    .collect(),
-                users_that_can_poll_messages_from_all_streams: inner
-                    .permissioner
-                    .users_that_can_poll_messages_from_all_streams
-                    .iter()
-                    .copied()
-                    .collect(),
-                users_that_can_send_messages_to_all_streams: inner
-                    .permissioner
-                    .users_that_can_send_messages_to_all_streams
-                    .iter()
-                    .copied()
-                    .collect(),
-                users_that_can_poll_messages_from_specific_streams: inner
-                    .permissioner
-                    .users_that_can_poll_messages_from_specific_streams
-                    .iter()
-                    .copied()
-                    .collect(),
-                users_that_can_send_messages_to_specific_streams: inner
-                    .permissioner
-                    .users_that_can_send_messages_to_specific_streams
-                    .iter()
-                    .copied()
-                    .collect(),
+                users_permissions: Vec::new(),
+                users_streams_permissions: Vec::new(),
+                users_that_can_poll_messages_from_all_streams: Vec::new(),
+                users_that_can_send_messages_to_all_streams: Vec::new(),
+                users_that_can_poll_messages_from_specific_streams: Vec::new(),
+                users_that_can_send_messages_to_specific_streams: Vec::new(),
             };
 
             UsersSnapshot {
@@ -751,38 +860,14 @@ impl Snapshotable for Users {
             personal_access_tokens.insert(user_id, token_map);
         }
 
-        let permissioner = Permissioner {
-            users_permissions: snapshot
-                .permissioner
-                .users_permissions
-                .into_iter()
-                .collect(),
-            users_streams_permissions: snapshot
-                .permissioner
-                .users_streams_permissions
-                .into_iter()
-                .collect(),
-            users_that_can_poll_messages_from_all_streams: snapshot
-                .permissioner
-                .users_that_can_poll_messages_from_all_streams
-                .into_iter()
-                .collect(),
-            users_that_can_send_messages_to_all_streams: snapshot
-                .permissioner
-                .users_that_can_send_messages_to_all_streams
-                .into_iter()
-                .collect(),
-            users_that_can_poll_messages_from_specific_streams: snapshot
-                .permissioner
-                .users_that_can_poll_messages_from_specific_streams
-                .into_iter()
-                .collect(),
-            users_that_can_send_messages_to_specific_streams: snapshot
-                .permissioner
-                .users_that_can_send_messages_to_specific_streams
-                .into_iter()
-                .collect(),
-        };
+        // Rebuild the permissioner from the restored users rather than the
+        // snapshot vecs, which are intentionally empty (see `to_snapshot`).
+        // Slab iteration is ascending by key, so the build is deterministic.
+        let mut permissioner = Permissioner::new();
+        for (user_id, user) in &items {
+            permissioner
+                .init_permissions_for_user(user_id as UserId, user.permissions.as_deref().cloned());
+        }
 
         let inner = UsersInner {
             index,
@@ -802,6 +887,8 @@ impl_fill_restore!(Users, users);
 #[cfg(test)]
 mod tests {
     use super::*;
+    use iggy_binary_protocol::primitives::permissions::WireStreamPermissions;
+    use iggy_common::IggyError;
 
     #[test]
     fn create_pat_request_roundtrip_preserves_user_id() {
@@ -954,6 +1041,42 @@ mod tests {
         );
     }
 
+    #[test]
+    fn personal_access_tokens_of_lists_only_callers_sorted_by_name() {
+        let mut users = UsersInner::new();
+        // Insertion order deliberately not name order; a token for user 9
+        // proves the listing is caller-scoped.
+        for (user_id, name, expiry, fill) in [
+            (5u32, "zeta", 0u64, b'a'),
+            (5u32, "alpha", 1, b'b'),
+            (9u32, "other", 0, b'c'),
+        ] {
+            CreatePersonalAccessTokenRequest {
+                user_id,
+                name: WireName::new(name).unwrap(),
+                expiry,
+                token_hash: [fill; PAT_TOKEN_HASH_BYTES],
+            }
+            .apply(&mut users, IggyTimestamp::zero());
+        }
+
+        let tokens = users.personal_access_tokens_of(5);
+        let names: Vec<&str> = tokens.iter().map(|(name, _)| name.as_ref()).collect();
+        assert_eq!(names, ["alpha", "zeta"], "own tokens only, sorted by name");
+        assert!(
+            tokens[0].1.is_some(),
+            "expiring token must carry its expiry_at"
+        );
+        assert!(
+            tokens[1].1.is_none(),
+            "never-expiring token must carry no expiry_at"
+        );
+        assert!(
+            users.personal_access_tokens_of(42).is_empty(),
+            "a user without tokens lists empty"
+        );
+    }
+
     fn create_user(users: &mut UsersInner, username: &str) {
         let request = CreateUserRequest {
             username: WireName::new(username).unwrap(),
@@ -989,4 +1112,404 @@ mod tests {
         let apply = StateHandler::apply(&request, &mut users, IggyTimestamp::now());
         assert_eq!(apply.code, u32::from(DeleteUserResult::UserNotFound));
     }
+
+    #[test]
+    fn given_root_user_when_apply_delete_user_should_reject_and_keep_root() {
+        // Production seeds root at slab id 0 (`ensure_root_user`), so the first
+        // slab slot is the root user. Deleting it must be rejected in-apply, or
+        // a later `CreateUser` would reclaim slot 0 and inherit the RBAC gate's
+        // `user_id == 0` root short-circuit.
+        let mut users = UsersInner::new();
+        let root_id = create_user_with(&mut users, "iggy", None);
+        assert_eq!(root_id, DEFAULT_ROOT_USER_ID, "root takes slab id 0");
+
+        let delete_root = DeleteUserRequest {
+            user_id: WireIdentifier::numeric(root_id),
+        };
+        let reply = StateHandler::apply(&delete_root, &mut users, IggyTimestamp::now());
+        assert_eq!(reply.code, u32::from(DeleteUserResult::CannotDeleteUser));
+        assert!(
+            users.items.contains(root_id as usize),
+            "root must survive a delete attempt"
+        );
+
+        // A non-root user (slab id 1) stays deletable: the guard is scoped to
+        // root, so there is no over-block regression.
+        let alice_id = create_user_with(&mut users, "alice", None);
+        assert_ne!(alice_id, DEFAULT_ROOT_USER_ID);
+        let delete_alice = DeleteUserRequest {
+            user_id: WireIdentifier::numeric(alice_id),
+        };
+        let reply = StateHandler::apply(&delete_alice, &mut users, IggyTimestamp::now());
+        assert_eq!(reply.code, 0, "non-root deletion still succeeds");
+        assert!(!users.items.contains(alice_id as usize));
+    }
+
+    #[test]
+    fn given_root_user_when_apply_update_permissions_should_reject_and_keep_grants() {
+        // Root occupies slab id 0 (production `ensure_root_user`). Its
+        // permissions must be immutable in-apply, or a delegated manage_users
+        // admin could tamper with root's grants.
+        let mut users = UsersInner::new();
+        let mut root_global = empty_global();
+        root_global.manage_servers = true;
+        root_global.poll_messages = true;
+        let root_id = create_user_with(
+            &mut users,
+            "iggy",
+            Some(WirePermissions {
+                global: root_global,
+                streams: Vec::new(),
+            }),
+        );
+        assert_eq!(root_id, DEFAULT_ROOT_USER_ID, "root takes slab id 0");
+        assert!(users.permissioner.poll_messages(root_id, 1, 1).is_ok());
+
+        // Attempt to strip root's grants: rejected, nothing mutates.
+        let strip = UpdatePermissionsRequest {
+            user_id: WireIdentifier::numeric(root_id),
+            permissions: None,
+        };
+        let reply = StateHandler::apply(&strip, &mut users, IggyTimestamp::now());
+        assert_eq!(
+            reply.code,
+            u32::from(UpdatePermissionsResult::CannotChangePermissions)
+        );
+        assert!(
+            users
+                .items
+                .get(root_id as usize)
+                .and_then(|user| user.permissions.as_ref())
+                .is_some(),
+            "root permissions must survive"
+        );
+        assert!(
+            users.permissioner.poll_messages(root_id, 1, 1).is_ok(),
+            "root gate access unchanged"
+        );
+
+        // A non-root user's permissions can still be updated: no over-block.
+        let alice_id = create_user_with(&mut users, "alice", None);
+        assert_ne!(alice_id, DEFAULT_ROOT_USER_ID);
+        let mut alice_global = empty_global();
+        alice_global.poll_messages = true;
+        let grant = UpdatePermissionsRequest {
+            user_id: WireIdentifier::numeric(alice_id),
+            permissions: Some(WirePermissions {
+                global: alice_global,
+                streams: Vec::new(),
+            }),
+        };
+        let reply = StateHandler::apply(&grant, &mut users, IggyTimestamp::now());
+        assert_eq!(reply.code, 0, "non-root permission update still succeeds");
+        assert!(users.permissioner.poll_messages(alice_id, 1, 1).is_ok());
+    }
+
+    fn empty_global() -> WireGlobalPermissions {
+        WireGlobalPermissions {
+            manage_servers: false,
+            read_servers: false,
+            manage_users: false,
+            read_users: false,
+            manage_streams: false,
+            read_streams: false,
+            manage_topics: false,
+            read_topics: false,
+            poll_messages: false,
+            send_messages: false,
+        }
+    }
+
+    fn create_user_with(
+        users: &mut UsersInner,
+        username: &str,
+        permissions: Option<WirePermissions>,
+    ) -> UserId {
+        let request = CreateUserRequest {
+            username: WireName::new(username).unwrap(),
+            password: "hash".to_owned(),
+            status: 1,
+            permissions,
+        };
+        let reply = StateHandler::apply(&request, users, IggyTimestamp::now());
+        assert_eq!(reply.code, 0);
+        *users.index.get(username).expect("user created")
+    }
+
+    #[test]
+    fn given_global_poll_grant_when_apply_create_user_should_allow_poll_from_any_stream() {
+        let mut users = UsersInner::new();
+        let mut global = empty_global();
+        global.poll_messages = true;
+        let uid = create_user_with(
+            &mut users,
+            "poller",
+            Some(WirePermissions {
+                global,
+                streams: Vec::new(),
+            }),
+        );
+
+        // The global poll bit denormalizes into the can-poll-all set, so the
+        // user may poll any stream/topic without a stream-specific grant.
+        assert!(users.permissioner.poll_messages(uid, 7, 3).is_ok());
+        assert!(
+            users
+                .permissioner
+                .users_that_can_poll_messages_from_all_streams
+                .contains(&uid)
+        );
+    }
+
+    #[test]
+    fn given_poll_grant_when_apply_update_permissions_removes_it_should_return_unauthorized() {
+        let mut users = UsersInner::new();
+        // Occupy slab id 0 with the (immutable) root user so the poller under
+        // test is a non-root user; root permission changes are rejected in-apply.
+        create_user_with(&mut users, "iggy", None);
+        let mut global = empty_global();
+        global.poll_messages = true;
+        let uid = create_user_with(
+            &mut users,
+            "poller",
+            Some(WirePermissions {
+                global,
+                streams: Vec::new(),
+            }),
+        );
+        assert!(users.permissioner.poll_messages(uid, 1, 1).is_ok());
+
+        let update = UpdatePermissionsRequest {
+            user_id: WireIdentifier::numeric(uid),
+            permissions: None,
+        };
+        let reply = StateHandler::apply(&update, &mut users, IggyTimestamp::now());
+        assert_eq!(reply.code, 0);
+
+        assert!(matches!(
+            users.permissioner.poll_messages(uid, 1, 1),
+            Err(IggyError::Unauthorized)
+        ));
+        assert!(
+            !users
+                .permissioner
+                .users_that_can_poll_messages_from_all_streams
+                .contains(&uid)
+        );
+    }
+
+    #[test]
+    fn given_permissioned_user_when_apply_delete_user_should_clear_all_indexes() {
+        let mut users = UsersInner::new();
+        // Occupy slab id 0 with the (undeletable) root user so the victim under
+        // test is a non-root user; root deletion is rejected in-apply.
+        create_user_with(&mut users, "iggy", None);
+        let mut global = empty_global();
+        global.poll_messages = true;
+        global.send_messages = true;
+        let stream = WireStreamPermissions {
+            stream_id: 4,
+            manage_stream: false,
+            read_stream: false,
+            manage_topics: false,
+            read_topics: false,
+            poll_messages: true,
+            send_messages: true,
+            topics: Vec::new(),
+        };
+        let uid = create_user_with(
+            &mut users,
+            "victim",
+            Some(WirePermissions {
+                global,
+                streams: vec![stream],
+            }),
+        );
+        assert!(!users.permissioner.users_permissions.is_empty());
+
+        let delete = DeleteUserRequest {
+            user_id: WireIdentifier::numeric(uid),
+        };
+        let reply = StateHandler::apply(&delete, &mut users, IggyTimestamp::now());
+        assert_eq!(reply.code, 0);
+
+        let permissioner = &users.permissioner;
+        assert!(permissioner.users_permissions.is_empty());
+        assert!(permissioner.users_streams_permissions.is_empty());
+        assert!(
+            permissioner
+                .users_that_can_poll_messages_from_all_streams
+                .is_empty()
+        );
+        assert!(
+            permissioner
+                .users_that_can_send_messages_to_all_streams
+                .is_empty()
+        );
+        assert!(
+            permissioner
+                .users_that_can_poll_messages_from_specific_streams
+                .is_empty()
+        );
+        assert!(
+            permissioner
+                .users_that_can_send_messages_to_specific_streams
+                .is_empty()
+        );
+    }
+
+    #[test]
+    fn given_populated_permissions_when_snapshot_round_trip_should_rebuild_index_and_persist_empty_vecs()
+     {
+        let mut inner = UsersInner::new();
+        let mut global = empty_global();
+        global.poll_messages = true;
+        global.send_messages = true;
+        let stream = WireStreamPermissions {
+            stream_id: 2,
+            manage_stream: false,
+            read_stream: false,
+            manage_topics: false,
+            read_topics: false,
+            poll_messages: true,
+            send_messages: true,
+            topics: Vec::new(),
+        };
+        let uid = create_user_with(
+            &mut inner,
+            "alice",
+            Some(WirePermissions {
+                global,
+                streams: vec![stream],
+            }),
+        );
+        let pre_poll = inner.permissioner.poll_messages(uid, 9, 9).is_ok();
+        let pre_send = inner.permissioner.append_messages(uid, 2, 0).is_ok();
+
+        let users: Users = inner.into();
+        let snapshot = users.to_snapshot();
+
+        // The derived index is not serialized: every permissioner vec is empty.
+        let snap = &snapshot.permissioner;
+        assert!(snap.users_permissions.is_empty());
+        assert!(snap.users_streams_permissions.is_empty());
+        assert!(
+            snap.users_that_can_poll_messages_from_all_streams
+                .is_empty()
+        );
+        assert!(snap.users_that_can_send_messages_to_all_streams.is_empty());
+        assert!(
+            snap.users_that_can_poll_messages_from_specific_streams
+                .is_empty()
+        );
+        assert!(
+            snap.users_that_can_send_messages_to_specific_streams
+                .is_empty()
+        );
+
+        let restored = Users::from_snapshot(snapshot).expect("snapshot restore");
+        let (poll_ok, send_ok, in_poll_all, in_send_specific) = restored.read(|inner| {
+            let permissioner = &inner.permissioner;
+            (
+                permissioner.poll_messages(uid, 9, 9).is_ok(),
+                permissioner.append_messages(uid, 2, 0).is_ok(),
+                permissioner
+                    .users_that_can_poll_messages_from_all_streams
+                    .contains(&uid),
+                permissioner
+                    .users_that_can_send_messages_to_specific_streams
+                    .contains(&(uid, 2)),
+            )
+        });
+
+        // Rule results survive the round-trip...
+        assert_eq!(poll_ok, pre_poll);
+        assert_eq!(send_ok, pre_send);
+        // ...because the index was rebuilt from the restored user: the global
+        // poll bit repopulated the can-poll-all set and the stream send bit
+        // repopulated the specific set.
+        assert!(in_poll_all);
+        assert!(in_send_specific);
+    }
+
+    #[test]
+    fn given_root_user_when_ensure_root_after_bootstrap_should_have_root_grants() {
+        let users = Users::default();
+        users.ensure_root_user("iggy", "hash");
+        // Root carries `Permissions::root()`: full grants across every rule.
+        let (can_create_stream, can_get_stats, can_poll) = users.read(|inner| {
+            let root = *inner.index.get("iggy").expect("root user created");
+            (
+                inner.permissioner.create_stream(root).is_ok(),
+                inner.permissioner.get_stats(root).is_ok(),
+                inner.permissioner.poll_messages(root, 1, 1).is_ok(),
+            )
+        });
+        assert!(can_create_stream);
+        assert!(can_get_stats);
+        assert!(can_poll);
+    }
+
+    #[test]
+    fn given_out_of_bound_username_when_apply_create_user_should_reject_without_insert() {
+        let mut users = UsersInner::new();
+
+        // `WireName` accepts 1..=255 bytes, so a raw binary client can submit a
+        // username shorter than the edge-enforced minimum. Apply must reject it.
+        for username in [
+            "a".repeat(MIN_USERNAME_LENGTH - 1),
+            "a".repeat(MAX_USERNAME_LENGTH + 1),
+        ] {
+            let request = CreateUserRequest {
+                username: WireName::new(&username).unwrap(),
+                password: "hash".to_owned(),
+                status: 1,
+                permissions: None,
+            };
+            let reply = StateHandler::apply(&request, &mut users, IggyTimestamp::now());
+            assert_eq!(reply.code, u32::from(CreateUserResult::InvalidUsername));
+            assert!(reply.body.is_empty());
+        }
+        assert!(users.items.is_empty(), "no user inserted on reject");
+        assert!(users.index.is_empty(), "index untouched on reject");
+
+        // The minimum-length boundary is accepted.
+        let username = "a".repeat(MIN_USERNAME_LENGTH);
+        let request = CreateUserRequest {
+            username: WireName::new(&username).unwrap(),
+            password: "hash".to_owned(),
+            status: 1,
+            permissions: None,
+        };
+        let reply = StateHandler::apply(&request, &mut users, IggyTimestamp::now());
+        assert_eq!(reply.code, 0);
+        assert!(users.index.contains_key(username.as_str()));
+    }
+
+    #[test]
+    fn given_too_short_rename_when_apply_update_user_should_reject_and_keep_username() {
+        let mut users = UsersInner::new();
+        create_user_with(&mut users, "iggy", None);
+        let alice_id = create_user_with(&mut users, "alice", None);
+
+        let short = "a".repeat(MIN_USERNAME_LENGTH - 1);
+        let rename = UpdateUserRequest {
+            user_id: WireIdentifier::numeric(alice_id),
+            username: Some(WireName::new(&short).unwrap()),
+            status: None,
+        };
+        let reply = StateHandler::apply(&rename, &mut users, IggyTimestamp::now());
+        assert_eq!(reply.code, u32::from(UpdateUserResult::InvalidUsername));
+
+        assert_eq!(
+            users
+                .items
+                .get(alice_id as usize)
+                .map(|u| u.username.as_ref()),
+            Some("alice"),
+            "username must survive a rejected rename"
+        );
+        assert_eq!(users.index.get("alice"), Some(&alice_id));
+        assert!(!users.index.contains_key(short.as_str()));
+    }
 }
diff --git a/core/partitions/src/iggy_partition.rs b/core/partitions/src/iggy_partition.rs
index b3c5098af..7373577d2 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -20,7 +20,7 @@ use crate::journal::{MessageLookup, PartitionJournal, PartitionJournalMemStorage
 use crate::log::JournalInfo;
 use crate::log::SegmentedLog;
 use crate::messages_writer::MessagesWriter;
-use crate::offset_storage::{delete_persisted_offset, persist_offset};
+use crate::offset_storage::{delete_persisted_offset, persist_offset, persist_offset_max};
 use crate::poll_plan::{
     AutoCommitCtx, AutoCommitTarget, DiskReadPlan, DiskSegment, LastPolledCtx, PollPlan, PollTier,
     ResidentTailSnapshot,
@@ -33,8 +33,8 @@ use crate::{
 use consensus::{
     CommitLogEvent, Consensus, PartitionDiagEvent, Pipeline, PipelineEntry, PlaneKind, Project,
     ReplicaLogContext, RequestLogEvent, Sequencer, SimEventKind, VsrConsensus, ack_preflight,
-    ack_quorum_reached, build_reply_from_request, build_reply_message,
-    build_result_rejection_reply, drain_committable_prefix, emit_namespace_progress_event,
+    ack_quorum_reached, build_deny_reply_from_request, build_reply_from_request,
+    build_reply_message, drain_committable_prefix, emit_namespace_progress_event,
     emit_partition_diag, emit_sim_event, fence_old_prepare_by_commit, replicate_preflight,
     replicate_to_next_in_chain, send_prepare_ok as send_prepare_ok_common,
 };
@@ -49,7 +49,7 @@ use iggy_common::{
     IggyByteSize, IggyError, IggyExpiry, IggyTimestamp, PartitionStats, PollingKind,
 };
 use journal::Journal as _;
-use message_bus::{IggyMessageBus, MessageBus};
+use message_bus::{IggyMessageBus, MessageBus, is_auto_commit_client};
 use server_common::{
     Message, SegmentStorage,
     iobuf::Frozen,
@@ -58,7 +58,9 @@ use server_common::{
     },
     sharding::IggyNamespace,
 };
+use std::cell::RefCell;
 use std::collections::HashMap;
+use std::hash::Hash;
 use std::rc::Rc;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicU64, Ordering};
@@ -105,6 +107,19 @@ where
     partition_dir: Option<String>,
     consumer_offset_enforce_fsync: bool,
     pending_consumer_offset_commits: HashMap<u64, PendingConsumerOffsetCommit>,
+    /// Committed-only mirror of each consumer's persisted offset file: the
+    /// last value this replica durably wrote per (kind, consumer id). Fed
+    /// exclusively by the file-writing paths (replicated commit-apply, the
+    /// primary-local `NoAck` store, purge/delete/reclaim) and never by the
+    /// eager poll-path in-memory apply, so both readers see committed state
+    /// only: the auto-commit persist gate (skip or blind-write, no per-commit
+    /// file read) and the submit-side coalesce gate
+    /// ([`Self::is_auto_commit_offset_covered`]). A cold key (first touch
+    /// after boot) folds against the file once via `persist_offset_max`, so
+    /// the tracker rebuilds from disk lazily and deterministically.
+    /// `RefCell`: mutated from `&self` paths on the single shard thread;
+    /// borrows never cross an await.
+    persisted_offsets: RefCell<HashMap<(ConsumerKind, u32), u64>>,
     observed_view: u32,
     /// Highest `PurgeTopic` generation this replica has locally applied (reset
     /// the partition to empty). The reconciler compares the committed metadata
@@ -131,6 +146,11 @@ struct PendingConsumerOffsetCommit {
     kind: ConsumerKind,
     consumer_id: u32,
     mutation: PendingConsumerOffsetMutation,
+    /// A server auto-commit (a poll's `auto_commit`, replicated via the reserved
+    /// `AUTO_COMMIT_CLIENT_ID`): the commit-apply must be monotone so it cannot
+    /// rewind the eager in-memory offset a newer poll already advanced. Explicit
+    /// client stores leave this `false` (a store may legitimately rewind).
+    auto_commit: bool,
 }
 
 #[derive(Debug, Clone, Copy, PartialEq)]
@@ -145,6 +165,17 @@ impl PendingConsumerOffsetCommit {
             kind,
             consumer_id,
             mutation: PendingConsumerOffsetMutation::Upsert(offset),
+            auto_commit: false,
+        }
+    }
+
+    /// Monotone-apply variant for a server auto-commit op. See `auto_commit`.
+    const fn upsert_auto_commit(kind: ConsumerKind, consumer_id: u32, offset: u64) -> Self {
+        Self {
+            kind,
+            consumer_id,
+            mutation: PendingConsumerOffsetMutation::Upsert(offset),
+            auto_commit: true,
         }
     }
 
@@ -153,6 +184,7 @@ impl PendingConsumerOffsetCommit {
             kind,
             consumer_id,
             mutation: PendingConsumerOffsetMutation::Delete,
+            auto_commit: false,
         }
     }
 
@@ -198,6 +230,7 @@ where
             partition_dir: None,
             consumer_offset_enforce_fsync: false,
             pending_consumer_offset_commits: HashMap::new(),
+            persisted_offsets: RefCell::new(HashMap::new()),
             observed_view,
             applied_purge_generation: 0,
         }
@@ -269,8 +302,13 @@ where
         kind: ConsumerKind,
         consumer_id: u32,
         offset: u64,
+        auto_commit: bool,
     ) {
-        let pending = PendingConsumerOffsetCommit::upsert(kind, consumer_id, offset);
+        let pending = if auto_commit {
+            PendingConsumerOffsetCommit::upsert_auto_commit(kind, consumer_id, offset)
+        } else {
+            PendingConsumerOffsetCommit::upsert(kind, consumer_id, offset)
+        };
         self.pending_consumer_offset_commits.insert(op, pending);
     }
 
@@ -322,14 +360,67 @@ where
         let Some(path) = self.persisted_offset_path(pending.kind, pending.consumer_id) else {
             return Ok(());
         };
+        let key = (pending.kind, pending.consumer_id);
         match pending.mutation {
+            // A server auto-commit persists monotonically: its op offset can
+            // trail the durably-recorded value (disk-tier polls replicate in
+            // IO-completion order), so a plain overwrite would rewind the file
+            // and re-deliver on restart. The `persisted_offsets` tracker keeps
+            // the fold off the file: a covered offset skips the write, an
+            // advancing one blind-writes, and only a cold key (first commit
+            // after boot) reads the file once. Explicit client stores
+            // overwrite, so a deliberate offset reset still holds. Mirrors the
+            // in-memory `upsert_offset_max` vs `upsert_offset` split in the
+            // commit-apply.
+            PendingConsumerOffsetMutation::Upsert(offset) if pending.auto_commit => {
+                let tracked = self.persisted_offsets.borrow().get(&key).copied();
+                let persisted = match tracked {
+                    Some(high_water) if offset <= high_water => return Ok(()),
+                    Some(_) => {
+                        persist_offset(&path, offset, self.consumer_offset_enforce_fsync).await?;
+                        offset
+                    }
+                    None => {
+                        persist_offset_max(&path, offset, self.consumer_offset_enforce_fsync)
+                            .await?
+                    }
+                };
+                self.persisted_offsets.borrow_mut().insert(key, persisted);
+                Ok(())
+            }
             PendingConsumerOffsetMutation::Upsert(offset) => {
-                persist_offset(&path, offset, self.consumer_offset_enforce_fsync).await
+                persist_offset(&path, offset, self.consumer_offset_enforce_fsync).await?;
+                self.persisted_offsets.borrow_mut().insert(key, offset);
+                Ok(())
+            }
+            PendingConsumerOffsetMutation::Delete => {
+                delete_persisted_offset(&path).await?;
+                self.persisted_offsets.borrow_mut().remove(&key);
+                Ok(())
             }
-            PendingConsumerOffsetMutation::Delete => delete_persisted_offset(&path).await,
         }
     }
 
+    /// Whether the committed high-water for this consumer already covers
+    /// `offset`, so a poll's auto-commit submit cannot advance it and may be
+    /// skipped instead of burning a consensus op. Reads committed state only
+    /// (the tracker is fed at commit-apply, never by the eager poll-path
+    /// apply): an offset covered in memory but not yet committed keeps
+    /// resubmitting until the covering op actually lands, so a dropped
+    /// in-flight op self-heals on the next poll.
+    #[must_use]
+    pub fn is_auto_commit_offset_covered(
+        &self,
+        kind: ConsumerKind,
+        consumer_id: u32,
+        offset: u64,
+    ) -> bool {
+        self.persisted_offsets
+            .borrow()
+            .get(&(kind, consumer_id))
+            .is_some_and(|&high_water| offset <= high_water)
+    }
+
     fn apply_consumer_offset_commit(
         &self,
         pending: PendingConsumerOffsetCommit,
@@ -340,12 +431,19 @@ where
             {
                 let id = pending.consumer_id;
                 let key = usize::try_from(id).expect("u32 consumer id must fit usize");
-                crate::poll_plan::upsert_offset(&self.consumer_offsets, key, offset, || {
+                let create = || {
                     self.consumer_offsets_path.as_deref().map_or_else(
                         || ConsumerOffset::new(ConsumerKind::Consumer, id, 0, String::new()),
                         |path| ConsumerOffset::default_for_consumer(id, path),
                     )
-                });
+                };
+                upsert_committed_offset(
+                    &self.consumer_offsets,
+                    key,
+                    offset,
+                    pending.auto_commit,
+                    create,
+                );
                 Ok(())
             }
             PendingConsumerOffsetMutation::Upsert(offset)
@@ -355,7 +453,7 @@ where
                 let key = ConsumerGroupId(
                     usize::try_from(group_id).expect("u32 group id must fit usize"),
                 );
-                crate::poll_plan::upsert_offset(&self.consumer_group_offsets, key, offset, || {
+                let create = || {
                     self.consumer_group_offsets_path.as_deref().map_or_else(
                         || {
                             ConsumerOffset::new(
@@ -367,7 +465,14 @@ where
                         },
                         |path| ConsumerOffset::default_for_consumer_group(key, path),
                     )
-                });
+                };
+                upsert_committed_offset(
+                    &self.consumer_group_offsets,
+                    key,
+                    offset,
+                    pending.auto_commit,
+                    create,
+                );
                 Ok(())
             }
             // Commit-time apply keeps its invariant check on the PRIMARY:
@@ -443,6 +548,9 @@ where
         let mut paths = Vec::with_capacity(dead.len());
         for group_id in dead {
             pinned.remove(&ConsumerGroupId(group_id as usize));
+            self.persisted_offsets
+                .borrow_mut()
+                .remove(&(ConsumerKind::ConsumerGroup, group_id as u32));
             if let Some(path) =
                 self.persisted_offset_path(ConsumerKind::ConsumerGroup, group_id as u32)
             {
@@ -713,10 +821,11 @@ where
         }
     }
 
-    /// Capture the owned inputs for an auto-commit, if requested. The committed
-    /// offset is unknown until the poll completes, so the persist path + fsync
-    /// flag (for the disk write) and the lock-free offset-map `Arc` (for the
-    /// in-memory apply) are captured here, so both run off the partition borrow.
+    /// Capture the owned inputs for an auto-commit, if requested: the lock-free
+    /// offset-map `Arc` and the target consumer/group id, so the in-memory apply
+    /// runs off the partition borrow once the poll's served offset is known.
+    /// Durability is not captured here: the poll no longer writes the offset
+    /// file, the serving shard replicates the offset through consensus instead.
     fn auto_commit_ctx(
         &self,
         consumer: PollingConsumer,
@@ -726,7 +835,6 @@ where
             return None;
         }
         let pending = PendingConsumerOffsetCommit::try_from_polling_consumer(consumer, 0).ok()?;
-        let offset_path = self.persisted_offset_path(pending.kind, pending.consumer_id);
         let target = match pending.kind {
             ConsumerKind::Consumer => AutoCommitTarget::Consumer {
                 offsets: self.consumer_offsets.clone(),
@@ -739,11 +847,7 @@ where
                 create_path: self.consumer_group_offsets_path.clone(),
             },
         };
-        Some(AutoCommitCtx {
-            offset_path,
-            enforce_fsync: self.consumer_offset_enforce_fsync,
-            target,
-        })
+        Some(AutoCommitCtx { target })
     }
 
     /// Synchronous in-memory journal poll, for the resident tier. Never awaits
@@ -1052,30 +1156,30 @@ where
                     .with_operation(message.header().operation)
                     .with_error(error.to_string()),
                 );
-                // Reply with a terminal error instead of dropping the request:
-                // the SDK decodes any non-`TransientNotCommitted` result code as a
-                // final error, so the client gets `ConsumerOffsetNotFound` at once
-                // rather than replaying until its read-timeout (a client hang).
-                // The op is NOT admitted/replicated, so no replica ever applies a
-                // delete for an offset the primary knows is absent.
-                let reply = build_result_rejection_reply(
+                // Deny on the primary before the op enters the pipeline: nothing
+                // replicates, so backups never see the rejected delete, and the
+                // client gets a typed failure instead of waiting out its reply
+                // timeout. The code rides `ReplyHeader.status` (not the result
+                // body): the HTTP listener's `classify_partition_reply` reads the
+                // status field to render the typed 404.
+                Self::send_partition_deny_or_log(
+                    consensus,
                     message.header(),
-                    self.consensus().commit_max(),
                     error.as_code(),
-                );
-                let _ = self
-                    .consensus()
-                    .message_bus()
-                    .send_to_client(client_id, reply.into_generic().into_frozen())
-                    .await;
+                    "delete_consumer_offset deny reply send failed",
+                )
+                .await;
                 return;
             }
 
-            // Reject an out-of-range consumer-offset store at admission with a
-            // terminal `InvalidOffset` (result-framed like the delete rejection),
-            // mirroring the legacy `validate_partition_offset`: an empty partition
-            // accepts no offset, and a stored offset may not run ahead of the
-            // committed offset. Done here so the doomed op is never replicated.
+            // Reject an out-of-range consumer-offset store at admission,
+            // mirroring the legacy `validate_partition_offset`: an empty
+            // partition accepts no offset, and a stored offset may not run ahead
+            // of the committed offset. Done here so the doomed op is never
+            // replicated. Like the delete-offset deny above, the typed
+            // `InvalidOffset` rides `ReplyHeader.status` (op=0, empty body): the
+            // status-only `classify_partition_reply` would misread a result-body
+            // code on this committed-shaped frame (op=commit_max) as success.
             if matches!(
                 message.header().operation,
                 Operation::StoreConsumerOffset | Operation::StoreConsumerOffset2
@@ -1094,16 +1198,13 @@ where
                         .with_operation(message.header().operation)
                         .with_error(IggyError::InvalidOffset(requested_offset).to_string()),
                     );
-                    let reply = build_result_rejection_reply(
+                    Self::send_partition_deny_or_log(
+                        consensus,
                         message.header(),
-                        self.consensus().commit_max(),
                         IggyError::InvalidOffset(requested_offset).as_code(),
-                    );
-                    let _ = self
-                        .consensus()
-                        .message_bus()
-                        .send_to_client(client_id, reply.into_generic().into_frozen())
-                        .await;
+                        "store_consumer_offset deny reply send failed",
+                    )
+                    .await;
                     return;
                 }
             }
@@ -1578,6 +1679,7 @@ where
                             kind,
                             consumer_id,
                             offset.expect("store_consumer_offset must include offset"),
+                            is_auto_commit_client(header.client),
                         );
                     }
                     Operation::DeleteConsumerOffset | Operation::DeleteConsumerOffset2 => {
@@ -1839,7 +1941,7 @@ where
         let committed_visible_offsets = self.resolve_committed_visible_offsets(&drained).await;
         let mut messages_committed = false;
 
-        for mut entry in drained {
+        for entry in drained {
             let prepare_header = entry.header;
             if !self
                 .commit_partition_entry(
@@ -1889,20 +1991,17 @@ where
             // No reply cache: at-least-once means retries re-commit at new
             // offsets. Only primary delivers replies; backups just advance
             // commit. Session lifecycle is metadata-only.
-            let reply = build_reply_message(
-                &prepare_header,
-                &committed_reply_body(prepare_header.operation),
-            );
-
-            // TODO: no production caller yet. Partition has no in-process
-            // subscriber (only metadata uses pipeline_message_with_subscriber);
-            // wired for forward-compat. Fired AFTER local commit (slot-first
-            // ordering analog). Dropped receiver ignored.
-            if let Some(sender) = entry.take_reply_sender() {
-                let _ = sender.send(reply.clone());
-            }
-
-            if send_client_replies {
+            //
+            // A server-generated auto-commit op (a poll's `auto_commit`,
+            // replicated for failover) carries the reserved
+            // `AUTO_COMMIT_CLIENT_ID`: no client ever waits on it, so skip the
+            // reply. Emitting it would push an unrequested frame onto a real
+            // client's lockstep reply stream if the sentinel ever routed there.
+            if send_client_replies && !is_auto_commit_client(prepare_header.client) {
+                let reply = build_reply_message(
+                    &prepare_header,
+                    &committed_reply_body(prepare_header.operation),
+                );
                 let reply_buffers = reply.into_generic().into_frozen();
                 emit_sim_event(SimEventKind::ClientReplyEmitted, &event);
 
@@ -2071,6 +2170,34 @@ where
         Self::parse_consumer_offset_payload(operation, body)
     }
 
+    /// Send `header`'s deny reply with `status` on `ReplyHeader.status` (empty
+    /// body, op=0), logging a WARN under `send_fail_label` if the reply send
+    /// fails. Callers deny on the primary, before the op enters the pipeline,
+    /// so nothing replicates.
+    async fn send_partition_deny_or_log(
+        consensus: &VsrConsensus<B>,
+        header: &RequestHeader,
+        status: u32,
+        send_fail_label: &'static str,
+    ) {
+        let reply = build_deny_reply_from_request(consensus, header, status);
+        if let Err(send_error) = consensus
+            .message_bus()
+            .send_to_client(header.client, reply.into_generic().into_frozen())
+            .await
+        {
+            emit_partition_diag(
+                tracing::Level::WARN,
+                &PartitionDiagEvent::new(
+                    ReplicaLogContext::from_consensus(consensus, PlaneKind::Partitions),
+                    send_fail_label,
+                )
+                .with_operation(header.operation)
+                .with_error(send_error.to_string()),
+            );
+        }
+    }
+
     fn parse_staged_consumer_offset_commit(
         operation: Operation,
         message: &Message<PrepareHeader>,
@@ -2680,6 +2807,10 @@ where
         for path in consumer_paths.into_iter().chain(group_paths) {
             let _ = delete_persisted_offset(&path).await;
         }
+        // The persisted-offset tracker mirrors the files unlinked above; a
+        // stale entry would make a post-purge auto-commit skip its write and
+        // lose the offset on restart.
+        self.persisted_offsets.borrow_mut().clear();
 
         // Clear the ephemeral cooperative-rebalance tracking too: after the
         // reset to offset 0 a stale `last_polled` (a high pre-purge offset)
@@ -2715,6 +2846,28 @@ where
     }
 }
 
+/// Commit-apply an upserted offset into a lock-free offset map. A server
+/// auto-commit already advanced this offset in memory on the serving poll and
+/// this replicated commit can land behind a newer poll, so it must be
+/// monotone (`fetch_max`) or it rewinds the map and re-serves consumed
+/// messages. An explicit client store keeps the rewinding `store` (an offset
+/// reset is a valid action).
+fn upsert_committed_offset<K>(
+    map: &papaya::HashMap<K, ConsumerOffset>,
+    key: K,
+    offset: u64,
+    auto_commit: bool,
+    create_on_miss: impl FnOnce() -> ConsumerOffset,
+) where
+    K: Hash + Eq + Clone + Send + Sync,
+{
+    if auto_commit {
+        crate::poll_plan::upsert_offset_max(map, key, offset, create_on_miss);
+    } else {
+        crate::poll_plan::upsert_offset(map, key, offset, create_on_miss);
+    }
+}
+
 /// The operation tag at the front of a journal entry. Every entry begins with a
 /// `PrepareHeader`, so reading the tag is a cheap cast, not a full batch decode;
 /// it tells a committed consumer-offset op (no segment bytes) apart from a
@@ -2840,9 +2993,14 @@ mod tests {
     use bytes::Bytes;
     use compio::io::AsyncWriteAtExt;
     use consensus::LocalPipeline;
+    use iggy_binary_protocol::{Command2, ReplyHeader, WireConsumer, WireEncode};
+    use message_bus::SendError;
+    use server_common::MESSAGE_ALIGN;
     use server_common::send_messages2::{
         COMMAND_HEADER_SIZE, IggyMessage2, IggyMessage2Header, IggyMessages2, SendMessages2Owned,
     };
+    use std::cell::RefCell;
+    use std::rc::Rc;
 
     const TEST_CLUSTER: u128 = 1;
 
@@ -2865,6 +3023,317 @@ mod tests {
         )
     }
 
+    /// Client-facing bus that records every `send_to_client` frame so tests
+    /// can assert on reply bytes without a connection registry (whose slot
+    /// guard would borrow the partition across `on_request(&mut self)`).
+    #[derive(Debug, Default)]
+    struct RecordingBus {
+        sent_to_clients: Rc<RefCell<Vec<(u128, Frozen<MESSAGE_ALIGN>)>>>,
+    }
+
+    impl MessageBus for RecordingBus {
+        fn track_background(&self, _handle: message_bus::JoinHandle<()>) {}
+
+        async fn send_to_client(
+            &self,
+            client_id: u128,
+            data: Frozen<MESSAGE_ALIGN>,
+        ) -> Result<(), SendError> {
+            self.sent_to_clients.borrow_mut().push((client_id, data));
+            Ok(())
+        }
+
+        async fn send_to_replica(
+            &self,
+            _replica: u8,
+            _data: Frozen<MESSAGE_ALIGN>,
+        ) -> Result<(), SendError> {
+            Ok(())
+        }
+
+        fn set_connection_lost_fn(&self, _f: message_bus::ConnectionLostFn) {}
+        fn set_replica_forward_fn(&self, _f: message_bus::ReplicaForwardFn) {}
+        fn set_client_forward_fn(&self, _f: message_bus::ClientForwardFn) {}
+    }
+
+    type SentFrames = Rc<RefCell<Vec<(u128, Frozen<MESSAGE_ALIGN>)>>>;
+
+    fn recording_partition() -> (IggyPartition<RecordingBus>, SentFrames) {
+        let namespace = IggyNamespace::new(1, 1, 0);
+        let bus = RecordingBus::default();
+        let sent_to_clients = bus.sent_to_clients.clone();
+        let consensus = VsrConsensus::new(
+            TEST_CLUSTER,
+            0,
+            1,
+            namespace.inner(),
+            bus,
+            LocalPipeline::new(),
+        );
+        consensus.init();
+        let partition = IggyPartition::with_in_memory_storage(
+            Arc::new(PartitionStats::default()),
+            consensus,
+            IggyByteSize::from(1024 * 1024),
+            false,
+        );
+        (partition, sent_to_clients)
+    }
+
+    fn delete_offset_request(
+        client_id: u128,
+        request_id: u64,
+        consumer_id: u32,
+    ) -> Message<RequestHeader> {
+        let body = DeleteConsumerOffset2Request {
+            consumer: WireConsumer::consumer(WireIdentifier::Numeric(consumer_id)),
+            stream_id: WireIdentifier::Numeric(1),
+            topic_id: WireIdentifier::Numeric(1),
+            partition_id: Some(0),
+            ack: AckLevel::Quorum,
+        }
+        .to_bytes();
+        let header_size = std::mem::size_of::<RequestHeader>();
+        let total = header_size + body.len();
+        let mut message = Message::<RequestHeader>::new(total);
+        message.as_mut_slice()[header_size..].copy_from_slice(&body);
+        message.transmute_header(|_, header: &mut RequestHeader| {
+            header.command = Command2::Request;
+            header.operation = Operation::DeleteConsumerOffset2;
+            header.client = client_id;
+            header.session = 1;
+            header.request = request_id;
+            header.namespace = IggyNamespace::new(1, 1, 0).inner();
+            header.size = u32::try_from(total).expect("request size fits u32");
+        })
+    }
+
+    /// Deleting a consumer offset that was never stored must answer with a
+    /// typed deny reply (empty body, `status` = `ConsumerOffsetNotFound`,
+    /// `op` 0) before consensus: nothing may enter the pipeline, and an
+    /// awaited client write must fail fast instead of waiting out its reply
+    /// timeout. Once the offset exists, the same request must pass the gate
+    /// into the pipeline without a deny.
+    #[compio::test]
+    async fn on_request_delete_of_missing_offset_replies_typed_deny() {
+        let (mut partition, sent_to_clients) = recording_partition();
+        let client_id: u128 = 42;
+        let consumer_id: u32 = 5;
+
+        partition
+            .on_request(delete_offset_request(client_id, 7, consumer_id))
+            .await;
+
+        {
+            let sent = sent_to_clients.borrow();
+            assert_eq!(sent.len(), 1, "exactly one deny reply");
+            let (reply_client, frame) = &sent[0];
+            assert_eq!(*reply_client, client_id);
+            let header = bytemuck::checked::try_from_bytes::<ReplyHeader>(
+                &frame.as_slice()[..std::mem::size_of::<ReplyHeader>()],
+            )
+            .expect("deny frame starts with a valid reply header");
+            assert_eq!(header.command, Command2::Reply);
+            assert_eq!(
+                header.status,
+                IggyError::ConsumerOffsetNotFound(0).as_code()
+            );
+            assert_eq!(header.op, 0, "a deny commits nothing");
+            assert_eq!(header.request, 7);
+            assert_eq!(
+                header.size as usize,
+                std::mem::size_of::<ReplyHeader>(),
+                "deny reply body must be empty"
+            );
+        }
+        assert_eq!(
+            partition.consensus().pipeline().borrow().len(),
+            0,
+            "denied delete must not replicate"
+        );
+        assert!(partition.pending_consumer_offset_commits.is_empty());
+
+        // Existing offset: the gate passes and the delete enters the pipeline.
+        partition.consumer_offsets.pin().insert(
+            consumer_id as usize,
+            ConsumerOffset::new(ConsumerKind::Consumer, consumer_id, 3, String::new()),
+        );
+        partition
+            .on_request(delete_offset_request(client_id, 8, consumer_id))
+            .await;
+        assert_eq!(
+            partition.consensus().pipeline().borrow().len(),
+            1,
+            "existing offset delete must replicate"
+        );
+    }
+
+    fn unique_temp_offset_dir() -> String {
+        let mut dir = std::env::temp_dir();
+        dir.push(format!(
+            "iggy-offset-test-{}-{}",
+            std::process::id(),
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .expect("system clock after epoch")
+                .as_nanos(),
+        ));
+        dir.to_string_lossy().into_owned()
+    }
+
+    /// A server auto-commit persists monotonically. Disk-tier polls replicate
+    /// their offsets in IO-completion order, so the last committed op can carry
+    /// a lower offset than an earlier one; the file must keep the max or a
+    /// restart reloads the rewound value and re-delivers. An explicit client
+    /// store still overwrites, so a deliberate offset reset holds.
+    #[compio::test]
+    async fn auto_commit_offset_persists_monotonically_explicit_store_rewinds() {
+        let mut partition = test_partition();
+        let dir = unique_temp_offset_dir();
+        partition.consumer_offsets_path = Some(dir.clone());
+        let consumer_id: u32 = 5;
+        let path = format!("{dir}/{consumer_id}");
+        let read_disk = |p: &str| -> u64 {
+            let bytes = std::fs::read(p).expect("offset file exists");
+            u64::from_le_bytes(bytes.try_into().expect("offset file is 8 bytes"))
+        };
+
+        // Reordered auto-commits: the later op (109) trails the earlier (114).
+        partition
+            .persist_consumer_offset_commit(PendingConsumerOffsetCommit::upsert_auto_commit(
+                ConsumerKind::Consumer,
+                consumer_id,
+                114,
+            ))
+            .await
+            .expect("auto-commit persist 114");
+        partition
+            .persist_consumer_offset_commit(PendingConsumerOffsetCommit::upsert_auto_commit(
+                ConsumerKind::Consumer,
+                consumer_id,
+                109,
+            ))
+            .await
+            .expect("auto-commit persist 109");
+        assert_eq!(
+            read_disk(&path),
+            114,
+            "auto-commit must not rewind the file on IO-completion reorder"
+        );
+
+        assert!(
+            partition.is_auto_commit_offset_covered(ConsumerKind::Consumer, consumer_id, 114),
+            "committed high-water covers the persisted offset"
+        );
+        assert!(
+            !partition.is_auto_commit_offset_covered(ConsumerKind::Consumer, consumer_id, 115),
+            "an advancing offset is not covered and must submit"
+        );
+
+        // An explicit client store may deliberately rewind.
+        partition
+            .persist_consumer_offset_commit(PendingConsumerOffsetCommit::upsert(
+                ConsumerKind::Consumer,
+                consumer_id,
+                109,
+            ))
+            .await
+            .expect("explicit store persist 109");
+        assert_eq!(read_disk(&path), 109, "explicit store may rewind the file");
+        assert!(
+            !partition.is_auto_commit_offset_covered(ConsumerKind::Consumer, consumer_id, 114),
+            "explicit rewind lowers the high-water so a later auto-commit may re-advance"
+        );
+
+        // The accepted edge: an auto-commit racing the explicit rewind
+        // re-advances the file past it.
+        partition
+            .persist_consumer_offset_commit(PendingConsumerOffsetCommit::upsert_auto_commit(
+                ConsumerKind::Consumer,
+                consumer_id,
+                114,
+            ))
+            .await
+            .expect("auto-commit persist 114 after rewind");
+        assert_eq!(
+            read_disk(&path),
+            114,
+            "auto-commit re-advances past a rewind"
+        );
+
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    /// The persisted-offset tracker is cold after a restart; the first
+    /// auto-commit folds against the file once (so a pre-existing higher value
+    /// wins, exactly like the old per-commit read-modify-write) and warms the
+    /// tracker with the on-disk value, not the op's. A delete drops both the
+    /// file and the tracker entry so a later auto-commit starts a fresh fold.
+    #[compio::test]
+    async fn auto_commit_cold_key_folds_against_file_once() {
+        let mut partition = test_partition();
+        let dir = unique_temp_offset_dir();
+        partition.consumer_offsets_path = Some(dir.clone());
+        let consumer_id: u32 = 5;
+        let path = format!("{dir}/{consumer_id}");
+        let read_disk = |p: &str| -> u64 {
+            let bytes = std::fs::read(p).expect("offset file exists");
+            u64::from_le_bytes(bytes.try_into().expect("offset file is 8 bytes"))
+        };
+
+        // Simulate the previous process run: the file already holds 114.
+        persist_offset(&path, 114, false)
+            .await
+            .expect("seed offset file");
+        assert!(
+            !partition.is_auto_commit_offset_covered(ConsumerKind::Consumer, consumer_id, 1),
+            "a cold key is never covered; the first submit must go through"
+        );
+
+        partition
+            .persist_consumer_offset_commit(PendingConsumerOffsetCommit::upsert_auto_commit(
+                ConsumerKind::Consumer,
+                consumer_id,
+                109,
+            ))
+            .await
+            .expect("auto-commit persist 109 on cold key");
+        assert_eq!(
+            read_disk(&path),
+            114,
+            "cold-key fold must not rewind the pre-existing on-disk value"
+        );
+        assert!(
+            partition.is_auto_commit_offset_covered(ConsumerKind::Consumer, consumer_id, 114),
+            "tracker warms with the on-disk value, not the trailing op offset"
+        );
+
+        partition
+            .persist_consumer_offset_commit(PendingConsumerOffsetCommit::delete(
+                ConsumerKind::Consumer,
+                consumer_id,
+            ))
+            .await
+            .expect("delete persisted offset");
+        assert!(!std::path::Path::new(&path).exists(), "file unlinked");
+        assert!(
+            !partition.is_auto_commit_offset_covered(ConsumerKind::Consumer, consumer_id, 1),
+            "delete drops the tracker entry with the file"
+        );
+
+        partition
+            .persist_consumer_offset_commit(PendingConsumerOffsetCommit::upsert_auto_commit(
+                ConsumerKind::Consumer,
+                consumer_id,
+                7,
+            ))
+            .await
+            .expect("auto-commit persist 7 after delete");
+        assert_eq!(read_disk(&path), 7, "post-delete auto-commit starts fresh");
+
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
     /// `reclaim_dead_group_offsets` must drop exactly the not-`is_live` groups
     /// from the in-memory map and hand back their owned persisted-file paths,
     /// leaving live groups untouched. The returned `Vec<String>` is what the
diff --git a/core/partitions/src/lib.rs b/core/partitions/src/lib.rs
index 12ba1cde1..64ae0d257 100644
--- a/core/partitions/src/lib.rs
+++ b/core/partitions/src/lib.rs
@@ -39,7 +39,7 @@ pub use iggy_partition::IggyPartition;
 pub use iggy_partitions::IggyPartitions;
 pub use messages_writer::MessagesWriter;
 pub use offset_storage::delete_persisted_offset;
-pub use poll_plan::PollPlan;
+pub use poll_plan::{AutoCommitApplied, PollPlan};
 pub use segment::Segment;
 use server_common::Message;
 pub use server_common::send_messages2::{IggyMessage2, IggyMessage2Header, IggyMessages2};
diff --git a/core/partitions/src/offset_storage.rs b/core/partitions/src/offset_storage.rs
index 249e5d8b0..ebd4cf9de 100644
--- a/core/partitions/src/offset_storage.rs
+++ b/core/partitions/src/offset_storage.rs
@@ -17,11 +17,13 @@
 
 use compio::{
     fs::{OpenOptions, create_dir_all, remove_file},
-    io::AsyncWriteAtExt,
+    io::{AsyncReadAtExt, AsyncWriteAtExt},
 };
 use iggy_common::IggyError;
 use std::path::Path;
 
+const OFFSET_SIZE: usize = core::mem::size_of::<u64>();
+
 pub async fn persist_offset(path: &str, offset: u64, enforce_fsync: bool) -> Result<(), IggyError> {
     if let Some(parent) = Path::new(path).parent()
         && !parent.exists()
@@ -53,6 +55,60 @@ pub async fn persist_offset(path: &str, offset: u64, enforce_fsync: bool) -> Res
     Ok(())
 }
 
+/// Monotone counterpart of [`persist_offset`] for a server auto-commit op:
+/// folds `max(current_on_disk, offset)` and returns the value now on disk,
+/// skipping the write when the file already holds it. Disk-tier polls
+/// replicate their auto-committed offsets in IO-completion order, so a
+/// committed op can carry a lower offset than an earlier one; a plain
+/// overwrite would leave the file rewound and a restart would reload the
+/// stale value and re-deliver. The on-disk value is committed-only (this path
+/// never writes the eager serving map), so the fold is identical on every
+/// replica applying the same op order.
+///
+/// The read makes this the cold-key path only: once the caller's
+/// persisted-offset tracker knows the file's value, warm commits persist with
+/// a blind [`persist_offset`] and skip covered offsets without any file read.
+pub async fn persist_offset_max(
+    path: &str,
+    offset: u64,
+    enforce_fsync: bool,
+) -> Result<u64, IggyError> {
+    let on_disk = read_persisted_offset(path).await?;
+    let effective = on_disk.map_or(offset, |current| current.max(offset));
+    if on_disk != Some(effective) {
+        persist_offset(path, effective, enforce_fsync).await?;
+    }
+    Ok(effective)
+}
+
+/// Read a single persisted consumer offset. `None` if the file is absent or
+/// torn (shorter than 8 bytes): a crash between `persist_offset`'s truncate
+/// and write leaves a short file, and the boot-time loader already skips such
+/// files, so the commit-path reader must agree or a torn file turns every
+/// later commit-apply into an error. Real I/O errors still propagate: mapping
+/// them to `None` would silently rewind a valid higher offset.
+async fn read_persisted_offset(path: &str) -> Result<Option<u64>, IggyError> {
+    if !Path::new(path).exists() {
+        return Ok(None);
+    }
+    let file = OpenOptions::new()
+        .read(true)
+        .open(path)
+        .await
+        .map_err(|_| IggyError::CannotOpenConsumerOffsetsFile(path.to_owned()))?;
+    let buf = vec![0u8; OFFSET_SIZE];
+    let compio::BufResult(read, buf) = file.read_exact_at(buf, 0).await;
+    match read {
+        Ok(()) => {}
+        Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
+        Err(_) => return Err(IggyError::CannotReadConsumerOffsets(path.to_owned())),
+    }
+    let bytes: [u8; OFFSET_SIZE] = buf
+        .try_into()
+        .map_err(|_| IggyError::CannotReadConsumerOffsets(path.to_owned()))?;
+    Ok(Some(u64::from_le_bytes(bytes)))
+}
+
 /// Unlink a persisted consumer-offset file. A no-op if the file is absent.
 ///
 /// # Errors
@@ -66,3 +122,90 @@ pub async fn delete_persisted_offset(path: &str) -> Result<(), IggyError> {
         .await
         .map_err(|_| IggyError::CannotDeleteConsumerOffsetFile(path.to_owned()))
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn unique_temp_dir() -> std::path::PathBuf {
+        let dir = std::env::temp_dir().join(format!(
+            "iggy-offset-storage-test-{}-{}",
+            std::process::id(),
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .expect("system clock after epoch")
+                .as_nanos(),
+        ));
+        std::fs::create_dir_all(&dir).expect("create temp dir");
+        dir
+    }
+
+    #[compio::test]
+    async fn read_persisted_offset_absent_file_is_none() {
+        let dir = unique_temp_dir();
+        let path = dir.join("42").to_string_lossy().into_owned();
+
+        let read = read_persisted_offset(&path).await.expect("absent file");
+        assert_eq!(read, None);
+
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[compio::test]
+    async fn read_persisted_offset_round_trips_persisted_value() {
+        let dir = unique_temp_dir();
+        let path = dir.join("42").to_string_lossy().into_owned();
+
+        persist_offset(&path, 114, false).await.expect("persist");
+        let read = read_persisted_offset(&path).await.expect("valid file");
+        assert_eq!(read, Some(114));
+
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[compio::test]
+    async fn read_persisted_offset_torn_file_is_none_not_error() {
+        let dir = unique_temp_dir();
+        let path = dir.join("42").to_string_lossy().into_owned();
+        std::fs::write(&path, [0xAB, 0xCD, 0xEF]).expect("write torn file");
+
+        let read = read_persisted_offset(&path)
+            .await
+            .expect("torn file must not error the commit path");
+        assert_eq!(read, None, "short read maps to None like the boot loader");
+
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[compio::test]
+    async fn read_persisted_offset_real_io_error_propagates() {
+        // A directory opens read-only but every read fails with EISDIR: a real
+        // I/O error, not a short read. It must surface as Err, never as None
+        // (a blanket None would silently rewind a valid higher offset).
+        let dir = unique_temp_dir();
+        let path = dir.to_string_lossy().into_owned();
+
+        let result = read_persisted_offset(&path).await;
+        assert!(
+            matches!(result, Err(IggyError::CannotReadConsumerOffsets(_))),
+            "real I/O error must propagate, got {result:?}",
+        );
+
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+
+    #[compio::test]
+    async fn persist_offset_max_recovers_torn_file() {
+        let dir = unique_temp_dir();
+        let path = dir.join("42").to_string_lossy().into_owned();
+        std::fs::write(&path, [0xABu8; 5]).expect("write torn file");
+
+        persist_offset_max(&path, 7, false)
+            .await
+            .expect("torn file folds as absent");
+        let read = read_persisted_offset(&path).await.expect("repaired file");
+        assert_eq!(read, Some(7));
+
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+}
diff --git a/core/partitions/src/poll_plan.rs b/core/partitions/src/poll_plan.rs
index aa9b7c54d..4792760d3 100644
--- a/core/partitions/src/poll_plan.rs
+++ b/core/partitions/src/poll_plan.rs
@@ -22,18 +22,17 @@
 //! `&mut` to the same namespace while a poll is parked, dangling the reference.
 //! So `IggyPartition::build_poll_plan` captures everything a poll needs
 //! synchronously under the borrow into the owned types here, drops the borrow,
-//! then [`PollPlan::execute`] runs the disk read + offset persist on owned data
-//! alone: consumer offsets are already `Arc`, the journal tail is a
-//! point-in-time `Frozen` snapshot, and segment files are re-opened by path. No
-//! value in this module holds a partition reference, so executing a plan is
-//! sound on a detached task concurrently with the pump's own writes.
+//! then [`PollPlan::execute`] runs the disk read + the in-memory auto-commit
+//! apply on owned data alone: consumer offsets are already `Arc`, the journal
+//! tail is a point-in-time `Frozen` snapshot, and segment files are re-opened
+//! by path. No value in this module holds a partition reference, so executing a
+//! plan is sound on a detached task concurrently with the pump's own writes.
 
 use crate::PollFragments;
 use crate::journal::{MessageLookup, push_selected_batch_fragments, select_batch_slice};
-use crate::offset_storage::persist_offset;
 use compio::io::AsyncReadAtExt;
 use iggy_common::{
-    ConsumerGroupId, ConsumerGroupOffsets, ConsumerKind, ConsumerOffset, ConsumerOffsets, IggyError,
+    ConsumerGroupId, ConsumerGroupOffsets, ConsumerKind, ConsumerOffset, ConsumerOffsets,
 };
 use server_common::iobuf::{Frozen, Owned};
 use server_common::send_messages2::{COMMAND_HEADER_SIZE, decode_batch_slice};
@@ -60,15 +59,27 @@ pub struct DiskSegment {
     pub(crate) persisted: u64,
 }
 
-/// Owned auto-commit inputs, applied off the partition borrow after a poll (see
-/// module docs). The committed offset is unknown until the poll completes, so
-/// the persist path + fsync flag are captured up front for the later disk write.
+/// Owned auto-commit input, applied off the partition borrow after a poll (see
+/// module docs). Only the in-memory apply happens here; durability is the
+/// replicated [`crate::iggy_partition::IggyPartition::apply_staged_consumer_offset_commit`]
+/// path's job on every node, driven by the `StoreConsumerOffset2` op the serving
+/// shard submits from [`AutoCommitApplied`]. A poll-local disk write would be
+/// node-local only and diverge on failover.
 pub struct AutoCommitCtx {
-    pub(crate) offset_path: Option<String>,
-    pub(crate) enforce_fsync: bool,
     pub(crate) target: AutoCommitTarget,
 }
 
+/// The offset an `auto_commit` poll applied in memory, surfaced for replication.
+///
+/// The serving shard replicates it through the partition consensus (the only
+/// cross-node durable path); `kind` + `consumer_id` are the offset key the
+/// submitted `StoreConsumerOffset2` op must carry.
+pub struct AutoCommitApplied {
+    pub kind: ConsumerKind,
+    pub consumer_id: u32,
+    pub offset: u64,
+}
+
 /// The lock-free offset map this auto-commit updates, captured as an owned
 /// `Arc` so the apply needs no partition borrow. `create_path` builds the
 /// `ConsumerOffset` entry on first commit for a consumer that has none yet.
@@ -158,30 +169,27 @@ pub struct PollPlan {
 }
 
 impl PollPlan {
-    /// Whether executing this plan needs off-pump IO: a disk read (`Disk` tier)
-    /// or a consumer-offset persist (`auto_commit`). When `false`, the result is
-    /// fully resident and the caller can [`Self::execute_resident`] + reply on
-    /// the pump without spawning; when `true`, it must spawn [`Self::execute`]
-    /// so the pump is not blocked on file IO.
+    /// Whether executing this plan needs off-pump IO: only a `Disk` tier read.
+    /// When `false` the result is fully resident and the caller runs
+    /// [`Self::execute_resident`] + replies on the pump; when `true` it must
+    /// spawn [`Self::execute`] so the pump is not blocked on file IO.
+    ///
+    /// Auto-commit no longer forces a detached task: its in-memory apply is
+    /// synchronous and its durability rides consensus off the serving shard
+    /// (no poll-local disk write), so a fully-resident `auto_commit` poll still
+    /// replies inline.
     #[must_use]
     pub const fn needs_off_pump_io(&self) -> bool {
-        if matches!(self.tier, PollTier::Disk { .. }) {
-            return true;
-        }
-        // A resident poll only needs a detached task when its auto-commit must
-        // hit disk. With no `offset_path` (sim/dev) the apply is a sync,
-        // in-memory store the pump runs inline via `execute_resident`.
-        match &self.auto_commit {
-            Some(auto_commit) => auto_commit.needs_persist(),
-            None => false,
-        }
+        matches!(self.tier, PollTier::Disk { .. })
     }
 
     /// Execute this plan off the partition borrow: disk read (if any), straddle
-    /// splice into the owned resident-tail snapshot, then persist + apply the
-    /// auto-commit on the owned `Arc` offset map. Holds no partition reference
-    /// (see module docs), so it is safe on a detached task.
-    pub async fn execute(self) -> (PollFragments<4096>, u64) {
+    /// splice into the owned resident-tail snapshot, then apply the auto-commit
+    /// to the owned `Arc` offset map. Holds no partition reference (see module
+    /// docs), so it is safe on a detached task. Returns the served fragments,
+    /// the poll's high-water offset, and the auto-committed offset (if any) for
+    /// the serving shard to replicate through consensus.
+    pub async fn execute(self) -> (PollFragments<4096>, u64, Option<AutoCommitApplied>) {
         let commit_offset = self.commit_offset;
         let (fragments, last_matching_offset) = match self.tier {
             PollTier::Empty => (PollFragments::new(), None),
@@ -240,34 +248,21 @@ impl PollPlan {
             },
         };
 
-        if let (Some(last_polled), Some(last_offset)) = (&self.last_polled, last_matching_offset) {
-            last_polled.record(last_offset);
-        }
-
-        if let Some(auto_commit) = self.auto_commit
-            && !fragments.is_empty()
-            && let Some(last_offset) = last_matching_offset
-        {
-            match auto_commit.persist(last_offset).await {
-                // Apply to the in-memory map on the owned `Arc` (no borrow).
-                Ok(()) => auto_commit.apply(last_offset),
-                Err(err) => warn!(
-                    target: "iggy.partitions.diag",
-                    last_offset,
-                    %err,
-                    "poll_read: failed to persist consumer offset"
-                ),
-            }
-        }
-
-        (fragments, commit_offset)
+        finish(
+            self.last_polled.as_ref(),
+            self.auto_commit,
+            commit_offset,
+            fragments,
+            last_matching_offset,
+        )
     }
 
     /// Synchronous fast path for a fully-resident poll
-    /// ([`Self::needs_off_pump_io`] is `false`): no disk read, no
-    /// consumer-offset persist, so the pump can reply inline without spawning.
+    /// ([`Self::needs_off_pump_io`] is `false`): no disk read, so the pump
+    /// applies the auto-commit in memory and replies inline without spawning.
+    /// The auto-committed offset is returned for the serving shard to replicate.
     #[must_use]
-    pub fn execute_resident(self) -> (PollFragments<4096>, u64) {
+    pub fn execute_resident(self) -> (PollFragments<4096>, u64, Option<AutoCommitApplied>) {
         let commit_offset = self.commit_offset;
         let (fragments, last_matching_offset) = match self.tier {
             PollTier::Empty => (PollFragments::new(), None),
@@ -281,23 +276,61 @@ impl PollPlan {
                 unreachable!("execute_resident on Disk tier; needs_off_pump_io guards this")
             }
         };
-        if let (Some(last_polled), Some(last_offset)) = (&self.last_polled, last_matching_offset) {
-            last_polled.record(last_offset);
-        }
-        // An auto-commit reaches the resident fast path only when it needs no
-        // disk persist (`needs_off_pump_io` gate), so apply the offset to the
-        // in-memory map inline. `execute()` does the same after its persist;
-        // skipping it here would silently drop the committed offset.
-        if let Some(auto_commit) = self.auto_commit
-            && !fragments.is_empty()
-            && let Some(last_offset) = last_matching_offset
-        {
-            auto_commit.apply(last_offset);
-        }
-        (fragments, commit_offset)
+        finish(
+            self.last_polled.as_ref(),
+            self.auto_commit,
+            commit_offset,
+            fragments,
+            last_matching_offset,
+        )
     }
 }
 
+/// Common tail of [`PollPlan::execute`] and [`PollPlan::execute_resident`],
+/// factored out so the high-water record, auto-commit, and returned triple
+/// stay identical across both.
+fn finish(
+    last_polled: Option<&LastPolledCtx>,
+    auto_commit: Option<AutoCommitCtx>,
+    commit_offset: u64,
+    fragments: PollFragments<4096>,
+    last_matching_offset: Option<u64>,
+) -> (PollFragments<4096>, u64, Option<AutoCommitApplied>) {
+    if let (Some(last_polled), Some(last_offset)) = (last_polled, last_matching_offset) {
+        last_polled.record(last_offset);
+    }
+    let auto_commit_applied = apply_auto_commit(auto_commit, &fragments, last_matching_offset);
+    (fragments, commit_offset, auto_commit_applied)
+}
+
+/// Apply an `auto_commit` to the in-memory offset map (monotone) and surface
+/// the committed offset so the serving shard can replicate it through
+/// consensus. `None` when the poll served nothing (empty fragments) or no
+/// auto-commit was requested. Shared by [`PollPlan::execute`] and
+/// [`PollPlan::execute_resident`] so both apply identically.
+///
+/// The eager in-memory apply preserves read-your-own-poll for a tight
+/// `Consumer::Next` loop that reads before the replicated commit lands; the
+/// commit's apply is an idempotent monotone set, so the double-apply converges.
+fn apply_auto_commit(
+    auto_commit: Option<AutoCommitCtx>,
+    fragments: &PollFragments<4096>,
+    last_matching_offset: Option<u64>,
+) -> Option<AutoCommitApplied> {
+    let auto_commit = auto_commit?;
+    if fragments.is_empty() {
+        return None;
+    }
+    let last_offset = last_matching_offset?;
+    auto_commit.apply(last_offset);
+    let (kind, consumer_id) = auto_commit.kind_and_id();
+    Some(AutoCommitApplied {
+        kind,
+        consumer_id,
+        offset: last_offset,
+    })
+}
+
 pub enum PollTier {
     Empty,
     Resident {
@@ -512,18 +545,16 @@ impl DiskReadPlan {
 }
 
 impl AutoCommitCtx {
-    /// Whether applying this auto-commit needs a real disk persist. `false` for
-    /// sim/dev partitions with no `offset_path`, where the apply is a sync,
-    /// in-memory `papaya` store that the pump can run inline.
-    pub(crate) const fn needs_persist(&self) -> bool {
-        self.offset_path.is_some()
-    }
-
-    /// Persist the committed offset to disk, off the partition borrow.
-    pub(crate) async fn persist(&self, offset: u64) -> Result<(), IggyError> {
-        match &self.offset_path {
-            Some(path) => persist_offset(path, offset, self.enforce_fsync).await,
-            None => Ok(()),
+    /// The offset key (kind + numeric id) this auto-commit targets, for the
+    /// replicated `StoreConsumerOffset2` op the serving shard submits.
+    pub(crate) const fn kind_and_id(&self) -> (ConsumerKind, u32) {
+        match &self.target {
+            AutoCommitTarget::Consumer { consumer_id, .. } => {
+                (ConsumerKind::Consumer, *consumer_id)
+            }
+            AutoCommitTarget::ConsumerGroup { group_id, .. } => {
+                (ConsumerKind::ConsumerGroup, *group_id)
+            }
         }
     }
 
@@ -610,7 +641,12 @@ pub fn upsert_offset<K>(
 /// newer explicit `StoreConsumerOffset` cannot rewind it backward. The
 /// on-miss create branch is identical. The explicit pump path keeps
 /// [`upsert_offset`] (`store`), since an explicit store may legitimately rewind.
-fn upsert_offset_max<K>(
+///
+/// Also used by the replicated commit-apply for a server auto-commit op
+/// ([`crate::iggy_partition::IggyPartition::apply_consumer_offset_commit`]): its
+/// offset was already advanced in memory by the eager poll-path apply, and this
+/// commit can land behind a newer poll, so it must not `store` (rewind) it.
+pub fn upsert_offset_max<K>(
     map: &papaya::HashMap<K, ConsumerOffset>,
     key: K,
     offset: u64,
@@ -684,14 +720,8 @@ mod tests {
         fragments
     }
 
-    fn consumer_auto_commit(
-        offsets: Arc<ConsumerOffsets>,
-        consumer_id: u32,
-        offset_path: Option<String>,
-    ) -> AutoCommitCtx {
+    fn consumer_auto_commit(offsets: Arc<ConsumerOffsets>, consumer_id: u32) -> AutoCommitCtx {
         AutoCommitCtx {
-            offset_path,
-            enforce_fsync: false,
             target: AutoCommitTarget::Consumer {
                 offsets,
                 consumer_id,
@@ -701,15 +731,15 @@ mod tests {
     }
 
     #[test]
-    fn resident_auto_commit_without_offset_path_applies_inline() {
-        // sim/dev partition: auto_commit is requested but there is no
-        // `offset_path`, so the apply is a sync in-memory store. The plan must
-        // stay on the resident fast path (no detached task) AND still record
-        // the committed offset, which the resident path used to drop.
+    fn resident_auto_commit_applies_in_memory_and_surfaces_offset() {
+        // A resident auto_commit poll stays on the inline fast path (no detached
+        // task since the poll no longer persists), applies the committed offset
+        // to the in-memory map for read-your-own-poll, AND surfaces it so the
+        // serving shard replicates it through consensus.
         let offsets = Arc::new(ConsumerOffsets::with_capacity(1));
         let plan = PollPlan {
             commit_offset: 42,
-            auto_commit: Some(consumer_auto_commit(offsets.clone(), 7, None)),
+            auto_commit: Some(consumer_auto_commit(offsets.clone(), 7)),
             last_polled: None,
             tier: PollTier::Resident {
                 fragments: non_empty_fragments(),
@@ -719,13 +749,18 @@ mod tests {
 
         assert!(
             !plan.needs_off_pump_io(),
-            "no offset_path means the apply is sync; the pump must not spawn",
+            "a resident auto_commit no longer persists on the poll path; the pump must not spawn",
         );
 
-        let (fragments, commit_offset) = plan.execute_resident();
+        let (fragments, commit_offset, applied) = plan.execute_resident();
         assert!(!fragments.is_empty(), "resident fragments must be returned");
         assert_eq!(commit_offset, 42, "commit offset is forwarded verbatim");
 
+        let applied = applied.expect("auto_commit must surface the applied offset for replication");
+        assert!(matches!(applied.kind, ConsumerKind::Consumer));
+        assert_eq!(applied.consumer_id, 7);
+        assert_eq!(applied.offset, 5);
+
         let stored = offsets
             .pin()
             .get(&7usize)
@@ -738,24 +773,25 @@ mod tests {
     }
 
     #[test]
-    fn resident_auto_commit_with_offset_path_needs_off_pump_io() {
+    fn empty_resident_poll_surfaces_no_auto_commit() {
+        // Nothing served -> nothing to commit: no offset is surfaced and the
+        // in-memory map stays untouched.
         let offsets = Arc::new(ConsumerOffsets::with_capacity(1));
         let plan = PollPlan {
-            commit_offset: 0,
-            auto_commit: Some(consumer_auto_commit(
-                offsets,
-                7,
-                Some("some/path".to_owned()),
-            )),
+            commit_offset: 9,
+            auto_commit: Some(consumer_auto_commit(offsets.clone(), 7)),
             last_polled: None,
-            tier: PollTier::Resident {
-                fragments: non_empty_fragments(),
-                last_matching_offset: Some(5),
-            },
+            tier: PollTier::Empty,
         };
+        let (fragments, _commit_offset, applied) = plan.execute_resident();
+        assert!(fragments.is_empty());
+        assert!(
+            applied.is_none(),
+            "empty poll must not surface an auto-commit"
+        );
         assert!(
-            plan.needs_off_pump_io(),
-            "a real offset_path persist must be spawned off the pump",
+            offsets.pin().get(&7usize).is_none(),
+            "an empty poll must not touch the offset map",
         );
     }
 
@@ -764,7 +800,7 @@ mod tests {
         // Auto-commit must never rewind a newer offset (anti-rewind via
         // fetch_max); an explicit StoreConsumerOffset may legitimately rewind.
         let offsets = Arc::new(ConsumerOffsets::with_capacity(1));
-        let auto_commit = consumer_auto_commit(offsets.clone(), 7, None);
+        let auto_commit = consumer_auto_commit(offsets.clone(), 7);
 
         auto_commit.apply(10);
         let after_high = offsets
diff --git a/core/sdk/src/session.rs b/core/sdk/src/session.rs
index 39f35c43c..ac56c0b33 100644
--- a/core/sdk/src/session.rs
+++ b/core/sdk/src/session.rs
@@ -30,15 +30,17 @@
 //!
 //! ```text
 //! new()                  - fresh client_id generated, session = None
-//! register_request_id()  - returns 0 (for the register operation, once only)
+//! begin_register()       - returns 0; re-arms a used session for a re-login
 //! bind(session)          - session assigned by server after register commits
 //! next_request_id()      - returns 1, 2, 3, ... (application requests)
 //! drop + new()           - on disconnect/crash, create a fresh session
 //! ```
 //!
-//! `ConsensusSession` is **not reusable**: on disconnect/crash drop and
-//! recreate. New `client_id` avoids ambiguous re-register; old entry
-//! stays in server `ClientTable` until evicted.
+//! A single registration is one-shot ([`ConsensusSession::register_request_id`]
+//! panics if reused), but a re-login re-arms the same value in place via
+//! [`ConsensusSession::begin_register`], which mints a fresh `client_id` and
+//! clears the binding. A new `client_id` avoids ambiguous re-register; the old
+//! entry stays in the server `ClientTable` until evicted.
 
 /// Consensus-level session state.
 ///
@@ -127,6 +129,21 @@ impl ConsensusSession {
         0
     }
 
+    /// Begin a registration, re-arming the session if it was already used.
+    ///
+    /// A re-login (fresh credentials, reconnect, or restart replay) reuses the
+    /// same `ConsensusSession`. If it is already bound or a prior register was
+    /// consumed, start a fresh session (new `client_id`, unbound) so the
+    /// Register encodes cleanly. Unlike the one-shot
+    /// [`register_request_id`](Self::register_request_id), this never panics on
+    /// a repeat login. Returns the register request id (always 0).
+    pub fn begin_register(&mut self) -> u64 {
+        if self.register_consumed || self.is_bound() {
+            *self = Self::new();
+        }
+        self.register_request_id()
+    }
+
     /// Get the next application request ID and advance the counter.
     ///
     /// Returns 1, 2, 3, ... (request 0 is reserved for register).
@@ -215,6 +232,35 @@ mod tests {
         let _ = session.register_request_id();
     }
 
+    #[test]
+    fn begin_register_on_fresh_session_keeps_client_id() {
+        let mut session = ConsensusSession::with_client_id(7);
+        assert_eq!(session.begin_register(), 0);
+        // The first registration keeps the session's client id; only a re-login
+        // re-arms with a fresh one.
+        assert_eq!(session.client_id(), 7);
+        assert!(!session.is_bound());
+    }
+
+    #[test]
+    fn begin_register_re_arms_after_consume_without_bind() {
+        // A prior register that never bound (failed login / dropped connection
+        // before the retry) must not panic the next login.
+        let mut session = ConsensusSession::with_client_id(1);
+        let _ = session.begin_register();
+        assert_eq!(session.begin_register(), 0);
+        assert!(!session.is_bound());
+    }
+
+    #[test]
+    fn begin_register_re_arms_after_bind() {
+        let mut session = ConsensusSession::with_client_id(1);
+        let _ = session.begin_register();
+        session.bind(42);
+        assert_eq!(session.begin_register(), 0);
+        assert!(!session.is_bound());
+    }
+
     #[test]
     #[should_panic(expected = "next_request_id called before bind")]
     fn next_request_id_before_bind_panics() {
diff --git a/core/sdk/src/vsr.rs b/core/sdk/src/vsr.rs
index 54fdb273c..d1e9ab859 100644
--- a/core/sdk/src/vsr.rs
+++ b/core/sdk/src/vsr.rs
@@ -37,9 +37,8 @@ use iggy_binary_protocol::requests::consumer_offsets::{
 };
 use iggy_binary_protocol::requests::messages::SendMessagesHeader;
 use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
-use iggy_binary_protocol::version::IGGY_PROTOCOL_VERSION;
 use iggy_binary_protocol::{WireIdentifier, WirePartitioning};
-use iggy_common::IggyError;
+use iggy_common::{IggyError, eviction_reason_to_error};
 
 const NON_REPLICATED_CODE_RANGE: std::ops::Range<usize> = 0..4;
 
@@ -72,7 +71,12 @@ pub(crate) fn encode_request_header(
 ) -> Result<(RequestHeader, usize), IggyError> {
     let (operation, request_id, session_id) = match code {
         LOGIN_REGISTER_CODE | LOGIN_REGISTER_WITH_PAT_CODE => {
-            (Operation::Register, session.register_request_id(), 0)
+            // A re-login reuses this `ConsensusSession`; `begin_register`
+            // re-arms it (fresh session) so the Register encodes cleanly instead
+            // of tripping the one-shot register guard. Safe under the transport
+            // stream lock: it is lockstep (one request in flight), so no
+            // in-flight request observes the reset.
+            (Operation::Register, session.begin_register(), 0)
         }
         _ => {
             let operation = operation_for_code(code)?;
@@ -114,7 +118,7 @@ pub(crate) fn encode_request_header(
         .checked_add(payload.len())
         .ok_or(IggyError::InvalidConfiguration)?;
     let size = u32::try_from(total_size).map_err(|_| IggyError::InvalidConfiguration)?;
-    let mut reserved = [0; 56];
+    let mut reserved = [0; 52];
     if operation == Operation::NonReplicated {
         reserved[NON_REPLICATED_CODE_RANGE].copy_from_slice(&code.to_le_bytes());
     }
@@ -179,6 +183,9 @@ pub(crate) fn decode_response(response: Bytes) -> Result<Bytes, IggyError> {
             if response.len() < total_size {
                 return Err(IggyError::InvalidCommand);
             }
+            if let Some(error) = read_reply_status(header_bytes) {
+                return Err(error);
+            }
             let operation = read_operation(header_bytes)?;
             split_metadata_result(operation, response.slice(HEADER_SIZE..total_size))
         }
@@ -206,6 +213,9 @@ pub(crate) fn decode_response_split(
             if body.len() < expected_body {
                 return Err(IggyError::InvalidCommand);
             }
+            if let Some(error) = read_reply_status(header_bytes) {
+                return Err(error);
+            }
             let operation = read_operation(header_bytes)?;
             split_metadata_result(operation, body.slice(..expected_body))
         }
@@ -213,6 +223,24 @@ pub(crate) fn decode_response_split(
     }
 }
 
+/// Peek `ReplyHeader.status`, the pre-commit deny channel shared by every deny
+/// frame: dispatch-time denies (authorization and a malformed user-password
+/// body) via `build_deny_reply`, and partition-primary admission rejects via
+/// `consensus::build_deny_reply_from_request`. Read before any body decode:
+/// a nonzero status maps to its [`IggyError`] and fails the request, an empty
+/// or partial body notwithstanding. `None` (status 0) lets the reply flow to
+/// the body/result-section decode. Read by wire offset for the same
+/// misalignment reason as [`read_operation`]; a committed metadata reply stamps
+/// 0 here and carries its business rejection in the result section (see
+/// `split_metadata_result`).
+fn read_reply_status(header_bytes: &[u8; HEADER_SIZE]) -> Option<IggyError> {
+    const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
+    let mut bytes = [0u8; 4];
+    bytes.copy_from_slice(&header_bytes[STATUS_OFFSET..STATUS_OFFSET + 4]);
+    let status = u32::from_le_bytes(bytes);
+    (status != 0).then(|| IggyError::from_code(status))
+}
+
 /// Read the [`Operation`] discriminant from a reply header by wire offset.
 /// Mirrors [`peek_command`]: a full `ReplyHeader` struct cast is avoided
 /// because the response buffers are not 16-aligned, so the leading `u128`
@@ -295,26 +323,11 @@ fn decode_eviction(header_bytes: &[u8; HEADER_SIZE]) -> IggyError {
     ) else {
         return IggyError::Unauthenticated;
     };
-    match reason {
-        EvictionReason::InvalidCredentials => IggyError::InvalidCredentials,
-        EvictionReason::InvalidToken => IggyError::InvalidPersonalAccessToken,
-        EvictionReason::UserInactive
-        | EvictionReason::SessionError
-        | EvictionReason::NoSession
-        | EvictionReason::SessionTooLow
-        | EvictionReason::SessionReleaseMismatch => IggyError::Unauthenticated,
-        EvictionReason::StaleClient => IggyError::StaleClient,
-        EvictionReason::IncompatibleProtocol => {
-            let server_max = read_window_field(header_bytes, VERSION_OFFSET);
-            let server_min = read_window_field(header_bytes, VERSION_MIN_OFFSET);
-            if server_min == 0 || server_max < server_min {
-                return IggyError::Unauthenticated;
-            }
-            IggyError::IncompatibleProtocolVersion(IGGY_PROTOCOL_VERSION, server_min, server_max)
-        }
-        EvictionReason::MalformedLogin => IggyError::InvalidFormat,
-        _ => IggyError::InvalidCommand,
-    }
+    eviction_reason_to_error(
+        reason,
+        read_window_field(header_bytes, VERSION_OFFSET),
+        read_window_field(header_bytes, VERSION_MIN_OFFSET),
+    )
 }
 
 fn read_window_field(header_bytes: &[u8; HEADER_SIZE], offset: usize) -> u32 {
@@ -450,6 +463,7 @@ mod tests {
     use iggy_binary_protocol::requests::messages::SendMessagesHeader;
     use iggy_binary_protocol::requests::streams::CreateStreamRequest;
     use iggy_binary_protocol::requests::users::LoginRegisterRequest;
+    use iggy_binary_protocol::version::IGGY_PROTOCOL_VERSION;
     use iggy_binary_protocol::{ClientVersionInfo, WireEncode, WireName};
     use secrecy::SecretString;
 
@@ -485,6 +499,35 @@ mod tests {
         assert_eq!(header.namespace, METADATA_CONSENSUS_NAMESPACE);
     }
 
+    #[test]
+    fn second_register_on_bound_session_re_arms_instead_of_panicking() {
+        let request = LoginRegisterRequest {
+            version_info: ClientVersionInfo {
+                protocol_version: IGGY_PROTOCOL_VERSION,
+                sdk_name: WireName::new("rust-sdk").unwrap(),
+                sdk_version: WireName::new("1.0.0").unwrap(),
+            },
+            username: WireName::new("admin").unwrap(),
+            password: SecretString::from("secret"),
+            client_context: None,
+        };
+
+        let mut session = ConsensusSession::with_client_id(7);
+        encode_contiguous_request(&mut session, LOGIN_REGISTER_CODE, &request.to_bytes()).unwrap();
+        session.bind(42);
+
+        // A second login on the same bound session must encode a fresh Register
+        // (request 0, session 0), not panic in the one-shot register guard.
+        let bytes =
+            encode_contiguous_request(&mut session, LOGIN_REGISTER_CODE, &request.to_bytes())
+                .unwrap();
+        let header = decode_request_header(&bytes);
+        assert_eq!(header.operation, Operation::Register);
+        assert_eq!(header.request, 0);
+        assert_eq!(header.session, 0);
+        assert!(!session.is_bound());
+    }
+
     #[test]
     fn eviction_incompatible_protocol_decodes_to_typed_error() {
         use iggy_binary_protocol::version::IGGY_PROTOCOL_VERSION_MIN;
@@ -533,6 +576,38 @@ mod tests {
         }
     }
 
+    #[test]
+    fn reply_with_nonzero_status_surfaces_as_typed_error() {
+        // A dispatch-time authorization denial rides `ReplyHeader.status`; the
+        // decode funnel surfaces it as the typed error before any body decode,
+        // even though the deny body is empty.
+        let header = ReplyHeader {
+            command: Command2::Reply,
+            size: HEADER_SIZE as u32,
+            status: IggyError::Unauthorized.as_code(),
+            ..Default::default()
+        };
+        let mut buf = [0u8; HEADER_SIZE];
+        buf.copy_from_slice(bytemuck::bytes_of(&header));
+        let result = decode_response_split(&buf, Bytes::new());
+        assert!(matches!(result, Err(IggyError::Unauthorized)));
+    }
+
+    #[test]
+    fn reply_with_zero_status_passes_body_through() {
+        // status 0 is the ok channel: a non-metadata reply returns its body.
+        let header = ReplyHeader {
+            command: Command2::Reply,
+            operation: Operation::NonReplicated,
+            size: (HEADER_SIZE + 3) as u32,
+            ..Default::default()
+        };
+        let mut buf = [0u8; HEADER_SIZE];
+        buf.copy_from_slice(bytemuck::bytes_of(&header));
+        let out = decode_response_split(&buf, Bytes::from_static(b"abc")).unwrap();
+        assert_eq!(&out[..], b"abc");
+    }
+
     #[test]
     fn replicated_request_increments_request_counter() {
         let mut session = ConsensusSession::with_client_id(42);
diff --git a/core/server-ng/Cargo.toml b/core/server-ng/Cargo.toml
index 3de31cf53..9869a1b96 100644
--- a/core/server-ng/Cargo.toml
+++ b/core/server-ng/Cargo.toml
@@ -155,6 +155,7 @@ strum = { workspace = true }
 sysinfo = { workspace = true }
 tempfile = { workspace = true }
 thiserror = { workspace = true }
+tokio = { workspace = true }
 toml = { workspace = true }
 tower-http = { workspace = true }
 tracing = { workspace = true }
diff --git a/core/server-ng/server-ng.http b/core/server-ng/server.http
similarity index 96%
rename from core/server-ng/server-ng.http
rename to core/server-ng/server.http
index 451bca8dd..b0be45ca8 100644
--- a/core/server-ng/server-ng.http
+++ b/core/server-ng/server.http
@@ -34,14 +34,11 @@
 @user1_username = user1
 @user1_password = secret
 @access_token = secret
-@root_id = 1
-@user1_id = 2
+@root_id = 0
+@user1_id = 1
 @pat_name = dev_token
 @pat_raw_token = secret
 
-###
-GET {{url}}
-
 ###
 GET {{url}}/ping
 
@@ -63,11 +60,18 @@ Content-Type: application/json
 }
 
 ###
-GET {{url}}/metrics
+GET {{url}}/stats
+Authorization: Bearer {{access_token}}
 
 ###
-GET {{url}}/stats
+POST {{url}}/snapshot
 Authorization: Bearer {{access_token}}
+Content-Type: application/json
+
+{
+  "compression": "Deflated",
+  "snapshot_types": ["All"]
+}
 
 ###
 GET {{url}}/cluster/metadata
@@ -81,14 +85,6 @@ Authorization: Bearer {{access_token}}
 GET {{url}}/clients/{{client_id}}
 Authorization: Bearer {{access_token}}
 
-###
-POST {{url}}/users/refresh-token
-Content-Type: application/json
-
-{
-  "token": "{{access_token}}"
-}
-
 ###
 DELETE {{url}}/users/logout
 Authorization: Bearer {{access_token}}
@@ -154,7 +150,7 @@ Content-Type: application/json
       "send_messages": true
     },
     "streams": {
-      "1": {
+      "0": {
         "manage_stream": false,
         "read_stream": true,
         "manage_topics": false,
@@ -162,7 +158,7 @@ Content-Type: application/json
         "poll_messages": true,
         "send_messages": true,
         "topics": {
-          "1": {
+          "0": {
             "manage_topic": false,
             "read_topic": true,
             "poll_messages": true,
@@ -193,9 +189,6 @@ Content-Type: application/json
   "expiry": 1000
 }
 
-###
-
-
 ###
 DELETE {{url}}/personal-access-tokens/{{pat_name}}
 Authorization: Bearer {{access_token}}
@@ -290,7 +283,7 @@ Authorization: Bearer {{access_token}}
 
 ###
 ### Delete segments
-DELETE {{url}}/streams/1/topics/1/partitions/1?segments_count=3
+DELETE {{url}}/streams/{{stream_id}}/topics/{{topic_id}}/partitions/{{partition_id}}?segments_count=3
 Authorization: Bearer {{access_token}}
 
 ###
diff --git a/core/server-ng/src/auth.rs b/core/server-ng/src/auth.rs
index 7e6c4627b..118aa674c 100644
--- a/core/server-ng/src/auth.rs
+++ b/core/server-ng/src/auth.rs
@@ -37,8 +37,26 @@ use metadata::impls::metadata::StreamsFrontend;
 use server_common::crypto;
 use std::cell::RefCell;
 use std::rc::Rc;
+use std::sync::LazyLock;
 use tracing::warn;
 
+/// A well-formed Argon2 hash to verify against on the unknown-user login
+/// branch, so a missing username costs the same single `verify_password` a real
+/// user's wrong-password branch costs. Closes the username-existence timing
+/// oracle without changing the returned error. On the unknown-username branch
+/// the verify result is discarded, so even a request presenting the exact dummy
+/// plaintext cannot authenticate; the literal only needs to be a fixed input
+/// hashed by the same Argon2 hasher real users use, so the dummy verify runs an
+/// identical Argon2 KDF.
+static DUMMY_PASSWORD_HASH: LazyLock<String> =
+    LazyLock::new(|| crypto::hash_password("http-login-timing-guard"));
+
+/// Pay the one-time Argon2 cost of [`DUMMY_PASSWORD_HASH`] at boot instead of
+/// inside the first unknown-username login request.
+pub(crate) fn warm_dummy_password_hash() {
+    LazyLock::force(&DUMMY_PASSWORD_HASH);
+}
+
 pub(crate) fn verify_login_credentials(
     shard: &Rc<ServerNgShard>,
     username: &str,
@@ -54,16 +72,25 @@ pub(crate) fn verify_login_credentials(
         return Err(LoginRegisterError::InvalidCredentials);
     }
     shard.plane.metadata().mux_stm.users().read(|users| {
-        let Some(user_id) = users.index.get(username).copied() else {
+        let user = users
+            .index
+            .get(username)
+            .copied()
+            .and_then(|user_id| users.items.get(user_id as usize));
+        let Some(user) = user else {
+            // Constant-cost path: verify against a dummy hash so a missing
+            // username is indistinguishable by response timing from a wrong
+            // password (both return InvalidCredentials).
+            let _ = crypto::verify_password(password, DUMMY_PASSWORD_HASH.as_str());
             return Err(LoginRegisterError::InvalidCredentials);
         };
-        let Some(user) = users.items.get(user_id as usize) else {
-            return Err(LoginRegisterError::InvalidCredentials);
-        };
-        if user.status != UserStatus::Active {
-            return Err(LoginRegisterError::UserInactive);
-        }
-        if !crypto::verify_password(password, user.password_hash.as_ref()) {
+        // Verify before the status check and collapse inactive to
+        // InvalidCredentials: an inactive account must answer exactly like a
+        // wrong password (same error, same Argon2 cost), or login could probe
+        // which accounts exist but are disabled.
+        if !crypto::verify_password(password, user.password_hash.as_ref())
+            || user.status != UserStatus::Active
+        {
             return Err(LoginRegisterError::InvalidCredentials);
         }
         Ok(user.id)
@@ -74,6 +101,17 @@ pub(crate) fn verify_pat_credentials(
     shard: &Rc<ServerNgShard>,
     token: &str,
 ) -> Result<u32, LoginRegisterError> {
+    verify_pat_credentials_with_expiry(shard, token).map(|(user_id, _)| user_id)
+}
+
+/// Like [`verify_pat_credentials`] but also surfaces the token's expiry (unix
+/// seconds, `u64::MAX` when the PAT never expires). The HTTP extractor keys a
+/// per-token VSR session table on this expiry for lazy eviction; the wire and
+/// login paths only need the user id and go through [`verify_pat_credentials`].
+pub(crate) fn verify_pat_credentials_with_expiry(
+    shard: &Rc<ServerNgShard>,
+    token: &str,
+) -> Result<(u32, u64), LoginRegisterError> {
     let token_hash = PersonalAccessToken::hash_token(token);
     let now = IggyTimestamp::now();
     shard.plane.metadata().mux_stm.users().read(|users| {
@@ -98,7 +136,12 @@ pub(crate) fn verify_pat_credentials(
         if user.status != UserStatus::Active {
             return Err(LoginRegisterError::UserInactive);
         }
-        Ok(user.id)
+        // `expiry_at == None` is a never-expiring PAT; map it to `u64::MAX` so
+        // the HTTP session table never expiry-evicts its entry.
+        let expiry = pat
+            .expiry_at
+            .map_or(u64::MAX, |expiry_at| expiry_at.to_secs());
+        Ok((user.id, expiry))
     })
 }
 
@@ -144,7 +187,7 @@ pub(crate) async fn complete_login_register(
     // optimistic Authenticated transition, so a transient submit failure
     // needs no rollback -- the connection stays Connected and the SDK
     // read-timeout replays.
-    let session = match submit_register_on_owner(shard, vsr_client_id).await {
+    let session = match submit_register_on_owner(shard, vsr_client_id, user_id).await {
         Ok(session) => session,
         Err(error) => {
             return Err(LoginRegisterError::Transient(error));
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index cc75df120..ca7710148 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -15,12 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use crate::auth::warm_dummy_password_hash;
+use crate::cluster_meta::ClusterRoster;
 use crate::config_writer::write_current_config;
 use crate::dispatch::{
     make_client_request_handler, make_deferred_client_request_handler,
     make_deferred_replica_message_handler, make_list_clients_handler, make_metadata_submit_handler,
     make_partition_read_handler,
 };
+use crate::http;
 use crate::partition_helpers::{
     configure_consumer_offsets, ensure_initial_segment, validate_namespace_bounds,
 };
@@ -519,6 +522,7 @@ pub fn bootstrap(
     config: ServerNgConfig,
     current_replica_id: Option<u8>,
 ) -> Result<ShardHandles, ServerNgError> {
+    warm_dummy_password_hash();
     let allocator = ShardAllocator::new(&config.system.sharding.cpu_allocation)
         .map_err(ServerNgError::ShardAllocator)?;
     let assignments = allocator
@@ -1048,7 +1052,8 @@ async fn shard_main(
         let coord = shard
             .coordinator()
             .expect("shard 0 always has a coordinator attached by the builder");
-        let on_client_request = make_client_request_handler(&shard, &sessions);
+        let on_client_request =
+            make_client_request_handler(&shard, &sessions, Arc::clone(&config.system));
         let (accepted_replica, dialed_replica) =
             make_replica_delegation_fns(Rc::clone(&coord), &bus);
         let accepted_client = make_shard_zero_client_accept_fns(coord, &bus, on_client_request);
@@ -1284,6 +1289,33 @@ fn spawn_shutdown_watchdog(
     watchdog.detach();
 }
 
+/// Copy the configured cluster roster plus this node's own client ports into
+/// the shared [`ClusterRoster`] so the binary `GetClusterMetadata` read serves
+/// the real topology. `self_*` back only the cluster-disabled self-synthesis;
+/// the HTTP port is read from config since HTTP binds outside this topology.
+fn build_cluster_roster(config: &ServerNgConfig, topology: &TcpTopology) -> ClusterRoster {
+    let http_port = if config.http.enabled {
+        parse_socket_addr("http.address", &config.http.address)
+            .ok()
+            .map(|addr| addr.port())
+    } else {
+        None
+    };
+    ClusterRoster {
+        enabled: config.cluster.enabled,
+        name: config.cluster.name.clone(),
+        nodes: config.cluster.nodes.clone(),
+        self_ip: topology.client_listen_addr.ip().to_string(),
+        self_ports: configs::cluster::TransportPorts {
+            tcp: Some(topology.client_listen_addr.port()),
+            quic: topology.quic_listen_addr.map(|addr| addr.port()),
+            http: http_port,
+            websocket: topology.ws_listen_addr.map(|addr| addr.port()),
+            tcp_replica: None,
+        },
+    }
+}
+
 #[allow(clippy::too_many_arguments, clippy::too_many_lines)]
 async fn build_shard_for_thread(
     shard_id: u16,
@@ -1401,10 +1433,19 @@ async fn build_shard_for_thread(
     let shard_handle = Rc::new(RefCell::new(None));
     // One per-shard SessionManager, shared by the client-request handler
     // (binds sessions) and the get_clients handler (reads them). Created
-    // here so both wirings reference the same instance.
+    // here so both wirings reference the same instance. It also carries this
+    // shard's cluster roster for the pre-auth GetClusterMetadata read.
     let sessions = Rc::new(RefCell::new(SessionManager::new()));
+    sessions
+        .borrow_mut()
+        .set_cluster_roster(Rc::new(build_cluster_roster(config, topology)));
     let on_replica_message = make_deferred_replica_message_handler(&shard_handle);
-    let on_client_request = make_deferred_client_request_handler(&bus, &shard_handle, &sessions);
+    let on_client_request = make_deferred_client_request_handler(
+        &bus,
+        &shard_handle,
+        &sessions,
+        Arc::clone(&config.system),
+    );
     let on_metadata_submit = make_metadata_submit_handler(&shard_handle);
     let on_list_clients = make_list_clients_handler(&sessions);
     let on_partition_read = make_partition_read_handler(&shard_handle);
@@ -1816,7 +1857,7 @@ async fn start_tcp_runtime(
     accepted_clients: LocalClientAcceptFns,
 ) -> Result<(), ServerNgError> {
     if config.tcp.enabled && !config.tcp.tls.enabled {
-        return start_via_replica_io(
+        start_via_replica_io(
             shard,
             config,
             topology,
@@ -1824,18 +1865,45 @@ async fn start_tcp_runtime(
             dialed_replica,
             accepted_clients,
         )
-        .await;
+        .await?;
+    } else {
+        start_manual_runtime(
+            shard,
+            config,
+            topology,
+            accepted_replica,
+            dialed_replica,
+            accepted_clients,
+        )
+        .await?;
     }
 
-    start_manual_runtime(
-        shard,
-        config,
-        topology,
-        accepted_replica,
-        dialed_replica,
-        accepted_clients,
-    )
-    .await
+    // HTTP is served over TCP but sits outside the replica_io / manual client
+    // reactor, so it binds independently. Shard-0 gating comes from the sole
+    // caller of this function.
+    if config.http.enabled {
+        let http_addr = parse_socket_addr("http.address", &config.http.address)?;
+        let self_ports = configs::cluster::TransportPorts {
+            tcp: config
+                .tcp
+                .enabled
+                .then(|| topology.client_listen_addr.port()),
+            quic: topology.quic_listen_addr.map(|addr| addr.port()),
+            websocket: topology.ws_listen_addr.map(|addr| addr.port()),
+            ..Default::default()
+        };
+        http::start(
+            shard,
+            http_addr,
+            &config.http,
+            &config.cluster,
+            Arc::clone(&config.system),
+            self_ports,
+        )
+        .await?;
+    }
+
+    Ok(())
 }
 
 // ws/wss bindings intentionally mirror the transport names (same convention as
diff --git a/core/server-ng/src/cluster_meta.rs b/core/server-ng/src/cluster_meta.rs
new file mode 100644
index 000000000..28c290187
--- /dev/null
+++ b/core/server-ng/src/cluster_meta.rs
@@ -0,0 +1,128 @@
+// 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.
+
+//! Single source of cluster-metadata assembly.
+//!
+//! Both the HTTP `GET /cluster/metadata` handler and the binary
+//! `GetClusterMetadata` reply build the same view: one entry per configured
+//! roster node with its client ports, the current VSR primary marked
+//! `Leader` and the rest `Follower`. The roster is config-only data, so it is
+//! reported whenever the cluster is enabled; the leader marking is the only
+//! part that needs live consensus, and it is passed in as `primary_index` so
+//! each caller derives it from its own on-shard view (`None` when the serving
+//! shard has no consensus - peer shards - in which case no node is marked
+//! leader, but the full roster is still returned). The self-synthesized single
+//! node is the cluster-disabled fallback, shared by both callers.
+
+use configs::cluster::{ClusterNodeConfig, TransportPorts};
+use iggy_common::{
+    ClusterMetadata, ClusterNode, ClusterNodeRole, ClusterNodeStatus, TransportEndpoints,
+};
+
+/// Node name reported for the synthesized self node when no roster applies.
+const SELF_NODE_NAME: &str = "iggy-node";
+
+/// Cluster name reported when no roster is configured, matching the legacy
+/// single-node label.
+const SINGLE_NODE_CLUSTER_NAME: &str = "single-node";
+
+/// Config-derived cluster topology reported by cluster-metadata reads.
+///
+/// Copied out of `ClusterConfig` at listener/shard start so both handlers stay
+/// synchronous and never borrow live config. `self_*` describe this node and
+/// back the cluster-disabled self-synthesis only.
+pub struct ClusterRoster {
+    pub enabled: bool,
+    pub name: String,
+    pub nodes: Vec<ClusterNodeConfig>,
+    /// This node's own address, reported for the synthesized self node.
+    pub self_ip: String,
+    /// This node's own client ports for the same self node (`None` = transport
+    /// disabled).
+    pub self_ports: TransportPorts,
+}
+
+impl ClusterRoster {
+    /// A cluster-disabled roster with no self address. Used as the pre-bootstrap
+    /// default before the real roster is installed; [`Self::cluster_metadata`]
+    /// on it synthesizes a bare single node.
+    pub fn disabled() -> Self {
+        Self {
+            enabled: false,
+            name: String::new(),
+            nodes: Vec::new(),
+            self_ip: String::new(),
+            self_ports: TransportPorts::default(),
+        }
+    }
+
+    /// Build the [`ClusterMetadata`] view. With a configured, non-empty roster
+    /// emit one node per entry, marking the node at `primary_index` the leader
+    /// and the rest followers; `None` (no on-shard consensus) leaves every node
+    /// a follower. Otherwise synthesize the single self node as the sole
+    /// leader.
+    pub fn cluster_metadata(&self, primary_index: Option<u8>) -> ClusterMetadata {
+        if self.enabled && !self.nodes.is_empty() {
+            let nodes = self
+                .nodes
+                .iter()
+                .map(|node| ClusterNode {
+                    name: node.name.clone(),
+                    ip: node.ip.clone(),
+                    endpoints: ports_to_endpoints(&node.ports),
+                    role: role_for(primary_index, node.replica_id),
+                    status: ClusterNodeStatus::Healthy,
+                })
+                .collect();
+            ClusterMetadata {
+                name: self.name.clone(),
+                nodes,
+            }
+        } else {
+            self.self_metadata()
+        }
+    }
+
+    fn self_metadata(&self) -> ClusterMetadata {
+        ClusterMetadata {
+            name: SINGLE_NODE_CLUSTER_NAME.to_owned(),
+            nodes: vec![ClusterNode {
+                name: SELF_NODE_NAME.to_owned(),
+                ip: self.self_ip.clone(),
+                endpoints: ports_to_endpoints(&self.self_ports),
+                role: ClusterNodeRole::Leader,
+                status: ClusterNodeStatus::Healthy,
+            }],
+        }
+    }
+}
+
+const fn role_for(primary_index: Option<u8>, replica_id: u8) -> ClusterNodeRole {
+    match primary_index {
+        Some(primary) if primary == replica_id => ClusterNodeRole::Leader,
+        _ => ClusterNodeRole::Follower,
+    }
+}
+
+fn ports_to_endpoints(ports: &TransportPorts) -> TransportEndpoints {
+    TransportEndpoints::new(
+        ports.tcp.unwrap_or(0),
+        ports.quic.unwrap_or(0),
+        ports.http.unwrap_or(0),
+        ports.websocket.unwrap_or(0),
+    )
+}
diff --git a/core/server-ng/src/dispatch.rs b/core/server-ng/src/dispatch.rs
index 552742b78..5d510860a 100644
--- a/core/server-ng/src/dispatch.rs
+++ b/core/server-ng/src/dispatch.rs
@@ -22,59 +22,76 @@
 //! run consensus on shard 0, and the login/register, logout, and
 //! non-replicated request handlers.
 
+mod authz;
+
 use crate::auth::{
     complete_login_register, send_login_failure_reply, surface_login_failure,
     verify_login_credentials, verify_pat_credentials,
 };
 use crate::bootstrap::{ServerNgShard, ServerNgShardHandle};
+use crate::cluster_meta::ClusterRoster;
 use crate::consumer_group::{
     maybe_rewrite_consumer_group_request, maybe_rewrite_consumer_offset_request,
 };
+use crate::dispatch::authz::{
+    authorize_default_read, authorize_partition_op, authorize_partition_read, authorize_uid,
+    send_non_replicated_deny, send_partition_deny_reply,
+};
 use crate::login_register::LoginRegisterError;
 use crate::pat::maybe_rewrite_pat_request;
 use crate::responses::{
-    NonReplicatedResponse, build_consumer_offset_body, build_empty_reply, build_get_me_response,
-    build_get_personal_access_tokens_response, build_non_replicated_response,
-    build_polled_messages_body, build_raw_pat_reply, connected_client_to_response,
-    current_metadata_commit, resolve_partition_namespace, resolve_partition_request_namespace,
+    NonReplicatedResponse, build_consumer_offset_body, build_deny_reply, build_empty_reply,
+    build_get_me_response, build_get_personal_access_tokens_response,
+    build_non_replicated_response, build_polled_messages_body, build_raw_pat_reply,
+    connected_client_to_response, current_metadata_commit, resolve_partition_namespace,
+    resolve_partition_request_namespace,
 };
 use crate::session_manager::SessionManager;
+use crate::snapshot;
 use crate::users::maybe_rewrite_user_password_request;
-use crate::wire::request_body;
+use crate::wire::{request_body, usize_to_u32};
 use bytes::Bytes;
+use configs::system::SystemConfig;
 use consensus::{
-    EvictionContext, MetadataHandle, PartitionsHandle, build_eviction_message,
+    Consensus, EvictionContext, MetadataHandle, PartitionsHandle, build_eviction_message,
     build_incompatible_protocol_eviction_message,
 };
 use iggy_binary_protocol::codes::{
     GET_CLIENT_CODE, GET_CLIENTS_CODE, GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE,
-    GET_ME_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE, PING_CODE, POLL_MESSAGES_CODE,
-    SYNC_CONSUMER_GROUP_CODE,
+    GET_ME_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE, GET_SNAPSHOT_FILE_CODE, LOGIN_USER_CODE,
+    LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, PING_CODE, POLL_MESSAGES_CODE, SYNC_CONSUMER_GROUP_CODE,
 };
 use iggy_binary_protocol::primitives::consumer::WireConsumer;
 use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
 use iggy_binary_protocol::requests::consumer_groups::SyncConsumerGroupRequest;
-use iggy_binary_protocol::requests::consumer_offsets::GetConsumerOffsetRequest;
+use iggy_binary_protocol::requests::consumer_offsets::{
+    GetConsumerOffsetRequest, StoreConsumerOffset2Request,
+};
 use iggy_binary_protocol::requests::messages::PollMessagesRequest;
 use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
 use iggy_binary_protocol::requests::system::get_client::GetClientRequest;
+use iggy_binary_protocol::requests::system::get_snapshot::GetSnapshotRequest;
 use iggy_binary_protocol::requests::users::{LoginRegisterRequest, LoginRegisterWithPatRequest};
 use iggy_binary_protocol::responses::clients::client_response::ConsumerGroupInfoResponse;
 use iggy_binary_protocol::responses::clients::get_client::ClientDetailsResponse;
 use iggy_binary_protocol::responses::clients::get_clients::GetClientsResponse;
 use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
+use iggy_binary_protocol::responses::system::get_snapshot::GetSnapshotResponse;
 use iggy_binary_protocol::{
-    ClientVersionInfo, EvictionReason, GenericHeader, KIND_CONSUMER_GROUP, Operation,
-    ProtocolVersion, RequestHeader, WireDecode, WireEncode, is_protocol_compatible,
+    AckLevel, ClientVersionInfo, Command2, EvictionReason, GenericHeader, HEADER_SIZE,
+    KIND_CONSUMER_GROUP, Operation, ProtocolVersion, RequestHeader, WireDecode, WireEncode,
+    WireIdentifier, is_protocol_compatible,
 };
-use iggy_common::{IggyError, PollingStrategy};
+use iggy_common::{IggyError, PollingStrategy, SnapshotCompression, SystemSnapshotType};
 use message_bus::client_listener::RequestHandler;
+use message_bus::framing::MAX_MESSAGE_SIZE;
 use message_bus::replica::listener::MessageHandler;
-use message_bus::{IggyMessageBus, MessageBus};
+use message_bus::{AUTO_COMMIT_CLIENT_ID, IggyMessageBus, MessageBus};
 use metadata::impls::metadata::{
     MetadataSubmitError, StreamsFrontend, build_truncate_partition_client_message,
 };
-use partitions::{PollPlan, PollingArgs, PollingConsumer};
+use metadata::permissioner::Permissioner;
+use partitions::{AutoCommitApplied, PollPlan, PollingArgs, PollingConsumer};
 use secrecy::ExposeSecret;
 use server_common::Message;
 use server_common::sharding::IggyNamespace;
@@ -86,6 +103,7 @@ use shard::{
 use std::cell::RefCell;
 use std::collections::{HashMap, HashSet, VecDeque};
 use std::rc::Rc;
+use std::sync::Arc;
 use tracing::warn;
 
 pub(crate) type ClientRequestQueues = Rc<RefCell<HashMap<u128, VecDeque<Message<GenericHeader>>>>>;
@@ -94,6 +112,7 @@ pub(crate) type ActiveClientRequests = Rc<RefCell<HashSet<u128>>>;
 pub(crate) fn make_client_request_handler(
     shard: &Rc<ServerNgShard>,
     sessions: &Rc<RefCell<SessionManager>>,
+    system_config: Arc<SystemConfig>,
 ) -> RequestHandler {
     let shard = Rc::clone(shard);
     let sessions = Rc::clone(sessions);
@@ -115,6 +134,7 @@ pub(crate) fn make_client_request_handler(
         enqueue_client_request(
             Rc::clone(&shard),
             Rc::clone(&sessions),
+            Arc::clone(&system_config),
             Rc::clone(&queues),
             Rc::clone(&active),
             client_id,
@@ -166,10 +186,13 @@ pub(crate) fn make_partition_read_handler(
                         let _ = reply.try_send(PartitionReadReply::NotFound);
                     }
                     Some(plan) if plan.needs_off_pump_io() => {
-                        spawn_poll_io(namespace, plan, reply);
+                        spawn_poll_io(Rc::clone(&shard), namespace, plan, reply);
                     }
                     Some(plan) => {
-                        let (fragments, current_offset) = plan.execute_resident();
+                        let (fragments, current_offset, auto_commit) = plan.execute_resident();
+                        if let Some(applied) = auto_commit {
+                            submit_auto_commit(&shard, namespace, &applied);
+                        }
                         let _ = reply.try_send(PartitionReadReply::Poll {
                             fragments,
                             current_offset,
@@ -217,18 +240,20 @@ pub(crate) fn make_partition_read_handler(
     })
 }
 
-/// Spawn the off-pump leg of a partition poll: disk read + auto-commit
-/// persist/apply on the OWNED plan (disk descriptors, resident-tail `Frozen`
-/// clones, `Arc` offset map), then send the reply. Holds no partition
-/// reference, so it is sound concurrently with the pump's `&mut` writes.
+/// Spawn the off-pump leg of a partition poll: disk read + auto-commit apply on
+/// the OWNED plan (disk descriptors, resident-tail `Frozen` clones, `Arc` offset
+/// map), then replicate the auto-committed offset and send the reply. Holds no
+/// partition reference across the IO, so it is sound concurrently with the
+/// pump's `&mut` writes; the auto-commit submit re-borrows synchronously after.
 fn spawn_poll_io(
+    shard: Rc<ServerNgShard>,
     namespace: IggyNamespace,
     plan: PollPlan,
     reply: shard::Sender<PartitionReadReply>,
 ) {
     compio::runtime::spawn(async move {
         let poll_started = std::time::Instant::now();
-        let (fragments, current_offset) = plan.execute().await;
+        let (fragments, current_offset, auto_commit) = plan.execute().await;
         let elapsed = poll_started.elapsed();
         if elapsed > std::time::Duration::from_secs(1) {
             warn!(
@@ -237,6 +262,10 @@ fn spawn_poll_io(
                 "slow partition poll; gather side may have timed out"
             );
         }
+        // Fire-and-forget: the poll reply is not gated on the offset commit.
+        if let Some(applied) = auto_commit {
+            submit_auto_commit(&shard, namespace, &applied);
+        }
         let _ = reply.try_send(PartitionReadReply::Poll {
             fragments,
             current_offset,
@@ -245,6 +274,121 @@ fn spawn_poll_io(
     .detach();
 }
 
+/// Replicate a poll's auto-committed offset through the partition consensus so
+/// it survives failover, mirroring the explicit `StoreConsumerOffset` path: the
+/// same op code, submitted onto the owning shard's own pipeline. Best-effort and
+/// fire-and-forget -- the poll reply never waits on it, and a full inbox drops
+/// the op at WARN rather than backpressuring the reply.
+///
+/// The partition plane admits writes on the primary only (it asserts so), and a
+/// poll is served on whichever node owns the namespace locally, which may be a
+/// backup. So gate on primary status here and drop at WARN otherwise; auto-commit
+/// is server-managed best-effort (at-least-once delivery), so a follower-served
+/// poll simply does not advance the durable offset.
+///
+/// Coalescing: an offset the partition's committed high-water already covers is
+/// dropped without a consensus op (the steady state for a re-poll of committed
+/// data, hence no log). The gate reads committed state only, so an offset that
+/// merely sits in flight keeps resubmitting until its covering op commits -- a
+/// dropped op self-heals on the next poll instead of being suppressed forever.
+fn submit_auto_commit(
+    shard: &Rc<ServerNgShard>,
+    namespace: IggyNamespace,
+    applied: &AutoCommitApplied,
+) {
+    enum AutoCommitGate {
+        Submit,
+        Covered,
+        NotPrimary,
+    }
+    let gate = shard
+        .plane
+        .partitions()
+        .with_partition(&namespace, |partition| {
+            let consensus = partition.consensus();
+            if !(consensus.is_primary() && consensus.is_normal() && !consensus.is_syncing()) {
+                AutoCommitGate::NotPrimary
+            } else if partition.is_auto_commit_offset_covered(
+                applied.kind,
+                applied.consumer_id,
+                applied.offset,
+            ) {
+                AutoCommitGate::Covered
+            } else {
+                AutoCommitGate::Submit
+            }
+        });
+    match gate {
+        Some(AutoCommitGate::Submit) => {}
+        Some(AutoCommitGate::Covered) => return,
+        Some(AutoCommitGate::NotPrimary) | None => {
+            warn!(
+                namespace_raw = namespace.inner(),
+                "auto-commit offset not replicated: partition not primary on this node (best-effort)"
+            );
+            return;
+        }
+    }
+    let message = match build_auto_commit_request(namespace, applied) {
+        Ok(message) => message,
+        Err(error) => {
+            warn!(
+                namespace_raw = namespace.inner(),
+                error = %error,
+                "failed to build auto-commit store-offset request"
+            );
+            return;
+        }
+    };
+    // Routes by namespace to this same (owning, primary) shard's inbox; the pump
+    // admits it next turn exactly like a client store. `dispatch` never blocks.
+    shard.dispatch(message.into_generic());
+}
+
+/// Build the synthetic `StoreConsumerOffset2` request for an auto-commit, keyed
+/// to the resolved numeric consumer/group id and stamped with the reserved
+/// [`AUTO_COMMIT_CLIENT_ID`] so the commit path skips the (unwaited) reply. The
+/// wire stream/topic ids are cosmetic here -- admission and apply key off the
+/// header namespace and the consumer id -- but are set from the namespace for a
+/// well-formed body. `ack` is `Quorum` so the offset actually replicates.
+fn build_auto_commit_request(
+    namespace: IggyNamespace,
+    applied: &AutoCommitApplied,
+) -> Result<Message<RequestHeader>, IggyError> {
+    let request = StoreConsumerOffset2Request {
+        consumer: WireConsumer {
+            kind: applied.kind.as_code(),
+            id: WireIdentifier::Numeric(applied.consumer_id),
+        },
+        stream_id: WireIdentifier::Numeric(usize_to_u32(namespace.stream_id())?),
+        topic_id: WireIdentifier::Numeric(usize_to_u32(namespace.topic_id())?),
+        partition_id: Some(usize_to_u32(namespace.partition_id())?),
+        offset: applied.offset,
+        ack: AckLevel::Quorum,
+    };
+    let body = request.to_bytes();
+    let header_size = std::mem::size_of::<RequestHeader>();
+    let total_size = header_size + body.len();
+    let size = u32::try_from(total_size).map_err(|_| IggyError::InvalidConfiguration)?;
+    let mut message = Message::<RequestHeader>::new(total_size);
+    message.as_mut_slice()[header_size..].copy_from_slice(&body);
+    Ok(message.transmute_header(|_, header: &mut RequestHeader| {
+        *header = RequestHeader {
+            command: Command2::Request,
+            operation: Operation::StoreConsumerOffset2,
+            size,
+            client: AUTO_COMMIT_CLIENT_ID,
+            // The partition plane is sessionless (no `ClientTable` dedup); a
+            // nonzero session + request just satisfy the wire header
+            // validation.
+            session: 1,
+            request: 1,
+            namespace: namespace.inner(),
+            ..Default::default()
+        };
+    }))
+}
+
 pub(crate) fn make_deferred_replica_message_handler(
     shard_handle: &ServerNgShardHandle,
 ) -> MessageHandler {
@@ -260,6 +404,7 @@ pub(crate) fn make_deferred_client_request_handler(
     bus: &Rc<IggyMessageBus>,
     shard_handle: &ServerNgShardHandle,
     sessions: &Rc<RefCell<SessionManager>>,
+    system_config: Arc<SystemConfig>,
 ) -> RequestHandler {
     let shard_handle = Rc::clone(shard_handle);
     let sessions = Rc::clone(sessions);
@@ -279,6 +424,7 @@ pub(crate) fn make_deferred_client_request_handler(
     Rc::new(move |client_id, message| {
         let shard_handle = Rc::clone(&shard_handle);
         let sessions = Rc::clone(&sessions);
+        let system_config = Arc::clone(&system_config);
         let queues = Rc::clone(&queues);
         let active = Rc::clone(&active);
         queues
@@ -294,7 +440,7 @@ pub(crate) fn make_deferred_client_request_handler(
                 active.borrow_mut().remove(&client_id);
                 return;
             };
-            drain_client_requests(shard, sessions, queues, active, client_id).await;
+            drain_client_requests(shard, sessions, system_config, queues, active, client_id).await;
         })
         .detach();
     })
@@ -319,12 +465,13 @@ pub(crate) fn make_metadata_submit_handler(
             match submit {
                 shard::MetadataSubmit::Register {
                     vsr_client_id,
+                    user_id,
                     reply,
                 } => {
                     let session = shard
                         .plane
                         .metadata()
-                        .submit_register_in_process(vsr_client_id)
+                        .submit_register_in_process(vsr_client_id, user_id)
                         .await
                         .ok();
                     let _ = reply.try_send(session);
@@ -389,6 +536,7 @@ pub(crate) fn make_metadata_submit_handler(
 fn enqueue_client_request(
     shard: Rc<ServerNgShard>,
     sessions: Rc<RefCell<SessionManager>>,
+    system_config: Arc<SystemConfig>,
     queues: ClientRequestQueues,
     active: ActiveClientRequests,
     client_id: u128,
@@ -404,7 +552,7 @@ fn enqueue_client_request(
     }
 
     compio::runtime::spawn(async move {
-        drain_client_requests(shard, sessions, queues, active, client_id).await;
+        drain_client_requests(shard, sessions, system_config, queues, active, client_id).await;
     })
     .detach();
 }
@@ -413,6 +561,7 @@ fn enqueue_client_request(
 async fn drain_client_requests(
     shard: Rc<ServerNgShard>,
     sessions: Rc<RefCell<SessionManager>>,
+    system_config: Arc<SystemConfig>,
     queues: ClientRequestQueues,
     active: ActiveClientRequests,
     client_id: u128,
@@ -421,7 +570,7 @@ async fn drain_client_requests(
         let Some(message) = pop_next_client_request(&queues, &active, client_id) else {
             return;
         };
-        handle_client_request(&shard, &sessions, client_id, message).await;
+        handle_client_request(&shard, &sessions, &system_config, client_id, message).await;
     }
 }
 
@@ -449,6 +598,7 @@ fn pop_next_client_request(
 async fn handle_client_request(
     shard: &Rc<ServerNgShard>,
     sessions: &Rc<RefCell<SessionManager>>,
+    system_config: &Arc<SystemConfig>,
     transport_client_id: u128,
     message: Message<iggy_binary_protocol::GenericHeader>,
 ) {
@@ -478,9 +628,35 @@ async fn handle_client_request(
         // legitimately pre-auth (liveness probe + connection bootstrap
         // metadata). Every other non-replicated code (`GET_STREAM*`,
         // `GET_TOPIC*`, `GET_STATS`, `POLL_MESSAGES`) reads live state and
-        // MUST go through Register first, since server-ng has no
-        // per-resource authz layer.
+        // MUST go through Register first, which binds the acting user the
+        // per-op authz gates resolve.
         let nr_code = u32::from_le_bytes(request.header().reserved[..4].try_into().unwrap());
+        // Legacy (pre-register) login codes. server-ng authenticates only via
+        // the Register handshake (LOGIN_REGISTER / LOGIN_REGISTER_WITH_PAT,
+        // Operation::Register); the vsr SDK funnels both logins there and never
+        // emits these. Reject them uniformly with a typed MalformedLogin (the
+        // SDK maps it to InvalidFormat) before the session gate, so a legacy or
+        // foreign client fails fast instead of getting the misleading
+        // NoSession eviction the pre-auth guard would send unbound, or the
+        // silent empty-ok Reply the bound non-replicated path would send.
+        if matches!(
+            nr_code,
+            LOGIN_USER_CODE | LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE
+        ) {
+            warn!(
+                transport_client_id,
+                code = nr_code,
+                "rejecting legacy login code; server-ng requires the register handshake"
+            );
+            send_login_eviction(
+                shard,
+                transport_client_id,
+                header.client,
+                EvictionReason::MalformedLogin,
+            )
+            .await;
+            return;
+        }
         let allowed_pre_auth = matches!(nr_code, PING_CODE | GET_CLUSTER_METADATA_CODE);
         if !allowed_pre_auth && sessions.borrow().get_session(transport_client_id).is_none() {
             warn!(
@@ -491,7 +667,8 @@ async fn handle_client_request(
             send_unauthenticated_eviction(shard, transport_client_id).await;
             return;
         }
-        handle_non_replicated_request(shard, sessions, transport_client_id, request).await;
+        handle_non_replicated_request(shard, sessions, system_config, transport_client_id, request)
+            .await;
         return;
     }
 
@@ -549,90 +726,22 @@ async fn handle_client_request(
         return;
     }
 
-    // Partition data-plane op (SendMessages / consumer-offset writes): the
-    // op belongs to the partition's own consensus group, not the metadata
-    // group. Route it through the shard mesh by namespace; the owning
-    // shard's partitions plane runs at-least-once consensus and replies
-    // directly via `send_to_client`. `header.client` therefore stays the
-    // TRANSPORT id (home-shard routing bits), not the VSR session id --
-    // partition ops are sessionless ("session lifecycle is metadata-only").
     if header.operation.is_partition() {
-        // The consumer-group offset fence keys on the bound VSR client id (the
-        // member id), not the transport id stamped into the partition-op
-        // header. `bound` is Some here (unbound transports returned above).
-        let vsr_client_id = bound.map_or(0, |(client_id, _)| client_id);
-        let namespace = match resolve_partition_request_namespace(
+        // `bound` is Some here (unbound transports returned above).
+        let (vsr_client_id, bound_session) = bound.unwrap_or((0, 0));
+        // `get_session` discards the acting user id the partition gate needs;
+        // resolve it from the same bound connection. A bound transport always
+        // has one, but the gate fails closed on `None` rather than trust that.
+        let acting_user_id = sessions.borrow().get_user_id(transport_client_id);
+        dispatch_partition_request(
             shard,
-            header.operation,
-            request_body(&request),
+            request,
             vsr_client_id,
-        ) {
-            Ok(namespace) => namespace,
-            Err(error) => {
-                // A partition op against a stream/topic that no longer
-                // resolves (e.g. a consumer's trailing auto-commit racing a
-                // `delete_stream`). A silent drop wedges the SDK connection
-                // forever; reply empty so the client fails fast instead.
-                warn!(
-                    transport_client_id,
-                    error = %error,
-                    operation = ?header.operation,
-                    "partition request with unresolved namespace; replying empty"
-                );
-                send_empty_partition_reply(shard, transport_client_id, &header).await;
-                return;
-            }
-        };
-        // Convergence wait: a CreateTopic commit returns to the client
-        // before the per-shard reconcilers seed routing rows and
-        // materialise the partition (next wake/periodic tick). The SDK
-        // does not replay sends, so an immediately-following partition op
-        // would be dropped as unroutable. Absorb that window here with a
-        // bounded wait; steady-state sends (row present, partition probed
-        // once) skip it entirely.
-        if !wait_for_partition_routable(shard, IggyNamespace::from_raw(namespace)).await {
-            warn!(
-                transport_client_id,
-                namespace,
-                operation = ?header.operation,
-                "partition request not routable within budget; replying empty"
-            );
-            send_empty_partition_reply(shard, transport_client_id, &header).await;
-            return;
-        }
-        // A group consumer-offset op carries the group NAME on the wire; the
-        // partition plane keys the offset by the group's monotonic id (the same
-        // key the poll path auto-commits under and the read path resolves), so
-        // rewrite the consumer id before replication -- the apply layer has no
-        // metadata access to resolve it.
-        let request = match maybe_rewrite_consumer_offset_request(shard, request) {
-            Ok(rewritten) => rewritten,
-            Err(error) => {
-                warn!(
-                    transport_client_id,
-                    error = %error,
-                    operation = ?header.operation,
-                    "failed to rewrite consumer-offset request; replying empty"
-                );
-                send_empty_partition_reply(shard, transport_client_id, &header).await;
-                return;
-            }
-        };
-        let request = request.transmute_header(|header, new_header: &mut RequestHeader| {
-            *new_header = header;
-            new_header.namespace = namespace;
-            new_header.client = transport_client_id;
-            // Header validation requires `session > 0 && request > 0` for
-            // non-register ops. The partition plane itself is sessionless
-            // (at-least-once, no `ClientTable` dedup), so the bound VSR
-            // session merely satisfies validation, and a zero request id
-            // (the SDK does not number data-plane ops) is normalized.
-            if let Some((_, bound_session)) = bound {
-                new_header.session = bound_session;
-            }
-            new_header.request = new_header.request.max(1);
-        });
-        shard.dispatch(request.into_generic());
+            bound_session,
+            transport_client_id,
+            acting_user_id,
+        )
+        .await;
         return;
     }
 
@@ -658,17 +767,36 @@ async fn handle_client_request(
                 return;
             }
         };
-    // Hash raw passwords on the primary before replication (CreateUser /
-    // ChangePassword); see `crate::users`. Replicas store the hash directly.
-    let request = match maybe_rewrite_user_password_request(request) {
+    // Hash raw passwords and, for ChangePassword, verify the current password
+    // on the primary before replication; see `crate::users`. Replicas store the
+    // hash directly. A wrong current password is not denied here: it rides
+    // consensus and applies as a committed InvalidCredentials no-op, so the only
+    // Err returned is a malformed body.
+    let request = match maybe_rewrite_user_password_request(shard, request) {
         Ok(rewritten) => rewritten,
         Err(error) => {
+            // Malformed body: deny fast with InvalidCommand. A silent drop
+            // would wedge every later request on the connection until the
+            // socket read timeout.
             warn!(
                 transport_client_id,
                 error = %error,
                 operation = ?header.operation,
-                "dropping user request with invalid password payload"
+                "denying user password request"
             );
+            let commit = current_metadata_commit(shard);
+            let reply = build_deny_reply(&header, transport_client_id, 0, commit, error.as_code());
+            if let Err(send_error) = shard
+                .bus
+                .send_to_client(transport_client_id, reply.into_generic().into_frozen())
+                .await
+            {
+                warn!(
+                    transport_client_id,
+                    error = %send_error,
+                    "failed to send password-deny reply"
+                );
+            }
             return;
         }
     };
@@ -775,15 +903,148 @@ async fn handle_get_me(
     .await;
 }
 
+/// Route a partition data-plane op (`SendMessages` / consumer-offset writes)
+/// through the shard mesh by namespace: the op belongs to the partition's
+/// own consensus group, not the metadata group. The owning shard's
+/// partitions plane runs at-least-once consensus and replies directly via
+/// `send_to_client`. `header.client` therefore stays the TRANSPORT id
+/// (home-shard routing bits), not the VSR session id -- partition ops are
+/// sessionless ("session lifecycle is metadata-only").
+///
+/// Callers must have authenticated the transport already: `vsr_client_id` /
+/// `bound_session` come from its bound VSR session. Every failure before
+/// dispatch replies empty so the client fails fast instead of wedging on a
+/// silent drop.
+///
+/// `vsr_client_id` keys the consumer-group offset fence (the member id),
+/// not the transport id stamped into the partition-op header.
 #[allow(clippy::future_not_send)]
+pub(crate) async fn dispatch_partition_request(
+    shard: &Rc<ServerNgShard>,
+    request: Message<RequestHeader>,
+    vsr_client_id: u128,
+    bound_session: u64,
+    transport_client_id: u128,
+    acting_user_id: Option<u32>,
+) {
+    let header = *request.header();
+    let namespace = match resolve_partition_request_namespace(
+        shard,
+        header.operation,
+        request_body(&request),
+        vsr_client_id,
+    ) {
+        Ok(namespace) => namespace,
+        Err(error) => {
+            // A partition op against a stream/topic that no longer
+            // resolves (e.g. a consumer's trailing auto-commit racing a
+            // `delete_stream`). A silent drop wedges the SDK connection
+            // forever; reply empty so the client fails fast instead.
+            warn!(
+                transport_client_id,
+                error = %error,
+                operation = ?header.operation,
+                "partition request with unresolved namespace; replying empty"
+            );
+            send_empty_partition_reply(shard, transport_client_id, &header).await;
+            return;
+        }
+    };
+    // Dispatch-time RBAC. The partition plane is not replicated through the
+    // metadata STM, so the in-apply gate cannot cover it; authorize here, on
+    // the connection's own shard, before burning the routable wait or touching
+    // the plane. The namespace resolved above, so its stream/topic are the
+    // committed slab ids the permissioner keys on directly. A denial replies
+    // the op's frame with an empty body and a nonzero `status` the SDK peeks.
+    //
+    // Consistency: this reads THIS shard's local committed permissioner. On a
+    // peer shard that is a replicated read-mirror, so a permission revocation
+    // takes effect on the partition plane only once this shard applies the
+    // revoking commit -- an apply-lag window bounded by replication lag.
+    // Control-plane ops are exact (gated in-apply, in the same committed order
+    // on every replica); this local-read relaxation on the data plane is the
+    // accepted trade for keeping partition ops off the metadata consensus.
+    let scope = IggyNamespace::from_raw(namespace);
+    if let Some(status) = authorize_partition_op(
+        shard,
+        header.operation,
+        acting_user_id,
+        scope.stream_id(),
+        scope.topic_id(),
+    ) {
+        warn!(
+            transport_client_id,
+            status,
+            operation = ?header.operation,
+            "partition request denied by authorization; replying with status"
+        );
+        send_partition_deny_reply(shard, transport_client_id, &header, status).await;
+        return;
+    }
+    // Convergence wait: a CreateTopic commit returns to the client
+    // before the per-shard reconcilers seed routing rows and
+    // materialise the partition (next wake/periodic tick). The SDK
+    // does not replay sends, so an immediately-following partition op
+    // would be dropped as unroutable. Absorb that window here with a
+    // bounded wait; steady-state sends (row present, partition probed
+    // once) skip it entirely.
+    if !wait_for_partition_routable(shard, IggyNamespace::from_raw(namespace)).await {
+        warn!(
+            transport_client_id,
+            namespace,
+            operation = ?header.operation,
+            "partition request not routable within budget; replying empty"
+        );
+        send_empty_partition_reply(shard, transport_client_id, &header).await;
+        return;
+    }
+    // A group consumer-offset op carries the group NAME on the wire; the
+    // partition plane keys the offset by the group's monotonic id (the same
+    // key the poll path auto-commits under and the read path resolves), so
+    // rewrite the consumer id before replication -- the apply layer has no
+    // metadata access to resolve it.
+    let request = match maybe_rewrite_consumer_offset_request(shard, request) {
+        Ok(rewritten) => rewritten,
+        Err(error) => {
+            warn!(
+                transport_client_id,
+                error = %error,
+                operation = ?header.operation,
+                "failed to rewrite consumer-offset request; replying empty"
+            );
+            send_empty_partition_reply(shard, transport_client_id, &header).await;
+            return;
+        }
+    };
+    let request = request.transmute_header(|header, new_header: &mut RequestHeader| {
+        *new_header = header;
+        new_header.namespace = namespace;
+        new_header.client = transport_client_id;
+        // Header validation requires `session > 0 && request > 0` for
+        // non-register ops. The partition plane itself is sessionless
+        // (at-least-once, no `ClientTable` dedup), so the bound VSR
+        // session merely satisfies validation, and a zero request id
+        // (the SDK does not number data-plane ops) is normalized.
+        new_header.session = bound_session;
+        new_header.request = new_header.request.max(1);
+    });
+    shard.dispatch(request.into_generic());
+}
+
+#[allow(clippy::future_not_send, clippy::too_many_lines)]
 async fn handle_non_replicated_request(
     shard: &Rc<ServerNgShard>,
     sessions: &Rc<RefCell<SessionManager>>,
+    system_config: &Arc<SystemConfig>,
     transport_client_id: u128,
     request: Message<RequestHeader>,
 ) {
     const CODE_RANGE: std::ops::Range<usize> = 0..4;
     let code = u32::from_le_bytes(request.header().reserved[CODE_RANGE].try_into().unwrap());
+    // Acting user for the read gates below, resolved once. `None` only on the
+    // pre-auth path (PING / GET_CLUSTER_METADATA), which serves ungated codes;
+    // the gated arms fail closed on it.
+    let user_id = sessions.borrow().get_user_id(transport_client_id);
     match code {
         PING_CODE => {
             // A ping is the client's liveness proof; reset its staleness clock
@@ -815,6 +1076,11 @@ async fn handle_non_replicated_request(
             handle_get_personal_access_tokens(shard, sessions, transport_client_id, &request).await;
         }
         GET_CLIENTS_CODE => {
+            if let Err(error) = authorize_uid(shard, user_id, Permissioner::get_clients) {
+                send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
+                    .await;
+                return;
+            }
             // Shared-nothing: each shard knows only its own connections, so
             // gather across all shards (scatter-gather over the mesh).
             let infos = shard.list_all_clients().await;
@@ -834,6 +1100,11 @@ async fn handle_non_replicated_request(
             .await;
         }
         GET_CLIENT_CODE => {
+            if let Err(error) = authorize_uid(shard, user_id, Permissioner::get_client) {
+                send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
+                    .await;
+                return;
+            }
             // No reverse map from the wire u32 id to a u128 transport id /
             // home shard (the u32 is just the seq tail), so gather all and
             // filter -- same fan-out as `get_clients`.
@@ -871,16 +1142,32 @@ async fn handle_non_replicated_request(
             send_non_replicated_bytes(shard, &request, transport_client_id, bytes, "get_client")
                 .await;
         }
+        GET_SNAPSHOT_FILE_CODE => {
+            handle_get_snapshot(shard, system_config, transport_client_id, &request, user_id).await;
+        }
         POLL_MESSAGES_CODE => {
-            handle_poll_messages(shard, transport_client_id, &request).await;
+            handle_poll_messages(shard, transport_client_id, &request, user_id).await;
         }
         GET_CONSUMER_OFFSET_CODE => {
-            handle_get_consumer_offset(shard, transport_client_id, &request).await;
+            handle_get_consumer_offset(shard, transport_client_id, &request, user_id).await;
         }
         SYNC_CONSUMER_GROUP_CODE => {
+            // Self-scoped: serves the caller's own assignment keyed by the
+            // header client id, so it carries no permissioner rule.
             handle_sync_consumer_group(shard, transport_client_id, &request).await;
         }
-        _ => handle_default_non_replicated(shard, transport_client_id, code, &request).await,
+        _ => {
+            let roster = sessions.borrow().cluster_roster();
+            handle_default_non_replicated(
+                shard,
+                transport_client_id,
+                code,
+                &request,
+                user_id,
+                &roster,
+            )
+            .await;
+        }
     }
 }
 
@@ -890,8 +1177,17 @@ async fn handle_default_non_replicated(
     transport_client_id: u128,
     code: u32,
     request: &Message<RequestHeader>,
+    user_id: Option<u32>,
+    roster: &ClusterRoster,
 ) {
-    match build_non_replicated_response(shard, code, request_body(request)) {
+    // Gate by command code before the shared builder runs. The builder stays
+    // authz-free (it is byte-shared with the HTTP read path, which gates
+    // separately); a denial replies status!=0 with an empty body.
+    if let Err(error) = authorize_default_read(shard, code, request_body(request), user_id) {
+        send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
+        return;
+    }
+    match build_non_replicated_response(shard, code, request_body(request), user_id, roster) {
         Ok(response) => {
             let commit = current_metadata_commit(shard);
             let reply = response.into_reply(
@@ -914,16 +1210,98 @@ async fn handle_default_non_replicated(
             }
         }
         Err(error) => {
+            // Surface the builder's typed error (unsupported op, undecodable
+            // body, or a not-found parity read) on the same deny channel the
+            // authz gate uses; a silent drop would wedge the client until its
+            // read timeout.
             warn!(
                 transport_client_id,
                 code,
                 error = %error,
-                "dropping unsupported non-replicated VSR request"
+                "denying non-replicated VSR request"
             );
+            send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
         }
     }
 }
 
+/// Serve `GET_SNAPSHOT_FILE`: gate on the snapshot rule (`read_servers ||
+/// manage_servers`, the legacy gate - the archive dumps host diagnostics, so
+/// plain authentication must not suffice), then await the off-thread
+/// collection (see `snapshot::collect`) and reply with the raw ZIP bytes.
+#[allow(clippy::future_not_send)]
+async fn handle_get_snapshot(
+    shard: &Rc<ServerNgShard>,
+    system_config: &Arc<SystemConfig>,
+    transport_client_id: u128,
+    request: &Message<RequestHeader>,
+    user_id: Option<u32>,
+) {
+    if let Err(error) = authorize_uid(shard, user_id, Permissioner::get_snapshot) {
+        send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
+        return;
+    }
+    let result = match decode_get_snapshot(request_body(request)) {
+        Ok((compression, snapshot_types)) => {
+            snapshot::collect(Arc::clone(system_config), compression, snapshot_types).await
+        }
+        Err(error) => Err(error),
+    };
+    match result {
+        Ok(archive) => {
+            // The reply frames as `[256-byte header][archive]`. The client's
+            // `message_bus::read_message` rejects any frame past `MAX_MESSAGE_SIZE`
+            // (64 MiB) by tearing the connection down untyped, and a frame past
+            // `u32::MAX` would panic `build_reply_with_body`. The archive is the
+            // only unbounded non-replicated body, so refuse an oversized one with a
+            // typed error the SDK decodes. The HTTP path streams via `Body` (not
+            // this framing), so it stays uncapped.
+            let frame_size = HEADER_SIZE + archive.len();
+            if frame_size > MAX_MESSAGE_SIZE {
+                warn!(
+                    transport_client_id,
+                    frame_size,
+                    max = MAX_MESSAGE_SIZE,
+                    "snapshot archive exceeds the client frame limit; refusing to send"
+                );
+                send_non_replicated_deny(
+                    shard,
+                    request,
+                    transport_client_id,
+                    IggyError::SnapshotFileCompletionFailed.as_code(),
+                )
+                .await;
+                return;
+            }
+            send_non_replicated_bytes(
+                shard,
+                request,
+                transport_client_id,
+                GetSnapshotResponse { data: archive }.to_bytes(),
+                "get_snapshot",
+            )
+            .await;
+        }
+        Err(error) => {
+            warn!(transport_client_id, error = %error, "denying snapshot request");
+            send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
+        }
+    }
+}
+
+fn decode_get_snapshot(
+    body: &[u8],
+) -> Result<(SnapshotCompression, Vec<SystemSnapshotType>), IggyError> {
+    let request = GetSnapshotRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
+    let compression = SnapshotCompression::from_code(request.compression)?;
+    let snapshot_types = request
+        .snapshot_types
+        .iter()
+        .map(|&code| SystemSnapshotType::from_code(code))
+        .collect::<Result<Vec<_>, _>>()?;
+    Ok((compression, snapshot_types))
+}
+
 /// Send a non-replicated reply body to a client, stamping the current
 /// metadata commit. Shared by the `get_me` / `get_clients` / `get_client`
 /// arms.
@@ -1091,8 +1469,37 @@ async fn handle_poll_messages(
     shard: &Rc<ServerNgShard>,
     transport_client_id: u128,
     request: &Message<RequestHeader>,
+    user_id: Option<u32>,
 ) {
-    let body = match decode_poll_request(shard, request) {
+    let Ok(wire) = PollMessagesRequest::decode_from(request_body(request)) else {
+        // Undecodable poll: keep the fail-fast empty-poll shape.
+        send_non_replicated_bytes(
+            shard,
+            request,
+            transport_client_id,
+            empty_polled_messages_body(0),
+            "poll_messages",
+        )
+        .await;
... 7824 lines suppressed ...