You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by hg...@apache.org on 2026/07/03 13:17:02 UTC

(iggy) branch http-listener-rebased updated (0482b1e73 -> ef87ef969)

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

hubcio pushed a change to branch http-listener-rebased
in repository https://gitbox.apache.org/repos/asf/iggy.git


 discard 0482b1e73 feat(server-ng): add cluster-correct HTTP listener + per-op RBAC
     add 9ddae16f4 feat(python): add topic listing, update, delete and purge (#3572)
     add 78066a60d chore(deps): bump node group, pin CI npm to 11 (#3602)
     add 8c3c99d37 feat(python): expose consumer group create and get APIs (#3581)
     add 7b2db334f fix(integration): pass remaining tests for server-ng (#3585)
     new ef87ef969 feat(server-ng): add cluster-correct HTTP listener + per-op RBAC

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

 * -- * -- B -- O -- O -- O   (0482b1e73)
            \
             N -- N -- N   refs/heads/http-listener-rebased (ef87ef969)

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

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

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


Summary of changes:
 .github/actions/node-npm/post-merge/action.yml     |   4 +
 .github/actions/node-npm/pre-merge/action.yml      |   4 +
 .../actions/python-maturin/pre-merge/action.yml    |   3 +
 .../actions/utils/setup-node-with-cache/action.yml |   4 +
 .github/workflows/coverage-baseline.yml            |   3 +
 .typos.toml                                        |   3 +
 Cargo.lock                                         |   1 +
 core/ai/mcp/Cargo.toml                             |   1 +
 core/binary_protocol/src/consensus/operation.rs    |  23 +
 core/common/src/error/iggy_error.rs                |   2 +
 core/configs/src/server_config/sharding.rs         |   2 +-
 core/consensus/src/impls.rs                        |  23 +-
 core/consensus/src/metadata_helpers.rs             |  24 +-
 core/consensus/src/plane_helpers.rs                |  64 ++
 core/integration/src/harness/handle/server.rs      |  27 +-
 .../src/harness/orchestrator/builder.rs            |   6 +-
 .../src/harness/orchestrator/harness.rs            |  83 +-
 .../verify_consumer_group_partition_assignment.rs  |  17 +-
 core/integration/tests/mod.rs                      |   7 +-
 core/integration/tests/sdk/mcp_parity.rs           | 613 +++++++++++++++
 core/integration/tests/sdk/mod.rs                  |   3 +-
 core/integration/tests/sdk/raw.rs                  |   2 +-
 core/integration/tests/server/cg_vsr.rs            |  14 +-
 .../tests/server/concurrent_addition.rs            |  12 +-
 core/integration/tests/server/general.rs           |  26 +-
 core/integration/tests/server/http_vsr.rs          |  68 ++
 core/integration/tests/server/mod.rs               |   3 +-
 core/integration/tests/server/purge_delete.rs      |  22 +-
 ...onsumer_group_duplicate_name_create_scenario.rs |   6 +-
 core/integration/tests/server/scenarios/mod.rs     |  13 -
 .../tests/server/scenarios/permissions_scenario.rs |  97 ++-
 .../scenarios/segment_rotation_race_scenario.rs    |  23 +
 .../scenarios/stream_size_validation_scenario.rs   |  86 ++-
 .../tests/server/scenarios/system_scenario.rs      | 158 ++--
 core/integration/tests/server/specific.rs          |  16 +-
 core/message_bus/Cargo.toml                        |   1 +
 core/message_bus/src/client_listener/mod.rs        |   9 +-
 core/message_bus/src/transports/quic.rs            | 186 ++++-
 core/metadata/src/impls/metadata.rs                | 261 ++++++-
 core/metadata/src/stm/consumer_group.rs            |   3 +-
 core/metadata/src/stm/result.rs                    |  13 +-
 core/metadata/src/stm/stream.rs                    | 230 +++++-
 core/metadata/src/stm/user.rs                      |  10 +
 core/partitions/src/iggy_index_reader.rs           | 117 +++
 core/partitions/src/iggy_partition.rs              | 213 ++++--
 core/partitions/src/lib.rs                         |   3 +
 core/sdk/src/prelude.rs                            |  25 +-
 core/sdk/src/quic/quic_client.rs                   | 160 +++-
 core/sdk/src/tcp/tcp_client.rs                     | 189 +++--
 core/sdk/src/vsr.rs                                |  82 +-
 core/sdk/src/websocket/websocket_client.rs         | 173 +++--
 core/server-ng/src/auth.rs                         |  40 +-
 core/server-ng/src/bootstrap.rs                    | 305 ++++----
 core/server-ng/src/dispatch.rs                     |  67 +-
 core/server-ng/src/lib.rs                          |   1 +
 core/server-ng/src/partition_helpers.rs            |   5 +-
 core/server-ng/src/partition_reconciler.rs         |  21 +-
 core/server-ng/src/responses.rs                    | 188 +++--
 core/server-ng/src/segment_recovery.rs             | 306 ++++++++
 core/server-ng/src/users.rs                        |  88 ++-
 core/server_common/src/indexes_mut.rs              |  10 +-
 core/shard/src/lib.rs                              |  22 +-
 core/simulator/src/workload/ops/change_password.rs |   6 +-
 foreign/node/eslint.config.mjs                     |   2 -
 foreign/node/package-lock.json                     | 526 +++++++------
 foreign/node/package.json                          |   6 +-
 foreign/python/apache_iggy.pyi                     | 270 ++++++-
 foreign/python/src/client.rs                       | 276 ++++++-
 foreign/python/src/consumer.rs                     | 136 +++-
 foreign/python/src/lib.rs                          |  11 +-
 foreign/python/src/topic.rs                        |  58 +-
 foreign/python/tests/test_consumer_group.py        | 404 +++++++++-
 foreign/python/tests/test_topic.py                 | 847 +++++++++++++++++++--
 73 files changed, 5575 insertions(+), 1158 deletions(-)
 create mode 100644 core/integration/tests/sdk/mcp_parity.rs
 create mode 100644 core/partitions/src/iggy_index_reader.rs
 create mode 100644 core/server-ng/src/segment_recovery.rs


(iggy) 01/01: feat(server-ng): add cluster-correct HTTP listener + per-op RBAC

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

hubcio pushed a commit to branch http-listener-rebased
in repository https://gitbox.apache.org/repos/asf/iggy.git

commit ef87ef9696b646f1dd33459a1cc2758c94c1f804
Author: Hubert Gruszecki <h....@gmail.com>
AuthorDate: Fri Jul 3 00:22:06 2026 +0200

    feat(server-ng): add cluster-correct HTTP listener + per-op RBAC
    
    server-ng had no HTTP transport: REST clients could not reach the
    next-gen server at all, and the legacy server's HTTP stack is
    single-node-coupled, with no notion of views, leaders, or
    replicated authorization.
    
    Make HTTP a regular client of the dispatch spine instead of a
    parallel implementation. Control-plane writes await the committed
    reply directly; the data plane gets the 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 via
    Register-once-per-login, with a per-session gate keeping request
    ids gap-free. Per-op RBAC is enforced in the metadata apply path
    and at dispatch time for every transport, replacing the interim
    root-only gates. Reads default to serializable-local; linearizable
    reads serve on the primary or 307-redirect to it.
    
    HTTP control writes are at-least-once: HTTP retries mint fresh
    request ids, so the consensus dedup window cannot collapse them.
    Produce honors ack=none and reports durability in a response
    header. Cross-transport fixes ride along where the port exposed
    pre-existing gaps: typed denies replace silent drops and fake
    successes (flush, snapshot, missing consumer offset, consumer
    group on a missing parent), PAT create/list framing is now
    SDK-decodable, and ChangePassword verifies and strips the current
    password before replication.
    
    Under the test harness HTTP becomes a first-class VSR transport
    alongside TCP, QUIC, and WebSocket, so the shared cross-transport
    scenarios now exercise it directly. Authorization-decision coverage
    consolidates into a single permissions scenario that asserts exact
    error codes over all four transports; the raw HTTP RBAC suite
    shrinks to the wire contract it alone can assert - status codes and
    typed JSON error bodies - plus the HTTP-only routes.
---
 core/binary_protocol/src/consensus/header.rs       |   76 +-
 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                |   71 +-
 core/harness_derive/src/codegen.rs                 |   15 +-
 core/integration/tests/server/flush_vsr.rs         |   66 +
 core/integration/tests/server/general.rs           |   16 +-
 core/integration/tests/server/http_rbac.rs         |  657 ++++
 core/integration/tests/server/http_vsr.rs          |  823 ++++
 core/integration/tests/server/mod.rs               |   12 +
 core/integration/tests/server/scenarios/mod.rs     |    1 -
 .../tests/server/scenarios/permissions_scenario.rs |  431 ++-
 core/integration/tests/server/snapshot_vsr.rs      |   45 +
 core/message_bus/src/lib.rs                        |   87 +-
 .../src/lifecycle/connection_registry.rs           |  454 ++-
 core/message_bus/src/lifecycle/mod.rs              |    2 +-
 core/metadata/src/impls/metadata.rs                |  283 +-
 core/metadata/src/impls/recovery.rs                |    6 +-
 .../src/permissioner/permissioner_rules/system.rs  |    4 +
 core/metadata/src/stm/authz.rs                     |  758 ++++
 core/metadata/src/stm/consumer_group.rs            |   52 +-
 core/metadata/src/stm/mod.rs                       |    1 +
 core/metadata/src/stm/result.rs                    |   77 +-
 core/metadata/src/stm/stream.rs                    |    8 +-
 core/metadata/src/stm/user.rs                      |  568 ++-
 core/partitions/src/iggy_partition.rs              |  236 +-
 core/sdk/src/vsr.rs                                |   55 +-
 core/server-ng/Cargo.toml                          |    1 +
 core/server-ng/src/auth.rs                         |   62 +-
 core/server-ng/src/bootstrap.rs                    |   44 +-
 core/server-ng/src/dispatch.rs                     |  671 +++-
 core/server-ng/src/http.rs                         | 3994 ++++++++++++++++++++
 core/server-ng/src/http/extractor.rs               |  155 +
 core/server-ng/src/http/jwt.rs                     |  218 ++
 core/server-ng/src/lib.rs                          |    1 +
 core/server-ng/src/pat.rs                          |   21 +-
 core/server-ng/src/responses.rs                    |  282 +-
 core/server-ng/src/users.rs                        |  149 +-
 core/shard/src/lib.rs                              |    1 +
 core/simulator/src/replica.rs                      |    4 +
 core/simulator/src/workload/ops/change_password.rs |    6 +-
 .../src/workload/ops/create_consumer_group.rs      |   29 +-
 core/simulator/src/workload/ops/delete_user.rs     |    7 +-
 .../src/workload/ops/update_permissions.rs         |    2 +
 45 files changed, 10188 insertions(+), 437 deletions(-)

diff --git a/core/binary_protocol/src/consensus/header.rs b/core/binary_protocol/src/consensus/header.rs
index ff87bf05f..f374c86a6 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -151,7 +151,10 @@ pub struct RequestHeader {
     pub operation_padding: [u8; 7],
     pub namespace: u64,
     pub session: u64,
-    pub reserved: [u8; 56],
+    /// Acting user id for a `Register`; replicated so every replica resolves
+    /// session -> user deterministically. Zero for non-register ops.
+    pub user_id: u32,
+    pub reserved: [u8; 52],
 }
 const _: () = {
     assert!(size_of::<RequestHeader>() == HEADER_SIZE);
@@ -159,7 +162,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 +188,8 @@ impl Default for RequestHeader {
             operation_padding: [0; 7],
             namespace: 0,
             session: 0,
-            reserved: [0; 56],
+            user_id: 0,
+            reserved: [0; 52],
         }
     }
 }
@@ -272,7 +279,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 (a dispatch-time authorization denial, or
+    /// the partition primary rejecting a delete of a missing consumer offset).
+    ///
+    /// 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 +300,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 +328,8 @@ impl Default for ReplyHeader {
             operation: Operation::Reserved,
             operation_padding: [0; 7],
             namespace: 0,
-            reserved: [0; 32],
+            status: 0,
+            reserved: [0; 28],
         }
     }
 }
@@ -591,7 +615,11 @@ pub struct PrepareHeader {
     pub operation: Operation,
     pub operation_padding: [u8; 7],
     pub namespace: u64,
-    pub reserved: [u8; 32],
+    /// Acting user id, copied from the originating `RequestHeader`. Lets every
+    /// replica resolve session -> user deterministically on `Register`. Zero
+    /// for non-register ops.
+    pub user_id: u32,
+    pub reserved: [u8; 28],
 }
 const _: () = {
     assert!(size_of::<PrepareHeader>() == HEADER_SIZE);
@@ -599,7 +627,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 +656,8 @@ impl Default for PrepareHeader {
             operation: Operation::Reserved,
             operation_padding: [0; 7],
             namespace: 0,
-            reserved: [0; 32],
+            user_id: 0,
+            reserved: [0; 28],
         }
     }
 }
@@ -1112,6 +1145,31 @@ 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>(), 256);
+        assert_eq!(
+            offset_of!(ReplyHeader, status),
+            offset_of!(ReplyHeader, namespace) + size_of::<u64>()
+        );
+        assert_eq!(offset_of!(ReplyHeader, reserved) + 28, 256);
+    }
+
+    // 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/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..6d13bc5e8 100644
--- a/core/consensus/src/plane_helpers.rs
+++ b/core/consensus/src/plane_helpers.rs
@@ -452,6 +452,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 = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(
+        &mut reply.as_mut_slice()[..header_size],
+    )
+    .expect("freshly built reply header is valid");
+    header.status = status;
+    header.op = 0;
+    reply
+}
+
 /// Verify hash chain would not break if we add this header.
 ///
 /// # Panics
@@ -539,7 +570,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 +1015,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/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..bba5cf793 100644
--- a/core/integration/tests/server/general.rs
+++ b/core/integration/tests/server/general.rs
@@ -17,13 +17,11 @@
 
 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,
+    invalid_consumer_offset_scenario, message_headers_scenario, permissions_scenario,
+    stream_size_validation_scenario, system_scenario,
 };
 #[cfg(not(feature = "vsr"))]
-use crate::server::scenarios::{
-    bench_scenario, permissions_scenario, snapshot_scenario, user_scenario,
-};
+use crate::server::scenarios::{bench_scenario, snapshot_scenario, user_scenario};
 use integration::iggy_harness;
 
 #[iggy_harness(
@@ -74,14 +72,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(
diff --git a/core/integration/tests/server/http_rbac.rs b/core/integration/tests/server/http_rbac.rs
new file mode 100644
index 000000000..e793d4aa3
--- /dev/null
+++ b/core/integration/tests/server/http_rbac.rs
@@ -0,0 +1,657 @@
+// 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
+//! `server::general::permissions`; 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 full change-password wire contract, which stays HTTP-only because the
+//!   binary transports' change-password path is broken (a successful rotation
+//!   tears down the caller's session, a missing target stalls the connection,
+//!   and a wrong current password poisons it).
+//!
+//! Each test owns its own server (one spawn per `#[iggy_harness]` fn).
+
+use iggy::prelude::*;
+use iggy_common::IggyMessagesBatch;
+use iggy_common::create_stream::CreateStream;
+use iggy_common::create_topic::CreateTopic;
+use integration::harness::TestHarness;
+use integration::iggy_harness;
+use reqwest::{Response, StatusCode};
+use serde_json::{Value, json};
+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 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";
+
+/// 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.
+const LOGIN_TIMEOUT: Duration = Duration::from_secs(15);
+const LOGIN_RETRY_INTERVAL: Duration = Duration::from_millis(50);
+const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// One authenticated HTTP session: a `reqwest` client plus the bearer to send.
+/// The bearer is either a JWT (from a login) or a raw personal access token
+/// (server-ng resolves either on the `Authorization: Bearer` header).
+struct Client {
+    http: reqwest::Client,
+    base_url: String,
+    token: String,
+}
+
+impl Client {
+    /// Log in as root, retrying while the listener binds and consensus warms up.
+    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 http = 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 = http
+                .post(format!("{base_url}/users/login"))
+                .json(&body)
+                .send()
+                .await;
+            match attempt {
+                Ok(response) if response.status().is_success() => {
+                    return Self {
+                        http,
+                        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.
+    async fn login(&self, username: &str, password: &str) -> Self {
+        let body = json!({ "username": username, "password": password });
+        let response = self
+            .http
+            .post(self.url("/users/login"))
+            .json(&body)
+            .send()
+            .await
+            .expect("login request");
+        assert!(
+            response.status().is_success(),
+            "login for {username} failed: {}",
+            response.status()
+        );
+        Self {
+            http: self.http.clone(),
+            base_url: self.base_url.clone(),
+            token: access_token(response).await,
+        }
+    }
+
+    fn url(&self, path: &str) -> String {
+        format!("{}{path}", self.base_url)
+    }
+
+    async fn get(&self, path: &str) -> Response {
+        self.http
+            .get(self.url(path))
+            .bearer_auth(&self.token)
+            .send()
+            .await
+            .expect("get request")
+    }
+
+    /// Unauthenticated GET (no bearer) for the public `/ping` probe.
+    async fn get_anonymous(&self, path: &str) -> Response {
+        self.http
+            .get(self.url(path))
+            .send()
+            .await
+            .expect("anonymous get request")
+    }
+
+    async fn post_json(&self, path: &str, body: &Value) -> Response {
+        self.http
+            .post(self.url(path))
+            .bearer_auth(&self.token)
+            .json(body)
+            .send()
+            .await
+            .expect("post request")
+    }
+
+    async fn put_json(&self, path: &str, body: &Value) -> Response {
+        self.http
+            .put(self.url(path))
+            .bearer_auth(&self.token)
+            .json(body)
+            .send()
+            .await
+            .expect("put request")
+    }
+
+    async fn delete(&self, path: &str) -> Response {
+        self.http
+            .delete(self.url(path))
+            .bearer_auth(&self.token)
+            .send()
+            .await
+            .expect("delete request")
+    }
+
+    /// 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 [`Client::login`] cannot express failure).
+    async fn login_attempt(&self, username: &str, password: &str) -> Response {
+        self.http
+            .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.http
+            .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
+    }
+}
+
+/// Extract the JWT from a successful login response.
+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
+}
+
+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_denied_http_requests_should_carry_typed_status_and_body(harness: &TestHarness) {
+    let root = Client::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_authorized_http_requests_should_carry_sdk_hidden_status_codes(
+    harness: &TestHarness,
+) {
+    let root = Client::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_should_return_expected_shapes(harness: &TestHarness) {
+    let root = Client::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 (HTTP-only; binary path is broken) =======
+
+/// The current-password check gates a self-service rotation: a wrong current
+/// password is rejected pre-consensus as 400 `InvalidCredentials` (never
+/// reaching the replicated apply), and 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 = Client::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 = Client::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..ddd9e1ad0
--- /dev/null
+++ b/core/integration/tests/server/http_vsr.rs
@@ -0,0 +1,823 @@
+// 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 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::harness::TestHarness;
+use integration::iggy_harness;
+use reqwest::{Response, StatusCode};
+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";
+
+/// 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.
+const LOGIN_TIMEOUT: Duration = Duration::from_secs(15);
+const LOGIN_RETRY_INTERVAL: Duration = Duration::from_millis(50);
+
+/// `?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);
+
+const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// One authenticated root session against the test server's HTTP listener.
+struct HttpSession {
+    client: reqwest::Client,
+    base_url: String,
+    token: String,
+}
+
+impl HttpSession {
+    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 = serde_json::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() => {
+                    let identity: IdentityInfo =
+                        response.json().await.expect("decode IdentityInfo");
+                    let token = identity
+                        .access_token
+                        .expect("login must return an access token")
+                        .token;
+                    return Self {
+                        client,
+                        base_url,
+                        token,
+                    };
+                }
+                // 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!("login rejected: {}", response.status()),
+                Err(error) if error.is_connect() => {}
+                Err(error) => panic!("login request failed: {error}"),
+            }
+            assert!(
+                Instant::now() < deadline,
+                "HTTP login did not succeed within {LOGIN_TIMEOUT:?}"
+            );
+            sleep(LOGIN_RETRY_INTERVAL).await;
+        }
+    }
+
+    fn url(&self, path: &str) -> String {
+        format!("{}{path}", self.base_url)
+    }
+
+    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")
+    }
+
+    /// Log in as an already-created user, reusing this session's client and
+    /// base URL but swapping the bearer for the new credential. Single attempt:
+    /// the server is already warm once the root session exists.
+    async fn login(&self, username: &str, password: &str) -> Self {
+        let body = serde_json::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()
+        );
+        let identity: IdentityInfo = response.json().await.expect("decode IdentityInfo");
+        let token = identity
+            .access_token
+            .expect("login must return an access token")
+            .token;
+        Self {
+            client: self.client.clone(),
+            base_url: self.base_url.clone(),
+            token,
+        }
+    }
+
+    /// 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()
+        );
+    }
+
+    /// GET `path` with this session's bearer.
+    async fn get(&self, path: &str) -> Response {
+        self.client
+            .get(self.url(path))
+            .bearer_auth(&self.token)
+            .send()
+            .await
+            .expect("get request")
+    }
+}
+
+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 = HttpSession::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 = HttpSession::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 = HttpSession::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 = HttpSession::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 = HttpSession::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 = HttpSession::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 = HttpSession::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 = HttpSession::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"
+    );
+}
diff --git a/core/integration/tests/server/mod.rs b/core/integration/tests/server/mod.rs
index eaed6e591..ec4ab1d92 100644
--- a/core/integration/tests/server/mod.rs
+++ b/core/integration/tests/server/mod.rs
@@ -24,6 +24,18 @@ 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;
+// Snapshot (GET_SNAPSHOT_FILE) has no server-ng primitive; it must deny typed.
+#[cfg(feature = "vsr")]
+mod snapshot_vsr;
+// Raw-HTTP data-plane contract against server-ng's shard-0 listener.
+#[cfg(feature = "vsr")]
+mod http_vsr;
+// End-to-end RBAC permission matrix against server-ng (HTTP + one TCP case).
+#[cfg(feature = "vsr")]
+mod http_rbac;
 // 80-case race matrix with hardcoded HTTP variants (test_matrix bypasses
 // the harness transport filter).
 mod concurrent_addition;
diff --git a/core/integration/tests/server/scenarios/mod.rs b/core/integration/tests/server/scenarios/mod.rs
index be99a9ad9..f2909beec 100644
--- a/core/integration/tests/server/scenarios/mod.rs
+++ b/core/integration/tests/server/scenarios/mod.rs
@@ -45,7 +45,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;
diff --git a/core/integration/tests/server/scenarios/permissions_scenario.rs b/core/integration/tests/server/scenarios/permissions_scenario.rs
index f16c168f3..4aa3520da 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;
 }
 
@@ -215,16 +224,21 @@ async fn test_no_permissions(harness: &TestHarness, root_client: &IggyClient) {
     );
     // Snapshot returns a real archive, never Ok(None), so a denied caller must
     // get an explicit Unauthorized rather than the enumeration-safe Ok of reads.
-    let snapshot_result = client
-        .snapshot(
-            SnapshotCompression::Deflated,
-            vec![SystemSnapshotType::Test],
-        )
-        .await;
-    assert!(
-        matches!(&snapshot_result, Err(e) if e.as_code() == IggyError::Unauthorized.as_code()),
-        "snapshot must be rejected as unauthorized without read_servers, got {snapshot_result:?}"
-    );
+    // server-ng does not implement the snapshot feature, so this authz check is
+    // legacy-only; the server-ng RBAC surface is covered by `server::http_rbac`.
+    #[cfg(not(feature = "vsr"))]
+    {
+        let snapshot_result = client
+            .snapshot(
+                SnapshotCompression::Deflated,
+                vec![SystemSnapshotType::Test],
+            )
+            .await;
+        assert!(
+            matches!(&snapshot_result, Err(e) if e.as_code() == IggyError::Unauthorized.as_code()),
+            "snapshot must be rejected as unauthorized without read_servers, got {snapshot_result:?}"
+        );
+    }
 
     delete_test_user(root_client, USER).await;
 }
@@ -258,6 +272,9 @@ async fn test_system_permissions(harness: &TestHarness, root_client: &IggyClient
         .get_clients()
         .await
         .expect("read_servers: get_clients should work");
+    // Snapshot is a legacy-only feature (server-ng stubs it); assert the
+    // authorized success on the legacy server only.
+    #[cfg(not(feature = "vsr"))]
     client
         .snapshot(
             SnapshotCompression::Deflated,
@@ -296,6 +313,7 @@ async fn test_system_permissions(harness: &TestHarness, root_client: &IggyClient
         .get_clients()
         .await
         .expect("manage_servers: get_clients should work");
+    #[cfg(not(feature = "vsr"))]
     client
         .snapshot(
             SnapshotCompression::Deflated,
@@ -2435,10 +2453,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 +2857,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/integration/tests/server/snapshot_vsr.rs b/core/integration/tests/server/snapshot_vsr.rs
new file mode 100644
index 000000000..505bceb9b
--- /dev/null
+++ b/core/integration/tests/server/snapshot_vsr.rs
@@ -0,0 +1,45 @@
+// 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.
+
+//! Snapshot contract against server-ng (vsr): the server has no snapshot
+//! primitive, so `GET_SNAPSHOT_FILE` must surface a typed `FeatureUnavailable`
+//! over the SDK rather than the non-replicated catch-all's empty-ok, which would
+//! attest a zero-byte snapshot artifact the server never produced.
+
+use iggy::prelude::*;
+use integration::iggy_harness;
+
+#[iggy_harness(
+    test_client_transport = [Tcp],
+    server(tcp.socket.override_defaults = true, tcp.socket.nodelay = true)
+)]
+async fn given_authenticated_client_when_requesting_snapshot_should_reject_feature_unavailable(
+    harness: &TestHarness,
+) {
+    let client = harness.tcp_root_client().await.expect("tcp root client");
+
+    // Snapshot is ungated read access, so an authenticated client reaches the
+    // response builder; the deny must be feature-unavailable, not an authz error.
+    let result = client
+        .snapshot(SnapshotCompression::Stored, vec![SystemSnapshotType::Test])
+        .await;
+
+    assert!(
+        matches!(&result, Err(error) if error.as_code() == IggyError::FeatureUnavailable.as_code()),
+        "snapshot over TCP-ng must surface Err(FeatureUnavailable), got {result:?}"
+    );
+}
diff --git a/core/message_bus/src/lib.rs b/core/message_bus/src/lib.rs
index fc96d7afb..9679c6906 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
@@ -1325,6 +1334,18 @@ 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())
+        .map_or(0, u64::from_ne_bytes)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1375,6 +1396,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..5858ab906 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,7 +785,8 @@ 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`].
@@ -595,8 +800,11 @@ impl ReplicaRegistry {
         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 +835,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 +880,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 +907,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 +1277,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 +1293,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 +1318,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 +1343,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..48142c9d4 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
@@ -825,6 +826,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 +849,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 +987,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 +1028,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 +1413,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 +1713,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 +1725,38 @@ 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 `user_id` 0 = root, 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 +1928,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 +1946,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 +2031,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 +2053,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
@@ -2200,6 +2241,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 (CreateTopic/CreatePartitions assignment forms,
+        // TruncatePartition), which would otherwise reset it to 0 via
+        // `..Default::default()`.
+        user_id: request.user_id,
         ..Default::default()
     };
 
@@ -2216,12 +2264,47 @@ 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 a stamp
+///   would zero it) 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 +2400,180 @@ 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 `user_id` 0 = root.
+        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..35bfdf122
--- /dev/null
+++ b/core/metadata/src/stm/authz.rs
@@ -0,0 +1,758 @@
+// 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 so
+/// the gate is correct even in a cluster that provisions no users (the
+/// deterministic simulator boots none, so its permissioner is empty).
+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..edc9841f2 100644
--- a/core/metadata/src/stm/result.rs
+++ b/core/metadata/src/stm/result.rs
@@ -192,9 +192,18 @@ result_enum!(UpdateUserResult {
     UserNotFound = 20,
     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,9 +213,19 @@ 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 });
 
+/// `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).
 ///
 /// The state machine only commits codes from the op's own enum, so an
@@ -215,6 +234,11 @@ result_enum!(DeleteConsumerGroupResult { NotFound = 5000 });
 /// 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 +349,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 +415,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 +444,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();
@@ -451,6 +497,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 +538,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..a60111309 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;
 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,15 @@ 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))
+    }
+
     /// Collect `(user_id, name)` for every expired personal access token.
     ///
     /// Walks the expiry-ordered `personal_access_token_expiry_index`,
@@ -155,6 +165,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,6 +204,22 @@ 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
@@ -360,6 +414,16 @@ impl StateHandler for CreateUserRequest {
             .personal_access_tokens
             .insert(id as UserId, AHashMap::default());
 
+        // The permissioner is a denormalized index of `User.permissions`;
+        // derive it from the just-stored user so the two never drift.
+        let user_permissions = state
+            .items
+            .get(id)
+            .and_then(|user| user.permissions.as_deref().cloned());
+        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(
@@ -419,6 +483,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 +508,9 @@ impl StateHandler for DeleteUserRequest {
                     }
                 }
             }
+            state
+                .permissioner
+                .delete_permissions_for_user(user_id as UserId);
         }
         ApplyReply::ok(Bytes::new())
     }
@@ -447,6 +523,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 +542,33 @@ 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
+            .items
+            .get(user_id)
+            .and_then(|user| user.permissions.as_deref().cloned());
+        state
+            .permissioner
+            .update_permissions_for_user(user_id as UserId, user_permissions);
         ApplyReply::ok(Bytes::new())
     }
 }
@@ -650,43 +752,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 +828,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 +855,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 +1009,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 +1080,341 @@ 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);
+    }
 }
diff --git a/core/partitions/src/iggy_partition.rs b/core/partitions/src/iggy_partition.rs
index b3c5098af..bbb2c425e 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -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,
 };
@@ -1052,30 +1052,40 @@ 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(
-                    message.header(),
-                    self.consensus().commit_max(),
-                    error.as_code(),
-                );
-                let _ = self
-                    .consensus()
+                // 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.
+                let reply =
+                    build_deny_reply_from_request(consensus, message.header(), error.as_code());
+                if let Err(send_error) = consensus
                     .message_bus()
-                    .send_to_client(client_id, reply.into_generic().into_frozen())
-                    .await;
+                    .send_to_client(message.header().client, reply.into_generic().into_frozen())
+                    .await
+                {
+                    emit_partition_diag(
+                        tracing::Level::WARN,
+                        &PartitionDiagEvent::new(
+                            ReplicaLogContext::from_consensus(consensus, PlaneKind::Partitions),
+                            "delete_consumer_offset deny reply send failed",
+                        )
+                        .with_operation(message.header().operation)
+                        .with_error(send_error.to_string()),
+                    );
+                }
                 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 +1104,26 @@ where
                         .with_operation(message.header().operation)
                         .with_error(IggyError::InvalidOffset(requested_offset).to_string()),
                     );
-                    let reply = build_result_rejection_reply(
+                    let reply = build_deny_reply_from_request(
+                        consensus,
                         message.header(),
-                        self.consensus().commit_max(),
                         IggyError::InvalidOffset(requested_offset).as_code(),
                     );
-                    let _ = self
-                        .consensus()
+                    if let Err(send_error) = consensus
                         .message_bus()
-                        .send_to_client(client_id, reply.into_generic().into_frozen())
-                        .await;
+                        .send_to_client(message.header().client, reply.into_generic().into_frozen())
+                        .await
+                    {
+                        emit_partition_diag(
+                            tracing::Level::WARN,
+                            &PartitionDiagEvent::new(
+                                ReplicaLogContext::from_consensus(consensus, PlaneKind::Partitions),
+                                "store_consumer_offset deny reply send failed",
+                            )
+                            .with_operation(message.header().operation)
+                            .with_error(send_error.to_string()),
+                        );
+                    }
                     return;
                 }
             }
@@ -1839,7 +1859,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(
@@ -1894,14 +1914,6 @@ where
                 &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 {
                 let reply_buffers = reply.into_generic().into_frozen();
                 emit_sim_event(SimEventKind::ClientReplyEmitted, &event);
@@ -2840,9 +2852,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 +2882,151 @@ 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"
+        );
+    }
+
     /// `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/sdk/src/vsr.rs b/core/sdk/src/vsr.rs
index 54fdb273c..28c1659ee 100644
--- a/core/sdk/src/vsr.rs
+++ b/core/sdk/src/vsr.rs
@@ -114,7 +114,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 +179,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 +209,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 +219,21 @@ pub(crate) fn decode_response_split(
     }
 }
 
+/// Peek `ReplyHeader.status`, the request-level error channel decided at
+/// dispatch time (authorization denial today). 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`]; metadata replies always stamp 0,
+/// so this is inert for them.
+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`
@@ -533,6 +554,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 bf42650ed..fd9193c5b 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/src/auth.rs b/core/server-ng/src/auth.rs
index 7e6c4627b..2f555e7de 100644
--- a/core/server-ng/src/auth.rs
+++ b/core/server-ng/src/auth.rs
@@ -37,8 +37,25 @@ 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. The password is a throwaway no
+/// account can present (login rejects out-of-range usernames before any hash
+/// check); computed once via the same 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 +71,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 +100,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 +135,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 +186,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 46bd45c29..da8b94bd2 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -15,12 +15,14 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use crate::auth::warm_dummy_password_hash;
 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,
 };
@@ -514,6 +516,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
@@ -1811,7 +1814,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,
@@ -1819,18 +1822,37 @@ 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, self_ports).await?;
+    }
+
+    Ok(())
 }
 
 // ws/wss bindings intentionally mirror the transport names (same convention as
diff --git a/core/server-ng/src/dispatch.rs b/core/server-ng/src/dispatch.rs
index 421ebf25c..a31de486d 100644
--- a/core/server-ng/src/dispatch.rs
+++ b/core/server-ng/src/dispatch.rs
@@ -33,10 +33,11 @@ use crate::consumer_group::{
 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, resolve_stream_id, resolve_topic_id,
 };
 use crate::session_manager::SessionManager;
 use crate::users::maybe_rewrite_user_password_request;
@@ -47,17 +48,23 @@ use consensus::{
     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,
+    GET_CLIENT_CODE, GET_CLIENTS_CODE, GET_CLUSTER_METADATA_CODE, GET_CONSUMER_GROUP_CODE,
+    GET_CONSUMER_GROUPS_CODE, GET_CONSUMER_OFFSET_CODE, GET_ME_CODE,
+    GET_PERSONAL_ACCESS_TOKENS_CODE, GET_STATS_CODE, GET_STREAM_CODE, GET_STREAMS_CODE,
+    GET_TOPIC_CODE, GET_TOPICS_CODE, GET_USER_CODE, GET_USERS_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_groups::{
+    GetConsumerGroupRequest, GetConsumerGroupsRequest, SyncConsumerGroupRequest,
+};
 use iggy_binary_protocol::requests::consumer_offsets::GetConsumerOffsetRequest;
 use iggy_binary_protocol::requests::messages::PollMessagesRequest;
 use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
+use iggy_binary_protocol::requests::streams::GetStreamRequest;
 use iggy_binary_protocol::requests::system::get_client::GetClientRequest;
+use iggy_binary_protocol::requests::topics::{GetTopicRequest, GetTopicsRequest};
 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;
@@ -65,7 +72,7 @@ use iggy_binary_protocol::responses::clients::get_clients::GetClientsResponse;
 use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
 use iggy_binary_protocol::{
     ClientVersionInfo, EvictionReason, GenericHeader, KIND_CONSUMER_GROUP, Operation,
-    ProtocolVersion, RequestHeader, WireDecode, WireEncode, is_protocol_compatible,
+    ProtocolVersion, RequestHeader, WireDecode, WireEncode, WireIdentifier, is_protocol_compatible,
 };
 use iggy_common::{IggyError, PollingStrategy};
 use message_bus::client_listener::RequestHandler;
@@ -74,6 +81,7 @@ use message_bus::{IggyMessageBus, MessageBus};
 use metadata::impls::metadata::{
     MetadataSubmitError, StreamsFrontend, build_truncate_partition_client_message,
 };
+use metadata::permissioner::Permissioner;
 use partitions::{PollPlan, PollingArgs, PollingConsumer};
 use secrecy::ExposeSecret;
 use server_common::Message;
@@ -319,12 +327,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);
@@ -549,90 +558,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 +599,35 @@ 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 verify ChangePassword's current password on the
+    // primary before replication (CreateUser / ChangePassword); see
+    // `crate::users`. Replicas store the hash directly.
+    let request = match maybe_rewrite_user_password_request(shard, request) {
         Ok(rewritten) => rewritten,
         Err(error) => {
+            // Deny fast with the typed code (InvalidCredentials on a failed
+            // current-password check, InvalidCommand on a malformed body): 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,7 +734,135 @@ 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>>,
@@ -784,6 +871,10 @@ async fn handle_non_replicated_request(
 ) {
     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 +906,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 +930,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`.
@@ -872,15 +973,20 @@ async fn handle_non_replicated_request(
                 .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,
+        _ => {
+            handle_default_non_replicated(shard, transport_client_id, code, &request, user_id)
+                .await;
+        }
     }
 }
 
@@ -890,8 +996,16 @@ async fn handle_default_non_replicated(
     transport_client_id: u128,
     code: u32,
     request: &Message<RequestHeader>,
+    user_id: Option<u32>,
 ) {
-    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) {
         Ok(response) => {
             let commit = current_metadata_commit(shard);
             let reply = response.into_reply(
@@ -914,12 +1028,17 @@ 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;
         }
     }
 }
@@ -1091,8 +1210,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;
+        return;
+    };
+    // Gate on (stream, topic) before touching the partition plane. A resolution
+    // miss falls through to the resolve path below (empty-poll / not-found); a
+    // denial replies status!=0 with an empty body, distinct from the empty-poll
+    // "0 messages" shape.
+    if let Some(status) = authorize_partition_read(
+        shard,
+        &wire.stream_id,
+        &wire.topic_id,
+        user_id,
+        |permissioner, uid, stream_id, topic_id| {
+            permissioner.poll_messages(uid, stream_id, topic_id)
+        },
+    ) {
+        send_non_replicated_deny(shard, request, transport_client_id, status).await;
+        return;
+    }
+    let body = match resolve_poll_request(shard, &wire, request.header().client) {
         Ok((namespace, partition_id, consumer, args)) => {
             match shard
                 .partition_read(namespace, PartitionRead::Poll { consumer, args })
@@ -1150,8 +1298,33 @@ async fn handle_get_consumer_offset(
     shard: &Rc<ServerNgShard>,
     transport_client_id: u128,
     request: &Message<RequestHeader>,
+    user_id: Option<u32>,
 ) {
-    let body = match decode_consumer_offset_request(shard, request) {
+    let Ok(wire) = GetConsumerOffsetRequest::decode_from(request_body(request)) else {
+        // Undecodable: an empty body decodes as None (no offset) on the SDK.
+        send_non_replicated_bytes(
+            shard,
+            request,
+            transport_client_id,
+            Bytes::new(),
+            "get_consumer_offset",
+        )
+        .await;
+        return;
+    };
+    if let Some(status) = authorize_partition_read(
+        shard,
+        &wire.stream_id,
+        &wire.topic_id,
+        user_id,
+        |permissioner, uid, stream_id, topic_id| {
+            permissioner.get_consumer_offset(uid, stream_id, topic_id)
+        },
+    ) {
+        send_non_replicated_deny(shard, request, transport_client_id, status).await;
+        return;
+    }
+    let body = match resolve_consumer_offset_request(shard, &wire) {
         Ok((namespace, partition_id, consumer)) => {
             match shard
                 .partition_read(namespace, PartitionRead::ConsumerOffset { consumer })
@@ -1257,6 +1430,270 @@ async fn send_empty_partition_reply(
     }
 }
 
+// Dispatch-time authorization
+//
+// The metadata STM enforces RBAC in-apply for replicated control ops. What
+// that gate cannot see -- partition-plane ops and non-replicated reads, both
+// decided on the connection's own shard without a replicated apply -- is gated
+// here against the live permissioner via `Users::authorize`. A denial rides
+// `ReplyHeader.status` (empty body), the request-level error channel the SDK
+// peeks before body decode. Root holds every grant in the permissioner, so the
+// rules pass for it without any user-id short-circuit.
+
+/// Authorize a partition-plane op on its resolved (stream, topic) for the
+/// acting user, returning the deny status code or `None` to proceed. The
+/// namespace already resolved, so the entity exists; a `None` user id (which
+/// the bound-session gate should preclude) fails closed with `Unauthenticated`
+/// rather than allow an unattributed write.
+fn authorize_partition_op(
+    shard: &Rc<ServerNgShard>,
+    operation: Operation,
+    user_id: Option<u32>,
+    stream_id: usize,
+    topic_id: usize,
+) -> Option<u32> {
+    let Some(user_id) = user_id else {
+        return Some(IggyError::Unauthenticated.as_code());
+    };
+    let decision =
+        shard
+            .plane
+            .metadata()
+            .mux_stm
+            .users()
+            .authorize(|permissioner| match operation {
+                Operation::SendMessages => {
+                    permissioner.append_messages(user_id, stream_id, topic_id)
+                }
+                Operation::StoreConsumerOffset | Operation::StoreConsumerOffset2 => {
+                    permissioner.store_consumer_offset(user_id, stream_id, topic_id)
+                }
+                Operation::DeleteConsumerOffset | Operation::DeleteConsumerOffset2 => {
+                    permissioner.delete_consumer_offset(user_id, stream_id, topic_id)
+                }
+                // The caller only routes the five partition ops above here; any
+                // other op is an allow, never a surprise denial.
+                _ => Ok(()),
+            });
+    decision.err().map(|error| error.as_code())
+}
+
+/// Reply to a denied partition op with the op's frame: empty body + nonzero
+/// `status`. Distinct from [`send_empty_partition_reply`] (status 0, the
+/// fail-fast "not routable" ack); here the SDK peeks the status and surfaces
+/// the typed authorization error. Same lockstep reasoning: a silent drop would
+/// wedge every later request on the connection.
+#[allow(clippy::future_not_send)]
+async fn send_partition_deny_reply(
+    shard: &Rc<ServerNgShard>,
+    transport_client_id: u128,
+    request_header: &RequestHeader,
+    status: u32,
+) {
+    let commit = current_metadata_commit(shard);
+    let reply = build_deny_reply(request_header, transport_client_id, 0, commit, status);
+    if let Err(error) = shard
+        .bus
+        .send_to_client(transport_client_id, reply.into_generic().into_frozen())
+        .await
+    {
+        warn!(
+            transport_client_id,
+            status,
+            error = %error,
+            operation = ?request_header.operation,
+            "failed to surface partition authz denial"
+        );
+    }
+}
+
+/// Run an unscoped non-replicated-read rule for the acting user. A `None` user
+/// id (only the pre-auth path, which serves ungated codes) fails closed.
+fn authorize_uid(
+    shard: &Rc<ServerNgShard>,
+    user_id: Option<u32>,
+    rule: impl FnOnce(&Permissioner, u32) -> Result<(), IggyError>,
+) -> Result<(), IggyError> {
+    let user_id = user_id.ok_or(IggyError::Unauthenticated)?;
+    shard
+        .plane
+        .metadata()
+        .mux_stm
+        .users()
+        .authorize(|permissioner| rule(permissioner, user_id))
+}
+
+/// Authorize a partition-plane non-replicated read (poll / consumer-offset) on
+/// (stream, topic). `None` proceeds (allowed, or a resolution miss the caller's
+/// own not-found path handles); `Some(status)` denies. A `None` user id fails
+/// closed.
+fn authorize_partition_read(
+    shard: &Rc<ServerNgShard>,
+    stream_id: &WireIdentifier,
+    topic_id: &WireIdentifier,
+    user_id: Option<u32>,
+    rule: impl FnOnce(&Permissioner, u32, usize, usize) -> Result<(), IggyError>,
+) -> Option<u32> {
+    let Some(user_id) = user_id else {
+        return Some(IggyError::Unauthenticated.as_code());
+    };
+    let (stream_id, topic_id) = resolve_topic_scope(shard, stream_id, topic_id)?;
+    shard
+        .plane
+        .metadata()
+        .mux_stm
+        .users()
+        .authorize(|permissioner| rule(permissioner, user_id, stream_id, topic_id))
+        .err()
+        .map(|error| error.as_code())
+}
+
+/// Authorize a non-replicated read routed through `build_non_replicated_response`
+/// (`handle_default_non_replicated`). `Ok(())` allows -- including a resolution
+/// miss, which falls through to the builder's own not-found reply so the legacy
+/// notfound-before-permission ordering holds. `Err` denies with that code.
+/// Unscoped rules gate directly; identifier-scoped rules resolve (stream[,
+/// topic]) against committed state first. The PAT list is self-scoped, so
+/// authentication is its whole rule. `GET_CLUSTER_METADATA` is auth-only
+/// (bootstrap / leader discovery) and every other code the builder serves is
+/// ungated here.
+fn authorize_default_read(
+    shard: &Rc<ServerNgShard>,
+    code: u32,
+    body: &[u8],
+    user_id: Option<u32>,
+) -> Result<(), IggyError> {
+    match code {
+        GET_STATS_CODE => authorize_uid(shard, user_id, Permissioner::get_stats),
+        GET_USERS_CODE => authorize_uid(shard, user_id, Permissioner::get_users),
+        GET_USER_CODE => authorize_uid(shard, user_id, Permissioner::get_user),
+        // Self-scoped: lists only the caller's own tokens, so there is no
+        // permissioner rule to run (legacy runs none either).
+        GET_PERSONAL_ACCESS_TOKENS_CODE => user_id.map(|_| ()).ok_or(IggyError::Unauthenticated),
+        GET_STREAMS_CODE => authorize_uid(shard, user_id, Permissioner::get_streams),
+        GET_STREAM_CODE => {
+            let Ok(request) = GetStreamRequest::decode_from(body) else {
+                return Ok(());
+            };
+            let Some(stream_id) = resolve_stream_scope(shard, &request.stream_id) else {
+                return Ok(());
+            };
+            authorize_uid(shard, user_id, |permissioner, uid| {
+                permissioner.get_stream(uid, stream_id)
+            })
+        }
+        GET_TOPICS_CODE => {
+            let Ok(request) = GetTopicsRequest::decode_from(body) else {
+                return Ok(());
+            };
+            let Some(stream_id) = resolve_stream_scope(shard, &request.stream_id) else {
+                return Ok(());
+            };
+            authorize_uid(shard, user_id, |permissioner, uid| {
+                permissioner.get_topics(uid, stream_id)
+            })
+        }
+        GET_TOPIC_CODE => {
+            let Ok(request) = GetTopicRequest::decode_from(body) else {
+                return Ok(());
+            };
+            let Some((stream_id, topic_id)) =
+                resolve_topic_scope(shard, &request.stream_id, &request.topic_id)
+            else {
+                return Ok(());
+            };
+            authorize_uid(shard, user_id, |permissioner, uid| {
+                permissioner.get_topic(uid, stream_id, topic_id)
+            })
+        }
+        GET_CONSUMER_GROUP_CODE => {
+            let Ok(request) = GetConsumerGroupRequest::decode_from(body) else {
+                return Ok(());
+            };
+            let Some((stream_id, topic_id)) =
+                resolve_topic_scope(shard, &request.stream_id, &request.topic_id)
+            else {
+                return Ok(());
+            };
+            authorize_uid(shard, user_id, |permissioner, uid| {
+                permissioner.get_consumer_group(uid, stream_id, topic_id)
+            })
+        }
+        GET_CONSUMER_GROUPS_CODE => {
+            let Ok(request) = GetConsumerGroupsRequest::decode_from(body) else {
+                return Ok(());
+            };
+            let Some((stream_id, topic_id)) =
+                resolve_topic_scope(shard, &request.stream_id, &request.topic_id)
+            else {
+                return Ok(());
+            };
+            authorize_uid(shard, user_id, |permissioner, uid| {
+                permissioner.get_consumer_groups(uid, stream_id, topic_id)
+            })
+        }
+        _ => Ok(()),
+    }
+}
+
+/// Resolve a wire stream identifier to its committed slab id, or `None` on a
+/// miss (the gate then falls through to the builder's not-found reply).
+fn resolve_stream_scope(shard: &Rc<ServerNgShard>, stream_id: &WireIdentifier) -> Option<usize> {
+    shard
+        .plane
+        .metadata()
+        .mux_stm
+        .streams()
+        .read(|inner| resolve_stream_id(inner, stream_id))
+}
+
+/// Resolve a wire (stream, topic) pair to committed slab ids, or `None` if
+/// either misses.
+fn resolve_topic_scope(
+    shard: &Rc<ServerNgShard>,
+    stream_id: &WireIdentifier,
+    topic_id: &WireIdentifier,
+) -> Option<(usize, usize)> {
+    shard.plane.metadata().mux_stm.streams().read(|inner| {
+        let stream_id = resolve_stream_id(inner, stream_id)?;
+        let topic_id = resolve_topic_id(inner, stream_id, topic_id)?;
+        Some((stream_id, topic_id))
+    })
+}
+
+/// Reply to a denied non-replicated read with the request's reply frame: empty
+/// body + nonzero `status`. The SDK peeks the status before body decode and
+/// surfaces the typed error, so a poll denial never reaches the empty-poll
+/// "0 messages" body path.
+#[allow(clippy::future_not_send)]
+async fn send_non_replicated_deny(
+    shard: &Rc<ServerNgShard>,
+    request: &Message<RequestHeader>,
+    transport_client_id: u128,
+    status: u32,
+) {
+    let commit = current_metadata_commit(shard);
+    let reply = build_deny_reply(
+        request.header(),
+        request.header().client,
+        request.header().session,
+        commit,
+        status,
+    );
+    if let Err(error) = shard
+        .bus
+        .send_to_client(transport_client_id, reply.into_generic().into_frozen())
+        .await
+    {
+        warn!(
+            transport_client_id,
+            status,
+            error = %error,
+            "failed to surface non-replicated authz denial"
+        );
+    }
+}
+
 /// Wait (bounded) until `namespace` is routable: this shard's routing row
 /// exists and the owning shard answers a probe read (partition
 /// materialised). Fast path: row already present -> no probe, no wait.
@@ -1316,15 +1753,18 @@ fn empty_polled_messages_body(partition_id: u32) -> Bytes {
     Bytes::from(body)
 }
 
-type DecodedPollRequest = (IggyNamespace, u32, PollingConsumer, PollingArgs);
+pub(crate) type DecodedPollRequest = (IggyNamespace, u32, PollingConsumer, PollingArgs);
 
+/// Resolve a decoded poll request into its owning-shard read: namespace,
+/// partition, polling consumer, and args. Shared by the TCP dispatch (client
+/// id = the connection's bound VSR client) and the HTTP route (client id 0,
+/// which fences group polls closed).
 #[allow(clippy::cast_possible_truncation)]
-fn decode_poll_request(
+pub(crate) fn resolve_poll_request(
     shard: &Rc<ServerNgShard>,
-    request: &Message<RequestHeader>,
+    wire: &PollMessagesRequest,
+    client_id: u128,
 ) -> Result<DecodedPollRequest, IggyError> {
-    let wire = PollMessagesRequest::decode_from(request_body(request))
-        .map_err(|_| IggyError::InvalidCommand)?;
     let strategy = polling_strategy_from_wire(&wire.strategy)?;
     let args = PollingArgs::new(strategy, wire.count, wire.auto_commit);
 
@@ -1336,7 +1776,6 @@ fn decode_poll_request(
     // both use, so `next()` reads back the offset it just committed.
     if wire.consumer.kind == KIND_CONSUMER_GROUP {
         let partition_id = wire.partition_id.ok_or(IggyError::InvalidIdentifier)?;
-        let client_id = request.header().client;
         let group_id = shard
             .plane
             .metadata()
@@ -1374,12 +1813,14 @@ fn decode_poll_request(
     Ok((namespace, partition_id, consumer, args))
 }
 
-fn decode_consumer_offset_request(
+/// Resolve a decoded consumer-offset read into its owning-shard read:
+/// namespace, partition, and polling consumer. Shared by the TCP dispatch and
+/// the HTTP route; needs no client id because offset reads are not fenced
+/// (any client may read a group's offset, member or not).
+pub(crate) fn resolve_consumer_offset_request(
     shard: &Rc<ServerNgShard>,
-    request: &Message<RequestHeader>,
+    wire: &GetConsumerOffsetRequest,
 ) -> Result<(IggyNamespace, u32, PollingConsumer), IggyError> {
-    let wire = GetConsumerOffsetRequest::decode_from(request_body(request))
-        .map_err(|_| IggyError::InvalidCommand)?;
     let partition_id = wire.partition_id.ok_or(IggyError::InvalidIdentifier)?;
     let namespace =
         resolve_partition_namespace(shard, &wire.stream_id, &wire.topic_id, Some(partition_id))?;
@@ -1456,17 +1897,19 @@ fn polling_strategy_from_wire(
 pub(crate) async fn submit_register_on_owner(
     shard: &Rc<ServerNgShard>,
     vsr_client_id: u128,
+    user_id: u32,
 ) -> Result<u64, MetadataSubmitError> {
     if shard.id == 0 {
         return shard
             .plane
             .metadata()
-            .submit_register_in_process(vsr_client_id)
+            .submit_register_in_process(vsr_client_id, user_id)
             .await;
     }
     let (reply, rx) = shard::channel::<Option<u64>>(1);
     shard.forward_metadata_submit(shard::MetadataSubmit::Register {
         vsr_client_id,
+        user_id,
         reply,
     });
     match rx.recv().await {
@@ -1477,7 +1920,7 @@ pub(crate) async fn submit_register_on_owner(
 
 /// Logout counterpart of [`submit_register_on_owner`].
 #[allow(clippy::future_not_send)]
-async fn submit_logout_on_owner(
+pub(crate) async fn submit_logout_on_owner(
     shard: &Rc<ServerNgShard>,
     vsr_client_id: u128,
     session: u64,
@@ -1658,7 +2101,7 @@ fn submit_disconnect_logout(shard: Rc<ServerNgShard>, vsr_client_id: u128, sessi
 /// `client` id (it's the VSR id, not the transport/home-shard-encoding id).
 /// `None` = transient submit failure (SDK read-timeout replays).
 #[allow(clippy::future_not_send)]
-async fn submit_client_request_on_owner(
+pub(crate) async fn submit_client_request_on_owner(
     shard: &Rc<ServerNgShard>,
     request: Message<RequestHeader>,
 ) -> Option<Message<GenericHeader>> {
diff --git a/core/server-ng/src/http.rs b/core/server-ng/src/http.rs
new file mode 100644
index 000000000..51a7ab73e
--- /dev/null
+++ b/core/server-ng/src/http.rs
@@ -0,0 +1,3994 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Shard-0 HTTP/REST listener: state bridge, router, and the auth-issuance
+//! half (login + bearer extraction). Management and data-plane routes land
+//! later; this module owns only health, login, and the authenticated probe.
+
+mod extractor;
+mod jwt;
+
+use std::cell::{Cell, RefCell};
+use std::collections::HashMap;
+use std::collections::hash_map::Entry;
+use std::net::{IpAddr, SocketAddr};
+use std::rc::Rc;
+use std::time::Duration;
+
+use axum::extract::{DefaultBodyLimit, Path, Query, State};
+use axum::http::header::LOCATION;
+use axum::http::{HeaderName, HeaderValue, StatusCode};
+use axum::middleware::map_response;
+use axum::response::{IntoResponse, Response};
+use axum::routing::{delete, get, post, put};
+use axum::{Json, Router};
+use bytes::{Bytes, BytesMut};
+use configs::cluster::{ClusterConfig, ClusterNodeConfig, TransportPorts};
+use configs::http::HttpConfig;
+use consensus::{CLIENTS_TABLE_MAX, MetadataHandle, VsrConsensus};
+use futures::channel::oneshot;
+use iggy_binary_protocol::codes::{
+    GET_CONSUMER_GROUP_CODE, GET_CONSUMER_GROUPS_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE,
+    GET_STATS_CODE, GET_STREAM_CODE, GET_STREAMS_CODE, GET_TOPIC_CODE, GET_TOPICS_CODE,
+    GET_USER_CODE, GET_USERS_CODE,
+};
+use iggy_binary_protocol::consensus::{
+    Command2, EvictionHeader, EvictionReason, HEADER_SIZE, result_code, result_section_len,
+};
+use iggy_binary_protocol::primitives::consumer::WireConsumer;
+use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
+use iggy_binary_protocol::requests::consumer_groups::{
+    CreateConsumerGroupRequest, DeleteConsumerGroupRequest, GetConsumerGroupRequest,
+    GetConsumerGroupsRequest,
+};
+use iggy_binary_protocol::requests::consumer_offsets::{
+    DeleteConsumerOffset2Request, GetConsumerOffsetRequest, StoreConsumerOffset2Request,
+};
+use iggy_binary_protocol::requests::messages::{
+    PollMessagesRequest, RawMessage, SendMessagesEncoder,
+};
+use iggy_binary_protocol::requests::personal_access_tokens::{
+    CreatePersonalAccessTokenRequest, DeletePersonalAccessTokenRequest,
+    GetPersonalAccessTokensRequest,
+};
+use iggy_binary_protocol::requests::streams::{
+    CreateStreamRequest, DeleteStreamRequest, GetStreamRequest, GetStreamsRequest,
+    PurgeStreamRequest, UpdateStreamRequest,
+};
+use iggy_binary_protocol::requests::system::GetStatsRequest;
+use iggy_binary_protocol::requests::topics::{
+    CreateTopicRequest, DeleteTopicRequest, GetTopicRequest, GetTopicsRequest, PurgeTopicRequest,
+    UpdateTopicRequest,
+};
+use iggy_binary_protocol::requests::users::{
+    ChangePasswordRequest, CreateUserRequest, DeleteUserRequest, GetUserRequest, GetUsersRequest,
+    UpdatePermissionsRequest, UpdateUserRequest,
+};
+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::get_consumer_group::ConsumerGroupDetailsResponse;
+use iggy_binary_protocol::responses::consumer_groups::get_consumer_groups::GetConsumerGroupsResponse;
+use iggy_binary_protocol::responses::personal_access_tokens::{
+    GetPersonalAccessTokensResponse, RawPersonalAccessTokenResponse,
+};
+use iggy_binary_protocol::responses::streams::get_stream::GetStreamResponse;
+use iggy_binary_protocol::responses::streams::get_streams::GetStreamsResponse;
+use iggy_binary_protocol::responses::system::get_stats::StatsResponse;
+use iggy_binary_protocol::responses::topics::get_topic::GetTopicResponse;
+use iggy_binary_protocol::responses::topics::get_topics::GetTopicsResponse;
+use iggy_binary_protocol::responses::users::get_user::UserDetailsResponse;
+use iggy_binary_protocol::responses::users::get_users::GetUsersResponse;
+use iggy_binary_protocol::version::IGGY_PROTOCOL_VERSION;
+use iggy_binary_protocol::{
+    AckLevel, GenericHeader, Operation, ReplyHeader, RequestHeader, WireDecode, WireEncode,
+    WireIdentifier, WireName,
+};
+use iggy_common::change_password::ChangePassword;
+use iggy_common::create_consumer_group::CreateConsumerGroup;
+use iggy_common::create_personal_access_token::CreatePersonalAccessToken;
+use iggy_common::create_stream::CreateStream;
+use iggy_common::create_topic::CreateTopic;
+use iggy_common::create_user::CreateUser;
+use iggy_common::delete_consumer_offset::DeleteConsumerOffset;
+use iggy_common::get_consumer_offset::GetConsumerOffset;
+// `DEFAULT_ROOT_USER_ID` is only referenced by the test module now that the
+// interim root-only gates are gone; its import lives there.
+use iggy_common::login_user::LoginUser;
+use iggy_common::login_with_personal_access_token::LoginWithPersonalAccessToken;
+use iggy_common::poll_messages::DEFAULT_PARTITION_ID;
+use iggy_common::store_consumer_offset::StoreConsumerOffset;
+use iggy_common::update_permissions::UpdatePermissions;
+use iggy_common::update_stream::UpdateStream;
+use iggy_common::update_topic::UpdateTopic;
+use iggy_common::update_user::UpdateUser;
+use iggy_common::wire_conversions::{
+    clients_from_wire, consumer_groups_from_wire, consumer_to_wire, identifier_to_wire,
+    partitioning_to_wire, permissions_to_wire, personal_access_tokens_from_wire, streams_from_wire,
+    topics_from_wire, users_from_wire,
+};
+use iggy_common::{
+    ClientInfo, ClientInfoDetails, ClusterMetadata, ClusterNode, ClusterNodeRole,
+    ClusterNodeStatus, Consumer, ConsumerGroup, ConsumerGroupDetails, ConsumerOffsetInfo,
+    Identifier, IdentityInfo, IggyError, IggyMessageView, IggyTimestamp, PersonalAccessTokenInfo,
+    PollMessages, PolledMessages, RESYNC_REQUIRED_PARTITION_SENTINEL, RawPersonalAccessToken,
+    SendMessages, Stats, Stream, StreamDetails, TokenInfo, Topic, TopicDetails, TransportEndpoints,
+    UserInfo, UserInfoDetails, Validatable,
+};
+use message_bus::{BusMessage, InstanceToken, client_listener};
+use metadata::impls::metadata::StreamsFrontend;
+use metadata::permissioner::Permissioner;
+use secrecy::ExposeSecret;
+use send_wrapper::SendWrapper;
+use serde::Deserialize;
+use server::http::error::{CustomError, ErrorResponse};
+use server_common::Message;
+use shard::{PartitionRead, PartitionReadReply};
+use tokio::sync::Mutex;
+use tracing::{error, info, warn};
+
+use crate::auth::{verify_login_credentials, verify_pat_credentials};
+use crate::bootstrap::ServerNgShard;
+use crate::dispatch::{
+    dispatch_partition_request, resolve_consumer_offset_request, resolve_poll_request,
+    submit_client_request_on_owner, submit_logout_on_owner, submit_register_on_owner,
+};
+use crate::http::extractor::{Authenticated, Identity};
+use crate::http::jwt::JwtManager;
+use crate::login_register::LoginRegisterError;
+use crate::pat::rewrite_pat_request_for_user;
+use crate::responses::{
+    NonReplicatedResponse, build_non_replicated_response, build_polled_messages_body,
+    build_raw_pat_reply, connected_client_to_response, resolve_stream_id, resolve_topic_id,
+};
+use crate::server_error::ServerNgError;
+use crate::users::maybe_rewrite_user_password_request;
+
+/// `GET /ping` response body, matching the legacy HTTP server's health probe.
+const PONG: &str = "pong";
+
+/// Hard cap on live per-credential sessions. A leak-guard, not a tuning knob:
+/// reaching it means this many distinct live tokens are in flight at once
+/// (pathological at P1 volume). New sessions past the cap are refused with a
+/// transient 503 rather than evicting a live one; the client retries. Expired
+/// entries are dropped first, so the cap only bites on live oversubscription.
+///
+/// Bounded by the shared VSR client table: HTTP sessions and the TCP/QUIC/WS
+/// virtual clients all Register into the one [`CLIENTS_TABLE_MAX`]-slot table,
+/// which LRU-evicts the oldest client when full. Capping HTTP at half that
+/// bound keeps this plane from crowding the others out and keeps the combined
+/// steady state under the shared bound, so a live idle HTTP session is not
+/// routinely evicted consensus-side. The residual eviction race (both planes
+/// busy) degrades gracefully: an evicted session's next control write is
+/// classified as an eviction and re-registers (see [`HttpInner::forget_session`]).
+const MAX_HTTP_SESSIONS: usize = CLIENTS_TABLE_MAX / 2;
+
+/// First per-session request id the write path hands out. VSR request numbers
+/// are 1-based and strictly increasing within a session.
+const FIRST_REQUEST_ID: u64 = 1;
+
+/// Bound on a partition write's (produce / consumer-offset write) wait for its
+/// committed reply. Long enough to ride out a view change (plus the dispatch
+/// gates' own routable-wait budget), short enough not to pin HTTP connections
+/// behind a dead consensus group. On expiry the caller gets 504 and must treat
+/// the outcome as unknown: the partition plane is at-least-once and the prepare
+/// may still commit after the wait gave up, so the server never retries on the
+/// caller's behalf.
+const PARTITION_WRITE_REPLY_TIMEOUT: Duration = Duration::from_secs(10);
+
+/// Per-session cap on concurrently awaited partition writes (produce /
+/// consumer-offset). Bounds how much of [`MAX_IN_FLIGHT_WRITES_GLOBAL`] one
+/// credential can occupy, so a session that outruns its own commits saturates
+/// itself (429, its own backpressure signal) before it can starve every other
+/// session out of the shared budget.
+const MAX_IN_FLIGHT_WRITES_PER_SESSION: u32 = 32;
+
+/// Shard-0-global budget for concurrently awaited partition writes, across all
+/// sessions. Every admitted write parks a handler for up to
+/// [`PARTITION_WRITE_REPLY_TIMEOUT`] while pinning its buffered body, and its
+/// decode/encode/HS256 CPU runs on the same single-threaded core that pumps
+/// consensus. The budget therefore bounds both starvation terms: budget x
+/// `max_request_size` bounds the worst-case buffered bytes, and budget x
+/// per-request CPU bounds how far admitted HTTP work can delay the consensus
+/// pump. `?ack=none` produces sit outside the budget by design: they never
+/// park a handler, so their whole cost is the synchronous handler body itself,
+/// which admission could not shed anyway.
+const MAX_IN_FLIGHT_WRITES_GLOBAL: u32 = 128;
+
+/// VSR client id stamped on HTTP data-plane reads (poll / consumer-offset).
+/// HTTP reads never Register a VSR client, and shard-0 client ids are minted
+/// from 1, so 0 can never name a live consumer-group member: a group-kind
+/// poll fences closed with `ConsumerGroupPartitionNotOwned` and answers the
+/// re-sync sentinel empty poll - the same failure a stale TCP member sees.
+/// Legacy HTTP polls with client id 0 for the same reason (no persistent
+/// sessions, so no group membership).
+const HTTP_READ_CLIENT_ID: u128 = 0;
+
+/// Response header attesting what durability a produce response proves:
+/// [`DURABILITY_REPLICATED_MEMORY`] after an awaited quorum commit,
+/// [`DURABILITY_NONE`] for a `?ack=none` fire-and-forget.
+const DURABILITY_HEADER: HeaderName = HeaderName::from_static("x-iggy-durability");
+const DURABILITY_REPLICATED_MEMORY: &str = "replicated-memory";
+const DURABILITY_NONE: &str = "none";
+
+/// Node name reported for the synthesized single self-node, matching the legacy
+/// server's single-node synthesis.
+const SELF_NODE_NAME: &str = "iggy-node";
+
+/// Cluster name reported when no roster is configured, matching the legacy
+/// server's single-node synthesis.
+const SINGLE_NODE_CLUSTER_NAME: &str = "single-node";
+
+/// `{user_id}` segment alias resolving to the caller in `GET /users/{user_id}`.
+/// Handled inside the handler rather than as a static `/users/me` route so it
+/// cannot shadow `{user_id}` matching (a user literally named "me" stays
+/// reachable by numeric id).
+const CURRENT_USER_ALIAS: &str = "me";
+
+/// Response header carrying the current VSR view number, set on every response
+/// while this shard-0 node has live consensus.
+const VIEW_HEADER: HeaderName = HeaderName::from_static("x-iggy-view");
+
+/// One VSR session established for a single login credential (a JWT `jti` or a
+/// PAT). Shared via `Rc` by every concurrent request bearing that credential,
+/// so the session granularity is per-login.
+struct HttpSession {
+    /// Session-table key this entry lives under (`jwt:{jti}` / `pat:{sha}`).
+    /// Held so an eviction observed on the write path can remove exactly this
+    /// entry (see [`HttpInner::forget_session`]) without threading the key
+    /// through the whole write chain.
+    key: String,
+    /// Shard-0 client id minted for this credential; its top 16 bits are 0, so
+    /// it shares the shard-0 id space with TCP virtual clients without
+    /// colliding. Fills `RequestHeader.client` on every write.
+    client_id: u128,
+    /// Cluster session number returned by the VSR `Register` commit. Fills
+    /// `RequestHeader.session` on every write.
+    session: u64,
+    /// User the credential authenticated as. Consumed by the write path for
+    /// authorization.
+    user_id: u32,
+    /// Credential expiry in unix seconds (`u64::MAX` = never). Drives lazy
+    /// eviction of stale table entries.
+    expiry: u64,
+    /// Serializes this session's writes: the guarded value is the NEXT request
+    /// id. A `tokio::sync::Mutex` because the write path holds it across the
+    /// submit `.await` so each session's request numbers reach the primary in
+    /// order and stay gap-free for the depth-1 consensus dedup.
+    gate: Mutex<u64>,
+    /// Next data-plane request id. A separate, gate-free counter: partition ops
+    /// are at-least-once with no consensus dedup, so the id only correlates the
+    /// in-process reply slot and concurrent produces on one session are legal.
+    /// A plain `Cell` suffices on single-threaded shard 0; ids are minted
+    /// monotonically and never reused, which the slot-guard contract requires.
+    data_request: Cell<u64>,
+    /// Registry token of this session's lazily-installed in-process reply
+    /// target (`None` until the first awaited partition write). Stored so
+    /// session eviction can tear the registry entry down fenced by the same
+    /// token.
+    registry_token: Cell<Option<InstanceToken>>,
+    /// Awaited partition writes currently in flight on this session, gated by
+    /// [`MAX_IN_FLIGHT_WRITES_PER_SESSION`]. Only [`InFlightWriteGuard`]
+    /// touches it, so every admission is paired with exactly one release.
+    in_flight_writes: Cell<u32>,
+}
+
+impl HttpSession {
+    /// Mint the next data-plane request id. Also consumed by the `?ack=none`
+    /// path, which installs no slot: sharing one counter keeps a shed reply's
+    /// id from ever colliding with a live awaited slot on this session.
+    fn next_data_request_id(&self) -> u64 {
+        let id = self.data_request.get();
+        self.data_request.set(id + 1);
+        id
+    }
+}
+
+/// Rejection for protected routes.
+///
+/// Two failure classes get two statuses: a missing, invalid, or expired
+/// credential is the caller's fault (401, rendered exactly like the legacy
+/// server so SDK error bodies match), while a VSR `Register` that cannot commit
+/// right now is a transient server condition (503) and must never masquerade as
+/// an auth failure.
+pub enum AuthError {
+    Unauthenticated(IggyError),
+    SessionUnavailable,
+}
+
+impl From<IggyError> for AuthError {
+    fn from(error: IggyError) -> Self {
+        Self::Unauthenticated(error)
+    }
+}
+
+impl IntoResponse for AuthError {
+    fn into_response(self) -> Response {
+        match self {
+            // Reuse the legacy `IggyError -> CustomError` rendering so 401
+            // bodies stay byte-identical to what the SDKs are tested against.
+            Self::Unauthenticated(error) => CustomError::from(error).into_response(),
+            // Register could not commit (no caught-up primary, pipeline full, or
+            // a view-change cancel). Transient server condition -> 503, retryable.
+            Self::SessionUnavailable => service_unavailable(),
+        }
+    }
+}
+
+/// Rejection for an authenticated control-plane write (`POST /streams` and the
+/// writes that follow it).
+///
+/// Same two-class split as [`AuthError`], for the same reasons: a caller-side
+/// validation failure or a committed business rejection (e.g. a duplicate
+/// stream name) renders through the legacy `IggyError -> CustomError` map so
+/// SDK error bodies stay byte-identical, while a write that cannot commit right
+/// now is a transient server condition (503) and must never surface as a
+/// business error or, worse, a 200 with a stale body.
+enum WriteError {
+    Rejected(IggyError),
+    /// The VSR session was evicted (its client slot was reclaimed cluster-side,
+    /// e.g. LRU-evicted from the full client table). Renders identically to a
+    /// terminal `Rejected` (401 -> re-authenticate), but is a distinct variant
+    /// so the submit path can drop the dead session entry and let the caller's
+    /// next request re-register cleanly instead of 401-looping on it.
+    Evicted(IggyError),
+    Unavailable,
+}
+
+impl IntoResponse for WriteError {
+    fn into_response(self) -> Response {
+        match self {
+            Self::Rejected(error) | Self::Evicted(error) => {
+                CustomError::from(error).into_response()
+            }
+            Self::Unavailable => service_unavailable(),
+        }
+    }
+}
+
+/// Rejection for a data-plane partition write (`POST .../messages` produce and
+/// the `PUT`/`DELETE .../consumer-offsets` writes).
+///
+/// Split differently from [`WriteError`] because the partition plane replies
+/// carry no committed error code: a pre-dispatch gate failure is an empty
+/// reply distinguishable only by header (see [`classify_partition_reply`]),
+/// and an unanswered write is a distinct outcome the caller must treat as
+/// unknown rather than failed.
+#[derive(Debug)]
+enum PartitionWriteError {
+    /// Caller-side rejection (bad identifier, oversized batch, an authorization
+    /// denial), a typed pre-commit deny from the partition plane
+    /// (`ReplyHeader.status`), or a malformed reply frame, rendered through the
+    /// legacy `IggyError -> status` map for SDK-identical bodies.
+    Rejected(IggyError),
+    /// The dispatch gates could not route the write: the stream, topic, or
+    /// partition does not resolve (or never materialised within the routable
+    /// budget). Rendered as the legacy 404 body.
+    NotFound,
+    /// The in-process reply slot could not be installed. Transient server
+    /// condition -> the shared 503, retryable.
+    Unavailable,
+    /// This session is already at [`MAX_IN_FLIGHT_WRITES_PER_SESSION`]
+    /// awaited writes. 429: the caller's own concurrency is the problem, so
+    /// it must drain its outstanding writes before submitting more.
+    TooManyInFlight,
+    /// Shard 0 is already at [`MAX_IN_FLIGHT_WRITES_GLOBAL`] awaited writes
+    /// across all sessions. 503 with its own code (distinct from the shared
+    /// consensus-unavailable body) so an operator can tell admission shedding
+    /// from a consensus outage.
+    ServerBusy,
+    /// No committed reply within [`PARTITION_WRITE_REPLY_TIMEOUT`], or the
+    /// session's reply target was torn down mid-wait. 504: the commit may
+    /// still land (at-least-once), so this is a hard "outcome unknown", not a
+    /// failure the server may transparently retry. Carries the write's
+    /// operation so the 504 body names which write kind timed out.
+    Timeout(Operation),
+}
+
+impl IntoResponse for PartitionWriteError {
+    fn into_response(self) -> Response {
+        match self {
+            Self::Rejected(error) => CustomError::from(error).into_response(),
+            Self::NotFound => CustomError::ResourceNotFound.into_response(),
+            Self::Unavailable => service_unavailable(),
+            Self::TooManyInFlight => too_many_in_flight_response(),
+            Self::ServerBusy => server_busy_response(),
+            Self::Timeout(operation) => partition_write_timeout_response(operation),
+        }
+    }
+}
+
+/// 504 body for a partition write whose commit outcome is unknown, coded per
+/// write kind so a caller can tell a produce timeout from an offset-write
+/// timeout. Shaped like every other HTTP error (`ErrorResponse`) so clients
+/// parse one error schema.
+fn partition_write_timeout_response(operation: Operation) -> Response {
+    let (code, reason) = match operation {
+        Operation::SendMessages => (
+            "produce_timeout",
+            "produce was not acknowledged in time; the write may still commit",
+        ),
+        _ => (
+            "offset_write_timeout",
+            "consumer-offset write was not acknowledged in time; the write may still commit",
+        ),
+    };
+    gateway_timeout_response(code, reason)
+}
+
+/// Shared 504 rendering for an in-band request the partition plane did not
+/// answer in time, shaped like every other HTTP error (`ErrorResponse`) so
+/// clients parse one error schema. Consumed by the partition-write reply wait
+/// and the partition reads ([`ReadError::Timeout`]).
+fn gateway_timeout_response(code: &str, reason: &str) -> Response {
+    (
+        StatusCode::GATEWAY_TIMEOUT,
+        Json(ErrorResponse {
+            id: StatusCode::GATEWAY_TIMEOUT.as_u16().into(),
+            code: code.to_owned(),
+            reason: reason.to_owned(),
+            field: None,
+        }),
+    )
+        .into_response()
+}
+
+/// The shared 503 body for a request that could not commit right now: no
+/// caught-up primary, a full pipeline, or a view-change cancel. Retryable, and
+/// rendered with the `CannotEstablishConnection` code the SDKs treat as a
+/// connection-level retry rather than a terminal error.
+fn service_unavailable() -> Response {
+    (
+        StatusCode::SERVICE_UNAVAILABLE,
+        Json(ErrorResponse::from_error(
+            IggyError::CannotEstablishConnection,
+        )),
+    )
+        .into_response()
+}
+
+/// 429 for a session at [`MAX_IN_FLIGHT_WRITES_PER_SESSION`] awaited partition
+/// writes. Shaped like every other HTTP error (`ErrorResponse`) so clients
+/// parse one error schema; the remedy is the caller's own: let outstanding
+/// writes finish, then retry.
+fn too_many_in_flight_response() -> Response {
+    (
+        StatusCode::TOO_MANY_REQUESTS,
+        Json(ErrorResponse {
+            id: StatusCode::TOO_MANY_REQUESTS.as_u16().into(),
+            code: "too_many_in_flight_writes".to_owned(),
+            reason: "session reached its in-flight write cap; await outstanding writes and retry"
+                .to_owned(),
+            field: None,
+        }),
+    )
+        .into_response()
+}
+
+/// 503 for shard 0 at [`MAX_IN_FLIGHT_WRITES_GLOBAL`] awaited partition writes
+/// across all sessions. A distinct `server_busy` code (unlike the shared
+/// consensus-unavailable 503) so admission shedding is tellable from a
+/// consensus outage; retry with backoff.
+fn server_busy_response() -> Response {
+    (
+        StatusCode::SERVICE_UNAVAILABLE,
+        Json(ErrorResponse {
+            id: StatusCode::SERVICE_UNAVAILABLE.as_u16().into(),
+            code: "server_busy".to_owned(),
+            reason: "shard is at its in-flight write budget; retry with backoff".to_owned(),
+            field: None,
+        }),
+    )
+        .into_response()
+}
+
+/// Read consistency selected by the `?consistency=` query param.
+///
+/// `serializable` (the default) serves from this node's local metadata STM:
+/// correct and consensus-free, but may trail the primary by the replication
+/// delay. `linearizable` demands the freshest committed state and is honored
+/// only on the primary; a follower answers 503 (see [`read_local`]).
+#[derive(Clone, Copy, Default, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "lowercase")]
+enum Consistency {
+    #[default]
+    Serializable,
+    Linearizable,
+}
+
+/// `?consistency=` query wrapper. An absent param defaults to
+/// [`Consistency::Serializable`]; an unrecognized value is a 400 (axum `Query`).
+#[derive(Default, Deserialize)]
+struct ConsistencyQuery {
+    #[serde(default)]
+    consistency: Consistency,
+}
+
+/// Produce acknowledgement selected by the `?ack=` query param.
+///
+/// `replicated` (the default) answers 201 only after the partition group's
+/// quorum commit. `none` is fire-and-forget: the request is validated,
+/// dispatched, and answered 202 immediately; the commit still happens, but its
+/// reply is shed at the bus (no reply slot is installed).
+#[derive(Clone, Copy, Default, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "lowercase")]
+enum ProduceAck {
+    #[default]
+    Replicated,
+    None,
+}
+
+/// `?ack=` query wrapper. An absent param defaults to
+/// [`ProduceAck::Replicated`]; an unrecognized value is a 400 (axum `Query`).
+#[derive(Default, Deserialize)]
+struct ProduceQuery {
+    #[serde(default)]
+    ack: ProduceAck,
+}
+
+/// Rejection for an authenticated read route (`GET /streams`,
+/// `GET /streams/{id}`, and the reads that follow). Three classes, three
+/// renderings, mirroring [`WriteError`]'s split so error bodies stay
+/// SDK-identical:
+enum ReadError {
+    /// Caller-side or STM rejection (bad identifier, unsupported op, or an
+    /// authorization denial) graded through the legacy `IggyError -> status`
+    /// map so SDK error bodies stay byte-identical.
+    Rejected(IggyError),
+    /// Requested entity is absent -> 404 with the legacy not-found body.
+    NotFound,
+    /// A linearizable read reached a follower and the primary's HTTP address was
+    /// not resolvable from the roster. Fail-closed 503, retryable against the
+    /// leader (see [`not_primary_response`]).
+    NotPrimary,
+    /// A linearizable read reached a follower and the current VSR primary's HTTP
+    /// address resolved: 307 to that address carrying the original path and
+    /// query, so the caller re-issues the read against the leader (see
+    /// [`primary_redirect_response`]).
+    RedirectToPrimary(String),
+    /// A partition read (poll / consumer-offset) got no reply from the owning
+    /// shard within the mesh budget. 504 like a produce timeout: the outcome is
+    /// unknown (the abandoned read may still be running), so the caller retries.
+    Timeout,
+}
+
+impl IntoResponse for ReadError {
+    fn into_response(self) -> Response {
+        match self {
+            Self::Rejected(error) => CustomError::from(error).into_response(),
+            // Reuse the legacy 404 body so a missing stream renders exactly as
+            // the legacy server's `CustomError::ResourceNotFound` does.
+            Self::NotFound => CustomError::ResourceNotFound.into_response(),
+            Self::NotPrimary => not_primary_response(),
+            Self::RedirectToPrimary(location) => primary_redirect_response(&location),
+            Self::Timeout => gateway_timeout_response(
+                "partition_read_timeout",
+                "the partition owner did not answer the read in time; retry",
+            ),
+        }
+    }
+}
+
+/// The 503 fail-closed body for a linearizable read that reached a follower
+/// whose primary HTTP address could not be resolved (absent consensus, a roster
+/// with no node at the primary index, or a port-less node). The resolvable case
+/// is a 307 via [`primary_redirect_response`] instead. Rendered as an
+/// `ErrorResponse` so the body shape matches every other HTTP error; the caller
+/// retries against the leader.
+fn not_primary_response() -> Response {
+    (
+        StatusCode::SERVICE_UNAVAILABLE,
+        Json(ErrorResponse {
+            id: StatusCode::SERVICE_UNAVAILABLE.as_u16().into(),
+            code: "not_primary".to_owned(),
+            reason: "linearizable read requires the primary; retry against the leader".to_owned(),
+            field: None,
+        }),
+    )
+        .into_response()
+}
+
+/// 307 Temporary Redirect to the current VSR primary for a linearizable read
+/// that reached a follower. `Location` is the primary's HTTP base plus the
+/// original path and query, so the caller re-issues the identical read against
+/// the leader. Dormant on a single node (always primary) and followed by no SDK
+/// yet. A `Location` that is not a valid header value falls back to the 503.
+fn primary_redirect_response(location: &str) -> Response {
+    HeaderValue::from_str(location).map_or_else(
+        |_| not_primary_response(),
+        |value| {
+            let mut response = StatusCode::TEMPORARY_REDIRECT.into_response();
+            response.headers_mut().insert(LOCATION, value);
+            response
+        },
+    )
+}
+
+/// Build the `Location` for a 307 redirect of a linearizable read to the VSR
+/// primary: `http://<host>:<http-port><path_and_query>`. `None` when the roster
+/// has no node at `primary_index`, that node exposes no HTTP port, or its `ip`
+/// is not a valid address, so the caller fails closed to a 503 rather than
+/// pointing at an unreachable target. Formats through [`SocketAddr`] so an IPv6
+/// host is bracketed (`http://[::1]:8080/...`) rather than left ambiguous. Pure
+/// (no consensus or axum dependency) so the redirect target is unit-tested in
+/// isolation.
+fn primary_redirect_location(
+    roster: &ClusterRoster,
+    primary_index: u8,
+    path_and_query: &str,
+) -> Option<String> {
+    let node = roster
+        .nodes
+        .iter()
+        .find(|node| node.replica_id == primary_index)?;
+    let http_port = node.ports.http?;
+    let ip = node.ip.parse::<IpAddr>().ok()?;
+    let socket = SocketAddr::new(ip, http_port);
+    Some(format!("http://{socket}{path_and_query}"))
+}
+
+/// Owned snapshot of the cluster topology `GET /cluster/metadata` reports.
+///
+/// The roster lives only in `ServerNgConfig` and is not reachable from
+/// [`ServerNgShard`], so [`start`] copies the minimal pieces here at listener
+/// start. Owned so the handler stays synchronous and never borrows config.
+struct ClusterRoster {
+    enabled: bool,
+    name: String,
+    nodes: Vec<ClusterNodeConfig>,
+    /// This node's bound HTTP address, used to synthesize the sole self-node
+    /// when no roster is configured.
+    http_addr: SocketAddr,
+    /// This node's other client listener ports for the same self-node
+    /// synthesis (`None` = transport disabled; HTTP comes from `http_addr`).
+    self_ports: TransportPorts,
+}
+
+/// Serializes first-use VSR registration per credential key so a herd of
+/// concurrent first-requests for one token runs exactly one `Register` instead
+/// of N that each mint a client id and orphan N-1 slots last-writer-wins.
+///
+/// Shard 0 is single-threaded, so the whole check-or-claim is a synchronous
+/// `RefCell` critical section (no cross-thread machinery): the first caller for
+/// a key claims it and gets a [`RegistrationGuard`]; every later caller gets a
+/// waiter to park on until the registrant finishes. The guard clears the marker
+/// and wakes all waiters on drop - including a cancellation drop, so a
+/// disconnected registrant can never wedge its waiters.
+#[derive(Default)]
+struct RegistrationBarrier {
+    /// Key -> waiters parked on the in-flight registration. Dropping a waiter's
+    /// sender wakes its receiver; the guard drops the whole vec at once.
+    inflight: RefCell<HashMap<String, Vec<oneshot::Sender<()>>>>,
+}
+
+/// Outcome of [`RegistrationBarrier::enter`]: lead the registration or wait for
+/// the caller that is already leading it.
+enum BarrierEntry<'a> {
+    Lead(RegistrationGuard<'a>),
+    Wait(oneshot::Receiver<()>),
+}
+
+/// Held by the sole registrant for a key. On drop it removes the in-flight
+/// marker, which drops every parked waiter's sender and so wakes them to
+/// re-check the session table.
+struct RegistrationGuard<'a> {
+    barrier: &'a RegistrationBarrier,
+    key: String,
+}
+
+impl RegistrationBarrier {
+    /// Claim `key` for registration, or return a waiter if another caller
+    /// already holds it. Synchronous and borrow-free of any `.await`.
+    fn enter(&self, key: &str) -> BarrierEntry<'_> {
+        match self.inflight.borrow_mut().entry(key.to_owned()) {
+            Entry::Occupied(mut occupied) => {
+                let (sender, receiver) = oneshot::channel();
+                occupied.get_mut().push(sender);
+                BarrierEntry::Wait(receiver)
+            }
+            Entry::Vacant(vacant) => {
+                vacant.insert(Vec::new());
+                BarrierEntry::Lead(RegistrationGuard {
+                    barrier: self,
+                    key: key.to_owned(),
+                })
+            }
+        }
+    }
+}
+
+impl Drop for RegistrationGuard<'_> {
+    fn drop(&mut self) {
+        // Dropping the vec of senders wakes every waiter (Canceled), which then
+        // re-checks the table for the session this registrant installed.
+        self.barrier.inflight.borrow_mut().remove(&self.key);
+    }
+}
+
+/// Shared shard-0 HTTP state.
+///
+/// Groups the shard handle, the JWT issuer/verifier, and the per-credential VSR
+/// session table so every handler and the [`Authenticated`] extractor reach
+/// them through one axum `State`.
+struct HttpInner {
+    shard: Rc<ServerNgShard>,
+    jwt: JwtManager,
+    /// Per-credential VSR sessions keyed by JWT `jti` / PAT hash. `RefCell` is
+    /// sound here - shard 0 is single-threaded and the `SendWrapper` state
+    /// bridge tolerates the `!Sync` interior - but the guard must never be held
+    /// across an `.await` (see [`HttpInner::resolve_session`]).
+    sessions: RefCell<HashMap<String, Rc<HttpSession>>>,
+    /// Per-key registration barrier: prevents a thundering herd of first
+    /// requests for one credential from each running its own `Register`.
+    registrations: RegistrationBarrier,
+    roster: ClusterRoster,
+    /// Awaited partition writes currently in flight across all sessions, gated
+    /// by [`MAX_IN_FLIGHT_WRITES_GLOBAL`]. Only [`InFlightWriteGuard`] touches
+    /// it, so every admission is paired with exactly one release.
+    in_flight_writes: Cell<u32>,
+}
+
+impl HttpInner {
+    /// True when this shard-0 node is the current VSR metadata primary, i.e.
+    /// `primary_index(current_view) == own_replica_id` - the check consensus
+    /// `is_primary` already encapsulates over the live view and this replica's
+    /// id. Absent consensus (never on shard 0 under VSR, only a no-replica
+    /// build) is treated as not-primary so a linearizable read fails closed
+    /// rather than serving possibly-stale local state as authoritative.
+    fn is_metadata_primary(&self) -> bool {
+        self.shard
+            .plane
+            .metadata()
+            .consensus
+            .as_ref()
+            .is_some_and(VsrConsensus::is_primary)
+    }
+
+    /// Grade a linearizable read that reached a follower: redirect (307) to the
+    /// current VSR primary's HTTP address when it resolves from the roster, else
+    /// fail closed to the 503. The target is the roster node whose `replica_id`
+    /// equals `primary_index(view)`; an absent consensus, an unmatched id, or a
+    /// port-less node all fall back to [`ReadError::NotPrimary`].
+    fn not_primary_read_error(&self, path_and_query: &str) -> ReadError {
+        let location = self
+            .shard
+            .plane
+            .metadata()
+            .consensus
+            .as_ref()
+            .and_then(|consensus| {
+                let primary_index = consensus.primary_index(consensus.view());
+                primary_redirect_location(&self.roster, primary_index, path_and_query)
+            });
+        location.map_or(ReadError::NotPrimary, ReadError::RedirectToPrimary)
+    }
+
+    /// Resolve the VSR session for `key`, minting and Registering one on first
+    /// use. Every later request bearing the same credential reuses it.
+    ///
+    /// Borrow discipline: the `RefCell` table guard is taken, read, and dropped
+    /// WITHOUT crossing the `.await`. Holding it across the Register suspend
+    /// would panic the moment a sibling shard-0 task borrowed the table while
+    /// this one is parked (single-threaded `RefCell` + cooperative scheduling).
+    async fn resolve_session(
+        &self,
+        key: String,
+        user_id: u32,
+        expiry: u64,
+    ) -> Result<Rc<HttpSession>, AuthError> {
+        loop {
+            let now = IggyTimestamp::now().to_secs();
+            if let Some(session) = self.live_session(&key, now) {
+                return Ok(session);
+            }
+
+            // Miss. Serialize registration per credential so a herd of
+            // concurrent first-requests runs one `Register`, not N.
+            match self.registrations.enter(&key) {
+                BarrierEntry::Wait(waiter) => {
+                    // Another first-request is registering this credential.
+                    // Park until it finishes (its guard wakes us on drop),
+                    // then loop to re-check the table for what it installed.
+                    let _ = waiter.await;
+                }
+                BarrierEntry::Lead(_guard) => {
+                    // Sole registrant for this key: mint + Register with no
+                    // borrow held (an async VSR commit). The guard wakes any
+                    // waiters when this scope ends, cancellation drop included.
+                    let fresh = self.register_session(key.clone(), user_id, expiry).await?;
+                    // Resample after the await: the pre-await stamp is stale for
+                    // the expiry sweep and cap check below.
+                    let now = IggyTimestamp::now().to_secs();
+                    let (admitted, torn) = {
+                        let mut table = self.sessions.borrow_mut();
+                        let torn = sweep_expired(&mut table, now);
+                        if table.len() >= MAX_HTTP_SESSIONS {
+                            // Still full after dropping expired entries: too many
+                            // genuinely live sessions. Refuse rather than evict a
+                            // live one (its `fresh` client id is orphaned on the
+                            // peers until they evict it - a rare at-cap cost).
+                            (None, torn)
+                        } else {
+                            table.insert(key.clone(), Rc::clone(&fresh));
+                            (Some(fresh), torn)
+                        }
+                    };
+                    self.teardown_reply_targets(torn);
+                    return admitted.ok_or(AuthError::SessionUnavailable);
+                }
+            }
+        }
+    }
+
+    /// Clone the live (non-expired) entry for `key`, if present. Confines the
+    /// shared `RefCell` borrow to this call so it can never span an `.await`.
+    fn live_session(&self, key: &str, now_secs: u64) -> Option<Rc<HttpSession>> {
+        live_entry(&self.sessions.borrow(), key, now_secs)
+    }
+
+    /// Mint a shard-0 client id and run the VSR `Register` for a fresh session.
+    /// Holds no table borrow; the caller inserts the result under `key`.
+    async fn register_session(
+        &self,
+        key: String,
+        user_id: u32,
+        expiry: u64,
+    ) -> Result<Rc<HttpSession>, AuthError> {
+        let coordinator = self
+            .shard
+            .coordinator()
+            .ok_or(AuthError::SessionUnavailable)?;
+        // Reuse the TCP accept path's minter: it draws from the same shard-0
+        // `client_seq`, so an HTTP session id can never collide with a TCP
+        // virtual client's and the shard-0 tag (top 16 bits == 0) is preserved.
+        let client_id = coordinator.mint_shard_zero_client_id();
+        // The minter seeds at 1, so 0 is only reachable after a 2^112 wrap.
+        // Guard anyway: `submit_register_in_process` asserts `client_id != 0`,
+        // and an assert on this request path would be a panic.
+        if client_id == 0 {
+            return Err(AuthError::SessionUnavailable);
+        }
+        // Shared Register entry point; on shard 0 (always, for HTTP) it runs
+        // `submit_register_in_process` directly on the metadata owner.
+        let session = submit_register_on_owner(&self.shard, client_id, user_id)
+            .await
+            .map_err(|error| {
+                warn!(?error, "server-ng HTTP: VSR Register submit failed");
+                AuthError::SessionUnavailable
+            })?;
+        Ok(Rc::new(HttpSession {
+            key,
+            client_id,
+            session,
+            user_id,
+            expiry,
+            gate: Mutex::new(FIRST_REQUEST_ID),
+            data_request: Cell::new(FIRST_REQUEST_ID),
+            registry_token: Cell::new(None),
+            in_flight_writes: Cell::new(0),
+        }))
+    }
+
+    /// Drop the session table entry for `session`, but only if it is still the
+    /// current occupant of its key (pointer-fenced, so a later re-registration
+    /// under the same key is never purged). Also tears down its in-process
+    /// reply target. Called when a control write comes back evicted: the VSR
+    /// slot is gone, so leaving the entry would 401-loop every retry on the
+    /// same credential until the token expires; removing it makes the next
+    /// request re-register cleanly through the barrier.
+    fn forget_session(&self, session: &Rc<HttpSession>) {
+        let torn = forget_if_same(&mut self.sessions.borrow_mut(), session);
+        self.teardown_reply_targets(torn.into_iter().collect());
+    }
+
+    /// Tear down the in-process reply targets of swept/forgotten sessions,
+    /// token-fenced so a stale teardown can never remove a later occupant's
+    /// registry entry. Runs outside the `sessions` borrow.
+    fn teardown_reply_targets(&self, torn: Vec<(u128, InstanceToken)>) {
+        for (client_id, token) in torn {
+            self.shard
+                .bus
+                .clients()
+                .remove_if_token_matches(client_id, token);
+        }
+    }
+}
+
+/// Drop every expired entry from the session table, returning the
+/// `(client_id, registry token)` of each dropped entry that had installed an
+/// in-process reply target so the caller can tear those down outside the
+/// borrow. A pure map operation (no shard), so the cap/expiry policy is
+/// unit-testable without a live consensus.
+fn sweep_expired(
+    table: &mut HashMap<String, Rc<HttpSession>>,
+    now_secs: u64,
+) -> Vec<(u128, InstanceToken)> {
+    let mut torn = Vec::new();
+    table.retain(|_, session| {
+        if session.expiry > now_secs {
+            return true;
+        }
+        if let Some(token) = session.registry_token.get() {
+            torn.push((session.client_id, token));
+        }
+        false
+    });
+    torn
+}
+
+/// Remove `session` from the table only if it is still the current occupant of
+/// its key (`Rc::ptr_eq`), returning its reply target to tear down. A stale
+/// handle whose key was re-registered to a newer session removes nothing. Pure
+/// (no shard) so the eviction-recovery fencing is unit-testable.
+fn forget_if_same(
+    table: &mut HashMap<String, Rc<HttpSession>>,
+    session: &Rc<HttpSession>,
+) -> Option<(u128, InstanceToken)> {
+    match table.get(&session.key) {
+        Some(current) if Rc::ptr_eq(current, session) => {
+            let torn = session
+                .registry_token
+                .get()
+                .map(|token| (session.client_id, token));
+            table.remove(&session.key);
+            torn
+        }
+        _ => None,
+    }
+}
+
+/// Borrow-and-clone a live table entry, or `None` if missing or expired. Shared
+/// by the fast path and the post-Register re-check so neither leaks a guard.
+fn live_entry(
+    table: &HashMap<String, Rc<HttpSession>>,
+    key: &str,
+    now_secs: u64,
+) -> Option<Rc<HttpSession>> {
+    table
+        .get(key)
+        .filter(|session| session.expiry > now_secs)
+        .map(Rc::clone)
+}
+
+/// Axum router state: shard-0's [`HttpInner`] behind an `Rc`, `!Send` yet
+/// bridged into axum's `Send + Sync` requirement by `SendWrapper`. Sound
+/// because the listener and every handler run on shard 0's compio thread - the
+/// same thread that builds this state. Never touch it off that thread.
+type HttpState = SendWrapper<Rc<HttpInner>>;
+
+/// Bind the shard-0 HTTP listener and spawn the `cyper-axum` serve loop as a
+/// background task on shard 0's compio runtime.
+///
+/// The caller gates this to shard 0 and to `http.enabled`; the listener stops
+/// when the bus shutdown token fires.
+///
+/// # Errors
+///
+/// Returns [`ServerNgError`] if the JWT manager cannot be built from
+/// `http_config.jwt` or the listener cannot bind to `addr`.
+pub async fn start(
+    shard: &Rc<ServerNgShard>,
+    addr: SocketAddr,
+    http_config: &HttpConfig,
+    cluster: &ClusterConfig,
+    self_ports: TransportPorts,
+) -> Result<(), ServerNgError> {
+    let jwt = JwtManager::build(&http_config.jwt)?;
+    let (listener, bound_addr) = client_listener::tcp::bind(addr).await?;
+    info!(address = %bound_addr, "server-ng HTTP listener started");
+
+    let state: HttpState = SendWrapper::new(Rc::new(HttpInner {
+        shard: Rc::clone(shard),
+        jwt,
+        sessions: RefCell::new(HashMap::new()),
+        registrations: RegistrationBarrier::default(),
+        roster: ClusterRoster {
+            enabled: cluster.enabled,
+            name: cluster.name.clone(),
+            nodes: cluster.nodes.clone(),
+            http_addr: bound_addr,
+            self_ports,
+        },
+        in_flight_writes: Cell::new(0),
+    }));
+    // Saturating: a configured limit past the pointer width (32-bit target,
+    // >4 GiB value) clamps to the largest enforceable cap instead of wrapping.
+    let max_request_size =
+        usize::try_from(http_config.max_request_size.as_bytes_u64()).unwrap_or(usize::MAX);
+    let router = router(state, max_request_size);
+
+    let shutdown = shard.bus.token();
+    let handle = compio::runtime::spawn(async move {
+        if let Err(error) = cyper_axum::serve(listener, router)
+            .with_graceful_shutdown(async move { shutdown.wait().await })
+            .await
+        {
+            error!(%error, "server-ng HTTP listener terminated with error");
+        }
+    });
+    shard.bus.track_background(handle);
+
+    Ok(())
+}
+
+/// Assemble the shard-0 router: unauthenticated health + login routes plus the
+/// authenticated REST surface.
+///
+/// `max_request_size` becomes the router-wide `DefaultBodyLimit` (413 past
+/// it), exactly like the legacy server: it bounds the per-request term of the
+/// admission math - what one body may cost in bytes and decode CPU - while
+/// the in-flight caps bound the multiplier.
+fn router(state: HttpState, max_request_size: usize) -> Router {
+    // Cloned for the response layer so `X-Iggy-View` reads the live view per
+    // response; the original `state` is moved into `with_state` below.
+    let view_source = state.clone();
+    Router::new()
+        .route("/ping", get(ping))
+        .route("/users/login", post(login_user))
+        .route(
+            "/personal-access-tokens/login",
+            post(login_with_personal_access_token),
+        )
+        .route("/users", get(get_users).post(create_user))
+        .route(
+            "/users/{user_id}",
+            get(get_user).put(update_user).delete(delete_user),
+        )
+        .route("/users/{user_id}/password", put(change_password))
+        .route("/users/{user_id}/permissions", put(update_permissions))
+        // Static `logout` outranks the `{user_id}` capture in axum's matcher, so
+        // `DELETE /users/logout` never misroutes to `delete_user`.
+        .route("/users/logout", delete(logout_user))
+        .route("/streams", get(get_streams).post(create_stream))
+        .route(
+            "/streams/{stream_id}",
+            get(get_stream).put(update_stream).delete(delete_stream),
+        )
+        .route("/streams/{stream_id}/purge", delete(purge_stream))
+        .route(
+            "/streams/{stream_id}/topics",
+            get(get_topics).post(create_topic),
+        )
+        .route(
+            "/streams/{stream_id}/topics/{topic_id}",
+            get(get_topic).put(update_topic).delete(delete_topic),
+        )
+        .route(
+            "/streams/{stream_id}/topics/{topic_id}/purge",
+            delete(purge_topic),
+        )
+        .route(
+            "/streams/{stream_id}/topics/{topic_id}/messages",
+            get(poll_messages).post(send_messages),
+        )
+        .route(
+            "/streams/{stream_id}/topics/{topic_id}/consumer-offsets",
+            get(get_consumer_offset).put(store_consumer_offset),
+        )
+        .route(
+            "/streams/{stream_id}/topics/{topic_id}/consumer-offsets/{consumer_id}",
+            delete(delete_consumer_offset),
+        )
+        .route(
+            "/streams/{stream_id}/topics/{topic_id}/consumer-groups",
+            get(get_cgs).post(create_cg),
+        )
+        .route(
+            "/streams/{stream_id}/topics/{topic_id}/consumer-groups/{group_id}",
+            get(get_cg).delete(delete_cg),
+        )
+        .route("/personal-access-tokens", get(get_pats).post(create_pat))
+        .route("/personal-access-tokens/{name}", delete(delete_pat))
+        .route("/stats", get(get_stats))
+        .route("/cluster/metadata", get(get_cluster_metadata))
+        .route("/clients", get(get_clients))
+        .route("/clients/{client_id}", get(get_client))
+        .with_state(state)
+        .layer(DefaultBodyLimit::max(max_request_size))
+        .layer(map_response(move |response: Response| {
+            let view_source = view_source.clone();
+            async move { insert_view_header(&view_source, response) }
+        }))
+}
+
+/// Extracting the state here proves at compile time that the `!Send` state
+/// bridges into axum's `Send + Sync` router state on shard 0's compio thread.
+/// Ping needs no state, so it is discarded.
+async fn ping(State(_state): State<HttpState>) -> &'static str {
+    PONG
+}
+
+async fn login_user(
+    State(state): State<HttpState>,
+    Json(command): Json<LoginUser>,
+) -> Result<Json<IdentityInfo>, CustomError> {
+    let user_id = verify_login_credentials(
+        &state.shard,
+        &command.username,
+        command.password.expose_secret(),
+    )
+    .map_err(|error| login_error_to_iggy(&error))?;
+    issue_identity(&state, user_id)
+}
+
+async fn login_with_personal_access_token(
+    State(state): State<HttpState>,
+    Json(command): Json<LoginWithPersonalAccessToken>,
+) -> Result<Json<IdentityInfo>, CustomError> {
+    let user_id = verify_pat_credentials(&state.shard, command.token.expose_secret())
+        .map_err(|error| login_error_to_iggy(&error))?;
+    issue_identity(&state, user_id)
+}
+
+/// `DELETE /users/logout`: end the caller's session and answer 204 - the status
+/// the SDK's `logout_user()` awaits before dropping its stored bearer. The
+/// teardown itself is [`logout_session`]; it is best-effort, so once the caller
+/// authenticates this always answers 204.
+async fn logout_user(State(state): State<HttpState>, identity: Authenticated) -> StatusCode {
+    // `logout_session` is `!Send`; bridge it like every other write path on this
+    // shard-0 listener (see [`delete_user`]).
+    SendWrapper::new(logout_session(&state, &identity.session)).await;
+    StatusCode::NO_CONTENT
+}
+
+/// `GET /streams`: list every stream as the same `Vec<Stream>` JSON the legacy
+/// server returns. A consensus-free local STM read via [`read_local`].
+async fn get_streams(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Query(query): Query<ConsistencyQuery>,
+) -> Result<Json<Vec<Stream>>, ReadError> {
+    let body = GetStreamsRequest.to_bytes();
+    let bytes = read_local(
+        &state,
+        &identity,
+        query.consistency,
+        GET_STREAMS_CODE,
+        &body,
+        Permissioner::get_streams,
+    )?;
+    let response = GetStreamsResponse::decode_from(&bytes)
+        .map_err(|_| ReadError::Rejected(IggyError::InvalidCommand))?;
+    Ok(Json(streams_from_wire(response)))
+}
+
+/// `GET /streams/{stream_id}`: fetch one stream by numeric id or name as the
+/// same `StreamDetails` JSON the legacy server returns; 404 when absent. A
+/// consensus-free local STM read via [`read_local`].
+async fn get_stream(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Path(stream_id): Path<String>,
+    Query(query): Query<ConsistencyQuery>,
+) -> Result<Json<StreamDetails>, ReadError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(ReadError::Rejected)?;
+    let wire_stream_id = identifier_to_wire(&stream_id).map_err(ReadError::Rejected)?;
+    // Resolve for the gate; a miss leaves it a pass-through so the read renders
+    // the existing 404 rather than a 403.
+    let scope = resolve_gate_stream(&state, &wire_stream_id);
+    let request = GetStreamRequest {
+        stream_id: wire_stream_id,
+    };
+    let body = request.to_bytes();
+    let bytes = read_local(
+        &state,
+        &identity,
+        query.consistency,
+        GET_STREAM_CODE,
+        &body,
+        |permissioner, uid| {
+            scope.map_or(Ok(()), |stream_id| permissioner.get_stream(uid, stream_id))
+        },
+    )?;
+    let response = GetStreamResponse::decode_from(&bytes)
+        .map_err(|_| ReadError::Rejected(IggyError::InvalidCommand))?;
+    Ok(Json(
+        StreamDetails::try_from(response).map_err(ReadError::Rejected)?,
+    ))
+}
+
+/// `GET /streams/{stream_id}/topics`: list a stream's topics as the same
+/// `Vec<Topic>` JSON the legacy server returns. A consensus-free local STM read
+/// via [`read_local`].
+async fn get_topics(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Path(stream_id): Path<String>,
+    Query(query): Query<ConsistencyQuery>,
+) -> Result<Json<Vec<Topic>>, ReadError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(ReadError::Rejected)?;
+    let wire_stream_id = identifier_to_wire(&stream_id).map_err(ReadError::Rejected)?;
+    let scope = resolve_gate_stream(&state, &wire_stream_id);
+    let request = GetTopicsRequest {
+        stream_id: wire_stream_id,
+    };
+    let body = request.to_bytes();
+    let bytes = read_local(
+        &state,
+        &identity,
+        query.consistency,
+        GET_TOPICS_CODE,
+        &body,
+        |permissioner, uid| {
+            scope.map_or(Ok(()), |stream_id| permissioner.get_topics(uid, stream_id))
+        },
+    )?;
+    let response = GetTopicsResponse::decode_from(&bytes)
+        .map_err(|_| ReadError::Rejected(IggyError::InvalidCommand))?;
+    Ok(Json(
+        topics_from_wire(response).map_err(ReadError::Rejected)?,
+    ))
+}
+
+/// `GET /streams/{stream_id}/topics/{topic_id}`: fetch one topic by numeric id
+/// or name as the same `TopicDetails` JSON the legacy server returns; 404 when
+/// absent. A consensus-free local STM read via [`read_local`].
+async fn get_topic(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Path((stream_id, topic_id)): Path<(String, String)>,
+    Query(query): Query<ConsistencyQuery>,
+) -> Result<Json<TopicDetails>, ReadError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(ReadError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(ReadError::Rejected)?;
+    let wire_stream_id = identifier_to_wire(&stream_id).map_err(ReadError::Rejected)?;
+    let wire_topic_id = identifier_to_wire(&topic_id).map_err(ReadError::Rejected)?;
+    let scope = resolve_gate_topic(&state, &wire_stream_id, &wire_topic_id);
+    let request = GetTopicRequest {
+        stream_id: wire_stream_id,
+        topic_id: wire_topic_id,
+    };
+    let body = request.to_bytes();
+    let bytes = read_local(
+        &state,
+        &identity,
+        query.consistency,
+        GET_TOPIC_CODE,
+        &body,
+        |permissioner, uid| {
+            scope.map_or(Ok(()), |(stream_id, topic_id)| {
+                permissioner.get_topic(uid, stream_id, topic_id)
+            })
+        },
+    )?;
+    let response = GetTopicResponse::decode_from(&bytes)
+        .map_err(|_| ReadError::Rejected(IggyError::InvalidCommand))?;
+    Ok(Json(
+        TopicDetails::try_from(response).map_err(ReadError::Rejected)?,
+    ))
+}
+
+/// `GET /users`: list every user as the same `Vec<UserInfo>` JSON the legacy
+/// server returns. A consensus-free local STM read via [`read_local`].
+async fn get_users(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Query(query): Query<ConsistencyQuery>,
+) -> Result<Json<Vec<UserInfo>>, ReadError> {
+    let body = GetUsersRequest.to_bytes();
+    let bytes = read_local(
+        &state,
+        &identity,
+        query.consistency,
+        GET_USERS_CODE,
+        &body,
+        Permissioner::get_users,
+    )?;
+    let response = GetUsersResponse::decode_from(&bytes)
+        .map_err(|_| ReadError::Rejected(IggyError::InvalidCommand))?;
+    Ok(Json(
+        users_from_wire(response).map_err(ReadError::Rejected)?,
+    ))
+}
+
+/// `GET /users/{user_id}`: fetch one user by numeric id or name as the same
+/// `UserInfoDetails` JSON the legacy server returns; 404 when absent. A
+/// consensus-free local STM read via [`read_local`]. [`CURRENT_USER_ALIAS`]
+/// resolves to the caller and skips the `read_users` gate, mirroring the
+/// legacy self-read bypass.
+async fn get_user(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Path(user_id): Path<String>,
+    Query(query): Query<ConsistencyQuery>,
+) -> Result<Json<UserInfoDetails>, ReadError> {
+    let is_self = user_id == CURRENT_USER_ALIAS;
+    let wire_user_id = if is_self {
+        WireIdentifier::numeric(identity.user_id)
+    } else {
+        let user_id = Identifier::from_str_value(&user_id).map_err(ReadError::Rejected)?;
+        identifier_to_wire(&user_id).map_err(ReadError::Rejected)?
+    };
+    let request = GetUserRequest {
+        user_id: wire_user_id,
+    };
+    let body = request.to_bytes();
+    let bytes = read_local(
+        &state,
+        &identity,
+        query.consistency,
+        GET_USER_CODE,
+        &body,
+        |permissioner, uid| {
+            if is_self {
+                Ok(())
+            } else {
+                permissioner.get_user(uid)
+            }
+        },
+    )?;
+    let response = UserDetailsResponse::decode_from(&bytes)
+        .map_err(|_| ReadError::Rejected(IggyError::InvalidCommand))?;
+    Ok(Json(
+        UserInfoDetails::try_from(response).map_err(ReadError::Rejected)?,
+    ))
+}
+
+/// `GET /streams/{stream_id}/topics/{topic_id}/consumer-groups`: list a topic's
+/// consumer groups as the same `Vec<ConsumerGroup>` JSON the legacy server
+/// returns. A consensus-free local STM read via [`read_local`].
+async fn get_cgs(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Path((stream_id, topic_id)): Path<(String, String)>,
+    Query(query): Query<ConsistencyQuery>,
+) -> Result<Json<Vec<ConsumerGroup>>, ReadError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(ReadError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(ReadError::Rejected)?;
+    let wire_stream_id = identifier_to_wire(&stream_id).map_err(ReadError::Rejected)?;
+    let wire_topic_id = identifier_to_wire(&topic_id).map_err(ReadError::Rejected)?;
+    let scope = resolve_gate_topic(&state, &wire_stream_id, &wire_topic_id);
+    let request = GetConsumerGroupsRequest {
+        stream_id: wire_stream_id,
+        topic_id: wire_topic_id,
+    };
+    let body = request.to_bytes();
+    let bytes = read_local(
+        &state,
+        &identity,
+        query.consistency,
+        GET_CONSUMER_GROUPS_CODE,
+        &body,
+        |permissioner, uid| {
+            scope.map_or(Ok(()), |(stream_id, topic_id)| {
+                permissioner.get_consumer_groups(uid, stream_id, topic_id)
+            })
+        },
+    )?;
+    let response = GetConsumerGroupsResponse::decode_from(&bytes)
+        .map_err(|_| ReadError::Rejected(IggyError::InvalidCommand))?;
+    Ok(Json(consumer_groups_from_wire(response)))
+}
+
+/// `GET /streams/{stream_id}/topics/{topic_id}/consumer-groups/{group_id}`:
+/// fetch one consumer group by numeric id or name as the same
+/// `ConsumerGroupDetails` JSON the legacy server returns; 404 when absent. The
+/// wire-to-domain conversion is infallible.
+async fn get_cg(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Path((stream_id, topic_id, group_id)): Path<(String, String, String)>,
+    Query(query): Query<ConsistencyQuery>,
+) -> Result<Json<ConsumerGroupDetails>, ReadError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(ReadError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(ReadError::Rejected)?;
+    let group_id = Identifier::from_str_value(&group_id).map_err(ReadError::Rejected)?;
+    let wire_stream_id = identifier_to_wire(&stream_id).map_err(ReadError::Rejected)?;
+    let wire_topic_id = identifier_to_wire(&topic_id).map_err(ReadError::Rejected)?;
+    let scope = resolve_gate_topic(&state, &wire_stream_id, &wire_topic_id);
+    let request = GetConsumerGroupRequest {
+        stream_id: wire_stream_id,
+        topic_id: wire_topic_id,
+        group_id: identifier_to_wire(&group_id).map_err(ReadError::Rejected)?,
+    };
+    let body = request.to_bytes();
+    let bytes = read_local(
+        &state,
+        &identity,
+        query.consistency,
+        GET_CONSUMER_GROUP_CODE,
+        &body,
+        |permissioner, uid| {
+            scope.map_or(Ok(()), |(stream_id, topic_id)| {
+                permissioner.get_consumer_group(uid, stream_id, topic_id)
+            })
+        },
+    )?;
+    let response = ConsumerGroupDetailsResponse::decode_from(&bytes)
+        .map_err(|_| ReadError::Rejected(IggyError::InvalidCommand))?;
+    Ok(Json(ConsumerGroupDetails::from(response)))
+}
+
+/// `GET /stats`: server + storage counters as the same `Stats` JSON the legacy
+/// server returns. `GET_STATS` is served by `build_non_replicated_response` like
+/// the entity reads, so it flows through [`read_local`] unchanged rather than a
+/// dedicated builder. No entity can be missing, so no 404 branch.
+async fn get_stats(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Query(query): Query<ConsistencyQuery>,
+) -> Result<Json<Stats>, ReadError> {
+    let body = GetStatsRequest.to_bytes();
+    let bytes = read_local(
+        &state,
+        &identity,
+        query.consistency,
+        GET_STATS_CODE,
+        &body,
+        Permissioner::get_stats,
+    )?;
+    let response = StatsResponse::decode_from(&bytes)
+        .map_err(|_| ReadError::Rejected(IggyError::InvalidCommand))?;
+    Ok(Json(Stats::from(response)))
+}
+
+/// `GET /cluster/metadata`: report the live cluster topology as the same
+/// `ClusterMetadata` JSON the legacy server returns.
+///
+/// Auth-only: any valid token serves. Unlike the entity reads it bypasses both
+/// the per-op authorization gate and the consistency gate, and serves from the
+/// roster captured at listener start plus the sync consensus getters, so it
+/// never touches the metadata STM, consensus, or a VSR session and stays fully
+/// synchronous.
+async fn get_cluster_metadata(
+    State(state): State<HttpState>,
+    _identity: Identity,
+) -> Json<ClusterMetadata> {
+    Json(build_cluster_metadata(&state))
+}
+
+/// `GET /clients`: list every connected client across all shards as the same
+/// `Vec<ClientInfo>` JSON the legacy server returns.
+///
+/// Unlike the entity reads, connections live in each shard's session manager,
+/// not the metadata STM, so this scatter-gathers over the shard mesh
+/// (`list_all_clients`) instead of going through [`read_local`]. It still runs
+/// the identical per-op + consistency gate via [`authorize_read`], so its
+/// authorization matches every metadata read. The gather future is `!Send`,
+/// bridged onto shard 0's thread by `SendWrapper` exactly as the write path
+/// bridges its submit.
+async fn get_clients(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Query(query): Query<ConsistencyQuery>,
+) -> Result<Json<Vec<ClientInfo>>, ReadError> {
+    authorize_read(&state, &identity, query.consistency, |permissioner, uid| {
+        permissioner.get_clients(uid)
+    })?;
+    let infos = SendWrapper::new(state.shard.list_all_clients()).await;
+    let response = GetClientsResponse {
+        clients: infos
+            .iter()
+            .map(|info| connected_client_to_response(&state.shard, info))
+            .collect(),
+    };
+    Ok(Json(clients_from_wire(response)))
+}
+
+/// `GET /clients/{client_id}`: fetch one connected client as the same
+/// `ClientInfoDetails` JSON the legacy server returns; 404 when absent.
+///
+/// The path id is the `u32` wire client id (the seq tail of the u128 transport
+/// id), matching the legacy route's `Path<u32>`. There is no reverse map from
+/// that id to a home shard, so this gathers every shard's clients and filters -
+/// the same fan-out-and-filter as [`get_clients`] and the TCP `get_client`
+/// dispatch. The wire-to-domain conversion is infallible.
+async fn get_client(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Path(client_id): Path<u32>,
+    Query(query): Query<ConsistencyQuery>,
+) -> Result<Json<ClientInfoDetails>, ReadError> {
+    authorize_read(&state, &identity, query.consistency, |permissioner, uid| {
+        permissioner.get_client(uid)
+    })?;
+    let infos = SendWrapper::new(state.shard.list_all_clients()).await;
+    // The wire client id is the u32 seq tail of the u128 transport id.
+    #[allow(clippy::cast_possible_truncation)]
+    let info = infos
+        .iter()
+        .find(|info| info.client_id as u32 == client_id)
+        .ok_or(ReadError::NotFound)?;
+    let consumer_groups = info.vsr_client_id.map_or_else(Vec::new, |vsr_client_id| {
+        state
+            .shard
+            .plane
+            .metadata()
+            .mux_stm
+            .streams()
+            .consumer_group_memberships(vsr_client_id)
+            .into_iter()
+            .map(
+                |(stream_id, topic_id, group_id)| ConsumerGroupInfoResponse {
+                    stream_id,
+                    topic_id,
+                    group_id,
+                },
+            )
+            .collect()
+    });
+    let response = ClientDetailsResponse {
+        client: connected_client_to_response(&state.shard, info),
+        consumer_groups,
+    };
+    Ok(Json(ClientInfoDetails::from(response)))
+}
+
+/// `POST /streams`: create a stream and render the committed reply as the same
+/// `StreamDetails` JSON the legacy server returns.
+///
+/// The accepted body is name-only (`{"name": ...}`), matching the legacy
+/// request; server-ng's wire `CreateStreamRequest` is likewise name-only and
+/// auto-assigns the id, so there is no client-supplied stream id to honor.
+async fn create_stream(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Json(command): Json<CreateStream>,
+) -> Result<Json<StreamDetails>, WriteError> {
+    let request = CreateStreamRequest {
+        name: WireName::new(command.name)
+            .map_err(|_| WriteError::Rejected(IggyError::InvalidStreamName))?,
+    };
+    let body = request.to_bytes();
+    let payload = SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::CreateStream,
+        &body,
+    ))
+    .await?;
+    Ok(Json(decode_stream_details(&payload)?))
+}
+
+/// `PUT /streams/{stream_id}`: rename a stream. A committed write returns 204
+/// with no body, matching the legacy server.
+async fn update_stream(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path(stream_id): Path<String>,
+    Json(command): Json<UpdateStream>,
+) -> Result<StatusCode, WriteError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(WriteError::Rejected)?;
+    let request = UpdateStreamRequest {
+        stream_id: identifier_to_wire(&stream_id).map_err(WriteError::Rejected)?,
+        name: WireName::new(command.name)
+            .map_err(|_| WriteError::Rejected(IggyError::InvalidStreamName))?,
+    };
+    let body = request.to_bytes();
+    SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::UpdateStream,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `DELETE /streams/{stream_id}`: delete a stream. Returns 204 on commit.
+async fn delete_stream(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path(stream_id): Path<String>,
+) -> Result<StatusCode, WriteError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(WriteError::Rejected)?;
+    let request = DeleteStreamRequest {
+        stream_id: identifier_to_wire(&stream_id).map_err(WriteError::Rejected)?,
+    };
+    let body = request.to_bytes();
+    SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::DeleteStream,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `DELETE /streams/{stream_id}/purge`: drop a stream's messages. Returns 204.
+async fn purge_stream(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path(stream_id): Path<String>,
+) -> Result<StatusCode, WriteError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(WriteError::Rejected)?;
+    let request = PurgeStreamRequest {
+        stream_id: identifier_to_wire(&stream_id).map_err(WriteError::Rejected)?,
+    };
+    let body = request.to_bytes();
+    SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::PurgeStream,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `POST /streams/{stream_id}/topics`: create a topic under a stream and render
+/// the committed reply as the same `TopicDetails` JSON the legacy server returns.
+///
+/// The stream comes from the path; the JSON body carries the remaining fields.
+/// The submitted op is a plain `CreateTopic`; the metadata owner allocates the
+/// consensus group ids and rewrites it to `CreateTopicWithAssignments` before
+/// replication, so this handler stays a pure submit-and-decode.
+async fn create_topic(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path(stream_id): Path<String>,
+    Json(command): Json<CreateTopic>,
+) -> Result<Json<TopicDetails>, WriteError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(WriteError::Rejected)?;
+    // Rejects empty/oversized name, partitions_count > MAX, replication_factor == Some(0).
+    command.validate().map_err(WriteError::Rejected)?;
+    let request = CreateTopicRequest {
+        stream_id: identifier_to_wire(&stream_id).map_err(WriteError::Rejected)?,
+        partitions_count: command.partitions_count,
+        compression_algorithm: command.compression_algorithm.as_code(),
+        message_expiry: command.message_expiry.into(),
+        max_topic_size: command.max_topic_size.into(),
+        replication_factor: command.replication_factor.unwrap_or(0),
+        name: WireName::new(command.name)
+            .map_err(|_| WriteError::Rejected(IggyError::InvalidTopicName))?,
+    };
+    let body = request.to_bytes();
+    let payload = SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::CreateTopic,
+        &body,
+    ))
+    .await?;
+    Ok(Json(decode_topic_details(&payload)?))
+}
+
+/// `PUT /streams/{stream_id}/topics/{topic_id}`: update a topic. Returns 204.
+async fn update_topic(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path((stream_id, topic_id)): Path<(String, String)>,
+    Json(command): Json<UpdateTopic>,
+) -> Result<StatusCode, WriteError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(WriteError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(WriteError::Rejected)?;
+    // Also rejects replication_factor == Some(0), which `WireName` cannot see.
+    command.validate().map_err(WriteError::Rejected)?;
+    let request = UpdateTopicRequest {
+        stream_id: identifier_to_wire(&stream_id).map_err(WriteError::Rejected)?,
+        topic_id: identifier_to_wire(&topic_id).map_err(WriteError::Rejected)?,
+        compression_algorithm: command.compression_algorithm.as_code(),
+        message_expiry: command.message_expiry.into(),
+        max_topic_size: command.max_topic_size.into(),
+        replication_factor: command.replication_factor.unwrap_or(0),
+        name: WireName::new(command.name)
+            .map_err(|_| WriteError::Rejected(IggyError::InvalidTopicName))?,
+    };
+    let body = request.to_bytes();
+    SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::UpdateTopic,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `DELETE /streams/{stream_id}/topics/{topic_id}`: delete a topic. Returns 204.
+async fn delete_topic(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path((stream_id, topic_id)): Path<(String, String)>,
+) -> Result<StatusCode, WriteError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(WriteError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(WriteError::Rejected)?;
+    let request = DeleteTopicRequest {
+        stream_id: identifier_to_wire(&stream_id).map_err(WriteError::Rejected)?,
+        topic_id: identifier_to_wire(&topic_id).map_err(WriteError::Rejected)?,
+    };
+    let body = request.to_bytes();
+    SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::DeleteTopic,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `DELETE /streams/{stream_id}/topics/{topic_id}/purge`: drop a topic's
+/// messages. Returns 204.
+async fn purge_topic(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path((stream_id, topic_id)): Path<(String, String)>,
+) -> Result<StatusCode, WriteError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(WriteError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(WriteError::Rejected)?;
+    let request = PurgeTopicRequest {
+        stream_id: identifier_to_wire(&stream_id).map_err(WriteError::Rejected)?,
+        topic_id: identifier_to_wire(&topic_id).map_err(WriteError::Rejected)?,
+    };
+    let body = request.to_bytes();
+    SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::PurgeTopic,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `GET /streams/{stream_id}/topics/{topic_id}/messages`: poll a batch of
+/// messages as the same `PolledMessages` JSON the legacy server returns. The
+/// query is the same flattened `PollMessages` shape the legacy server accepts
+/// (`consumer_id`, `partition_id`, strategy `kind`+`value`, `count`,
+/// `auto_commit`); stream and topic come from the path.
+///
+/// A non-replicated read served in band: the same resolution the TCP dispatch
+/// runs ([`resolve_poll_request`]), then a mesh read on the owning shard and a
+/// re-encode of the stored batches into the legacy wire body, decoded here by
+/// the SDK's own [`PolledMessages::from_bytes`] so the JSON is field-identical
+/// to a TCP poll. `auto_commit` rides [`resolve_poll_request`]'s args and is
+/// honored by the owning shard's poll plan; no extra HTTP work.
+async fn poll_messages(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Path((stream_id, topic_id)): Path<(String, String)>,
+    Query(query): Query<PollMessages>,
+    Query(consistency): Query<ConsistencyQuery>,
+) -> Result<Json<PolledMessages>, ReadError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(ReadError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(ReadError::Rejected)?;
+    let scope = resolve_gate_topic_ids(&state, &stream_id, &topic_id);
+    authorize_read(
+        &state,
+        &identity,
+        consistency.consistency,
+        |permissioner, uid| {
+            scope.map_or(Ok(()), |(stream_id, topic_id)| {
+                permissioner.poll_messages(uid, stream_id, topic_id)
+            })
+        },
+    )?;
+    let wire = poll_wire_request(&stream_id, &topic_id, &query).map_err(ReadError::Rejected)?;
+    let (namespace, partition_id, consumer, args) =
+        match resolve_poll_request(&state.shard, &wire, HTTP_READ_CLIENT_ID) {
+            Ok(decoded) => decoded,
+            // TCP parity: a fenced group poll answers 200 with the re-sync
+            // sentinel partition, not an error (see [`HTTP_READ_CLIENT_ID`]).
+            Err(IggyError::ConsumerGroupPartitionNotOwned(..)) => {
+                return Ok(Json(resync_required_polled_messages()));
+            }
+            // The remaining resolver failures are STM lookups that came up
+            // empty (unknown stream, topic, partition, or consumer group), so
+            // they render as the legacy 404 body.
+            Err(_) => return Err(ReadError::NotFound),
+        };
+    let reply = SendWrapper::new(
+        state
+            .shard
+            .partition_read(namespace, PartitionRead::Poll { consumer, args }),
+    )
+    .await;
+    match reply {
+        Some(PartitionReadReply::Poll {
+            fragments,
+            current_offset,
+        }) => {
+            let body = build_polled_messages_body(partition_id, current_offset, fragments)
+                .map_err(ReadError::Rejected)?;
+            Ok(Json(
+                PolledMessages::from_bytes(body).map_err(ReadError::Rejected)?,
+            ))
+        }
+        Some(PartitionReadReply::NotFound) => Err(ReadError::NotFound),
+        Some(_) => Err(ReadError::Rejected(IggyError::InvalidCommand)),
+        None => Err(ReadError::Timeout),
+    }
+}
+
+/// `GET /streams/{stream_id}/topics/{topic_id}/consumer-offsets`: fetch a
+/// consumer's stored offset as the same `ConsumerOffsetInfo` JSON the legacy
+/// server returns. The query is the same flattened `GetConsumerOffset` shape
+/// the legacy server accepts (`consumer_id`, optional `partition_id`).
+///
+/// A non-replicated read served in band, mirroring [`poll_messages`]. A
+/// missing offset (never stored, or the partition unknown to its owner) is
+/// the legacy 404: the TCP path replies an empty body the SDK decodes as
+/// `None`, and the legacy HTTP server renders that `None` as
+/// `CustomError::ResourceNotFound`.
+async fn get_consumer_offset(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Path((stream_id, topic_id)): Path<(String, String)>,
+    Query(query): Query<GetConsumerOffset>,
+    Query(consistency): Query<ConsistencyQuery>,
+) -> Result<Json<ConsumerOffsetInfo>, ReadError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(ReadError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(ReadError::Rejected)?;
+    let scope = resolve_gate_topic_ids(&state, &stream_id, &topic_id);
+    authorize_read(
+        &state,
+        &identity,
+        consistency.consistency,
+        |permissioner, uid| {
+            scope.map_or(Ok(()), |(stream_id, topic_id)| {
+                permissioner.get_consumer_offset(uid, stream_id, topic_id)
+            })
+        },
+    )?;
+    let wire =
+        consumer_offset_wire_request(&stream_id, &topic_id, &query).map_err(ReadError::Rejected)?;
+    let (namespace, partition_id, consumer) =
+        resolve_consumer_offset_request(&state.shard, &wire).map_err(|_| ReadError::NotFound)?;
+    let reply = SendWrapper::new(
+        state
+            .shard
+            .partition_read(namespace, PartitionRead::ConsumerOffset { consumer }),
+    )
+    .await;
+    match reply {
+        Some(PartitionReadReply::ConsumerOffset {
+            stored: Some(stored_offset),
+            current_offset,
+        }) => Ok(Json(ConsumerOffsetInfo {
+            partition_id,
+            current_offset,
+            stored_offset,
+        })),
+        Some(
+            PartitionReadReply::ConsumerOffset { stored: None, .. } | PartitionReadReply::NotFound,
+        ) => Err(ReadError::NotFound),
+        Some(_) => Err(ReadError::Rejected(IggyError::InvalidCommand)),
+        None => Err(ReadError::Timeout),
+    }
+}
+
+/// `POST /streams/{stream_id}/topics/{topic_id}/messages`: produce a batch of
+/// messages to a topic. The JSON body is the same `SendMessages` shape the
+/// legacy server accepts (partitioning + base64 messages); stream and topic
+/// come from the path.
+///
+/// Data plane, not control plane: the batch rides the partition group's own
+/// consensus (at-least-once, no dedup, no session gate - concurrent produces
+/// on one credential are legal), and the committed reply comes back through
+/// the session's in-process reply slot rather than a submit return value.
+/// The default answers 201 + `X-Iggy-Durability: replicated-memory` only
+/// after the quorum commit; `?ack=none` answers 202 + `X-Iggy-Durability:
+/// none` immediately after dispatch.
+async fn send_messages(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path((stream_id, topic_id)): Path<(String, String)>,
+    Query(query): Query<ProduceQuery>,
+    Json(command): Json<SendMessages>,
+) -> Result<Response, PartitionWriteError> {
+    let stream_id =
+        Identifier::from_str_value(&stream_id).map_err(PartitionWriteError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(PartitionWriteError::Rejected)?;
+    // RBAC: authorize the produce on (stream, topic) before any consensus work.
+    // Handler-side so an HTTP denial never enters the partition plane.
+    authorize_data_plane(
+        &state,
+        identity.session.user_id,
+        &stream_id,
+        &topic_id,
+        Permissioner::append_messages,
+    )
+    .map_err(PartitionWriteError::Rejected)?;
+    // Rejects an oversized partitioning key and an empty or oversized batch.
+    command.validate().map_err(PartitionWriteError::Rejected)?;
+    let body = encode_send_messages(&stream_id, &topic_id, &command)
+        .map_err(PartitionWriteError::Rejected)?;
+    match query.ack {
+        ProduceAck::Replicated => {
+            SendWrapper::new(partition_write_replicated(
+                &state,
+                &identity.session,
+                Operation::SendMessages,
+                &body,
+            ))
+            .await?;
+            Ok((
+                StatusCode::CREATED,
+                [(
+                    DURABILITY_HEADER,
+                    HeaderValue::from_static(DURABILITY_REPLICATED_MEMORY),
+                )],
+            )
+                .into_response())
+        }
+        ProduceAck::None => {
+            SendWrapper::new(produce_unacked(&state, &identity.session, &body)).await;
+            Ok((
+                StatusCode::ACCEPTED,
+                [(DURABILITY_HEADER, HeaderValue::from_static(DURABILITY_NONE))],
+            )
+                .into_response())
+        }
+    }
+}
+
+/// `PUT /streams/{stream_id}/topics/{topic_id}/consumer-offsets`: store a
+/// consumer's offset. The JSON body is the same `StoreConsumerOffset` shape the
+/// legacy server accepts (flattened `consumer_id`, optional `partition_id`,
+/// `offset`); stream and topic come from the path. Returns 204 on commit,
+/// matching the legacy server.
+///
+/// Data plane like a produce: the offset write is a replicated op on the
+/// partition group's own consensus, awaited through the session's in-process
+/// reply slot ([`partition_write_replicated`]). The v2 wire op is pinned to
+/// `ack = Quorum` - `?ack=none` is a produce-only surface. The consumer
+/// identifier passes through on the wire; the dispatch resolvers hash named
+/// consumers and rewrite group ids server-side, identically to TCP.
+async fn store_consumer_offset(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path((stream_id, topic_id)): Path<(String, String)>,
+    Json(command): Json<StoreConsumerOffset>,
+) -> Result<StatusCode, PartitionWriteError> {
+    let stream_id =
+        Identifier::from_str_value(&stream_id).map_err(PartitionWriteError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(PartitionWriteError::Rejected)?;
+    // RBAC: authorize the offset write on (stream, topic) handler-side.
+    authorize_data_plane(
+        &state,
+        identity.session.user_id,
+        &stream_id,
+        &topic_id,
+        Permissioner::store_consumer_offset,
+    )
+    .map_err(PartitionWriteError::Rejected)?;
+    let request = store_offset_wire_request(&stream_id, &topic_id, &command)
+        .map_err(PartitionWriteError::Rejected)?;
+    let body = request.to_bytes();
+    SendWrapper::new(partition_write_replicated(
+        &state,
+        &identity.session,
+        Operation::StoreConsumerOffset2,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `DELETE /streams/{stream_id}/topics/{topic_id}/consumer-offsets/{consumer_id}`:
+/// delete a consumer's stored offset. The consumer comes from the path and the
+/// optional `partition_id` from the query, the same `DeleteConsumerOffset`
+/// shape the legacy server accepts. Returns 204 on commit, matching the legacy
+/// server; a delete of a never-stored offset is denied by the partition
+/// primary (`ReplyHeader.status`) and renders the legacy typed 404. Same
+/// replicated partition write as [`store_consumer_offset`].
+async fn delete_consumer_offset(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path((stream_id, topic_id, consumer_id)): Path<(String, String, String)>,
+    Query(query): Query<DeleteConsumerOffset>,
+) -> Result<StatusCode, PartitionWriteError> {
+    let stream_id =
+        Identifier::from_str_value(&stream_id).map_err(PartitionWriteError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(PartitionWriteError::Rejected)?;
+    // RBAC: authorize the offset delete on (stream, topic) handler-side.
+    authorize_data_plane(
+        &state,
+        identity.session.user_id,
+        &stream_id,
+        &topic_id,
+        Permissioner::delete_consumer_offset,
+    )
+    .map_err(PartitionWriteError::Rejected)?;
+    // `Consumer::new` fixes the kind to `Consumer`, exactly as the legacy
+    // handler does; HTTP cannot express a group-kind offset op.
+    let consumer = Consumer::new(
+        Identifier::from_str_value(&consumer_id).map_err(PartitionWriteError::Rejected)?,
+    );
+    let request = delete_offset_wire_request(&stream_id, &topic_id, &consumer, query.partition_id)
+        .map_err(PartitionWriteError::Rejected)?;
+    let body = request.to_bytes();
+    SendWrapper::new(partition_write_replicated(
+        &state,
+        &identity.session,
+        Operation::DeleteConsumerOffset2,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `POST /streams/{stream_id}/topics/{topic_id}/consumer-groups`: create a
+/// consumer group under a topic and render the committed reply as the same
+/// `ConsumerGroupDetails` JSON the legacy server returns.
+///
+/// The stream and topic come from the path; the JSON body is name-only
+/// (`{"name": ...}`), matching the legacy request. The submitted op is a plain
+/// `CreateConsumerGroup`; the metadata owner assigns the group id, so this
+/// handler never allocates one itself.
+async fn create_cg(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path((stream_id, topic_id)): Path<(String, String)>,
+    Json(command): Json<CreateConsumerGroup>,
+) -> Result<Json<ConsumerGroupDetails>, WriteError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(WriteError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(WriteError::Rejected)?;
+    let request = CreateConsumerGroupRequest {
+        stream_id: identifier_to_wire(&stream_id).map_err(WriteError::Rejected)?,
+        topic_id: identifier_to_wire(&topic_id).map_err(WriteError::Rejected)?,
+        name: WireName::new(command.name)
+            .map_err(|_| WriteError::Rejected(IggyError::InvalidConsumerGroupName))?,
+    };
+    let body = request.to_bytes();
+    let payload = SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::CreateConsumerGroup,
+        &body,
+    ))
+    .await?;
+    Ok(Json(decode_consumer_group_details(&payload)?))
+}
+
+/// `DELETE /streams/{stream_id}/topics/{topic_id}/consumer-groups/{group_id}`:
+/// delete a consumer group. Returns 204 on commit.
+async fn delete_cg(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path((stream_id, topic_id, group_id)): Path<(String, String, String)>,
+) -> Result<StatusCode, WriteError> {
+    let stream_id = Identifier::from_str_value(&stream_id).map_err(WriteError::Rejected)?;
+    let topic_id = Identifier::from_str_value(&topic_id).map_err(WriteError::Rejected)?;
+    let group_id = Identifier::from_str_value(&group_id).map_err(WriteError::Rejected)?;
+    let request = DeleteConsumerGroupRequest {
+        stream_id: identifier_to_wire(&stream_id).map_err(WriteError::Rejected)?,
+        topic_id: identifier_to_wire(&topic_id).map_err(WriteError::Rejected)?,
+        group_id: identifier_to_wire(&group_id).map_err(WriteError::Rejected)?,
+    };
+    let body = request.to_bytes();
+    SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::DeleteConsumerGroup,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `POST /users`: create a user and render the committed reply as the same
+/// `UserInfoDetails` JSON the legacy server returns.
+///
+/// The plaintext password rides the JSON body; [`submit_write`] hashes it on
+/// shard 0 before the request enters consensus (see
+/// [`maybe_rewrite_user_password_request`]), so no plaintext is ever replicated.
+async fn create_user(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Json(command): Json<CreateUser>,
+) -> Result<Json<UserInfoDetails>, WriteError> {
+    // Rejects empty/oversized username or password before any consensus work.
+    command.validate().map_err(WriteError::Rejected)?;
+    let request = CreateUserRequest {
+        username: WireName::new(&command.username)
+            .map_err(|_| WriteError::Rejected(IggyError::InvalidUsername))?,
+        password: command.password.expose_secret().to_string(),
+        status: command.status.as_code(),
+        permissions: command.permissions.as_ref().map(permissions_to_wire),
+    };
+    let body = request.to_bytes();
+    let payload = SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::CreateUser,
+        &body,
+    ))
+    .await?;
+    Ok(Json(decode_user_details(&payload)?))
+}
+
+/// `PUT /users/{user_id}`: update a user's username and/or status. Returns 204.
+async fn update_user(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path(user_id): Path<String>,
+    Json(command): Json<UpdateUser>,
+) -> Result<StatusCode, WriteError> {
+    let user_id = Identifier::from_str_value(&user_id).map_err(WriteError::Rejected)?;
+    // Rejects an oversized replacement username; a no-op when username is absent.
+    command.validate().map_err(WriteError::Rejected)?;
+    let request = UpdateUserRequest {
+        user_id: identifier_to_wire(&user_id).map_err(WriteError::Rejected)?,
+        username: command
+            .username
+            .as_deref()
+            .map(WireName::new)
+            .transpose()
+            .map_err(|_| WriteError::Rejected(IggyError::InvalidUsername))?,
+        status: command.status.map(|status| status.as_code()),
+    };
+    let body = request.to_bytes();
+    SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::UpdateUser,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `DELETE /users/{user_id}`: delete a user. Returns 204.
+async fn delete_user(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path(user_id): Path<String>,
+) -> Result<StatusCode, WriteError> {
+    let user_id = Identifier::from_str_value(&user_id).map_err(WriteError::Rejected)?;
+    let request = DeleteUserRequest {
+        user_id: identifier_to_wire(&user_id).map_err(WriteError::Rejected)?,
+    };
+    let body = request.to_bytes();
+    SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::DeleteUser,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `PUT /users/{user_id}/password`: change a user's password. Returns 204.
+///
+/// Both passwords ride the JSON body in plaintext. On shard 0, before the op
+/// enters consensus, [`maybe_rewrite_user_password_request`] hashes the new
+/// password and strips the current one (so neither plaintext is ever
+/// replicated), and verifies `current_password` against the target's stored
+/// hash -- a wrong one rejects as 400 `InvalidCredentials`.
+async fn change_password(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path(user_id): Path<String>,
+    Json(command): Json<ChangePassword>,
+) -> Result<StatusCode, WriteError> {
+    let user_id = Identifier::from_str_value(&user_id).map_err(WriteError::Rejected)?;
+    // Rejects empty/oversized current or new password before any consensus work.
+    command.validate().map_err(WriteError::Rejected)?;
+    let request = ChangePasswordRequest {
+        user_id: identifier_to_wire(&user_id).map_err(WriteError::Rejected)?,
+        current_password: command.current_password.expose_secret().to_string(),
+        new_password: command.new_password.expose_secret().to_string(),
+    };
+    let body = request.to_bytes();
+    SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::ChangePassword,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `PUT /users/{user_id}/permissions`: replace a user's permissions. Returns 204.
+async fn update_permissions(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path(user_id): Path<String>,
+    Json(command): Json<UpdatePermissions>,
+) -> Result<StatusCode, WriteError> {
+    let user_id = Identifier::from_str_value(&user_id).map_err(WriteError::Rejected)?;
+    command.validate().map_err(WriteError::Rejected)?;
+    let request = UpdatePermissionsRequest {
+        user_id: identifier_to_wire(&user_id).map_err(WriteError::Rejected)?,
+        permissions: command.permissions.as_ref().map(permissions_to_wire),
+    };
+    let body = request.to_bytes();
+    SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::UpdatePermissions,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// `GET /personal-access-tokens`: list the caller's own tokens (name +
+/// expiry, never the secrets) as the same `Vec<PersonalAccessTokenInfo>` JSON
+/// the legacy server returns. Self-scoped, so authentication is the whole
+/// rule - no permissioner check, matching legacy parity. A consensus-free
+/// local STM read via [`read_local`].
+async fn get_pats(
+    State(state): State<HttpState>,
+    identity: Identity,
+    Query(query): Query<ConsistencyQuery>,
+) -> Result<Json<Vec<PersonalAccessTokenInfo>>, ReadError> {
+    let body = GetPersonalAccessTokensRequest.to_bytes();
+    let bytes = read_local(
+        &state,
+        &identity,
+        query.consistency,
+        GET_PERSONAL_ACCESS_TOKENS_CODE,
+        &body,
+        |_, _| Ok(()),
+    )?;
+    let response = GetPersonalAccessTokensResponse::decode_from(&bytes)
+        .map_err(|_| ReadError::Rejected(IggyError::InvalidCommand))?;
+    Ok(Json(personal_access_tokens_from_wire(response)))
+}
+
+/// `POST /personal-access-tokens`: mint a personal access token for the caller
+/// and return its one-time raw secret as the same `{"token": ...}` JSON the
+/// legacy server returns, with HTTP 200.
+///
+/// The raw token is non-deterministic and secret, so it must never enter
+/// consensus: [`rewrite_pat_request_for_user`] (invoked inside
+/// [`submit_committed`]) mints it on shard 0 and replicates only its hash, so a
+/// successful committed reply body is empty. [`build_raw_pat_reply`] then splices
+/// the raw secret back into that reply locally, using the confirmed commit
+/// position. The token is surfaced only after the write commits; a malformed
+/// splice fails closed rather than emitting a blank token.
+///
+/// A committed create can still carry a business rejection (duplicate name,
+/// invalid expiry), so the result code is honored via [`committed_payload`] -
+/// exactly as the mechanical routes do - BEFORE the secret is spliced. Only a
+/// genuine success gets a token; a rejection renders the legacy error instead of
+/// a bogus 200 + token.
+async fn create_pat(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Json(command): Json<CreatePersonalAccessToken>,
+) -> Result<Json<RawPersonalAccessToken>, WriteError> {
+    // Rejects an empty/oversized token name before any consensus work.
+    command.validate().map_err(WriteError::Rejected)?;
+    let request = CreatePersonalAccessTokenRequest {
+        name: WireName::new(&command.name)
+            .map_err(|_| WriteError::Rejected(IggyError::InvalidPersonalAccessTokenName))?,
+        expiry: command.expiry.into(),
+    };
+    let body = request.to_bytes();
+    let (request_header, committed, raw_token) = SendWrapper::new(submit_committed(
+        &state,
+        &identity.session,
+        Operation::CreatePersonalAccessToken,
+        &body,
+    ))
+    .await?;
+    // Reject a committed business error before splicing the secret; the success
+    // payload is empty, so the returned slice is discarded.
+    committed_payload(&committed)?;
+    let reply =
+        build_raw_pat_reply(&request_header, committed, raw_token).map_err(WriteError::Rejected)?;
+    Ok(Json(RawPersonalAccessToken {
+        token: decode_raw_pat_token(&reply)?,
+    }))
+}
+
+/// `DELETE /personal-access-tokens/{name}`: delete one of the caller's tokens by
+/// name. Returns 204 on commit. Self-scoped: any authenticated user may delete
+/// their own tokens (the in-apply gate skips PAT ops), matching legacy parity.
+async fn delete_pat(
+    State(state): State<HttpState>,
+    identity: Authenticated,
+    Path(name): Path<String>,
+) -> Result<StatusCode, WriteError> {
+    let request = DeletePersonalAccessTokenRequest {
+        name: WireName::new(&name)
+            .map_err(|_| WriteError::Rejected(IggyError::InvalidPersonalAccessTokenName))?,
+    };
+    let body = request.to_bytes();
+    SendWrapper::new(submit_write(
+        &state,
+        &identity.session,
+        Operation::DeletePersonalAccessToken,
+        &body,
+    ))
+    .await?;
+    Ok(StatusCode::NO_CONTENT)
+}
+
+/// Run one authenticated control-plane write to commit and hand back the
+/// committed reply `Message`, the request header, and any raw PAT token minted
+/// along the way. Shared core of every HTTP write: [`submit_write`] decodes the
+/// reply body for the stream/topic/user routes, while [`create_pat`] needs the
+/// raw `Message` + request header to substitute the one-time token.
+///
+/// Control-plane writes are authorized in-apply on the metadata STM, so this
+/// runs no pre-submit gate; it drives the gate-locked submit
+/// ([`submit_gated`]) on a detached shard-0 task and awaits its outcome over a
+/// oneshot. Axum drops this handler future the moment the HTTP client
+/// disconnects; were the gate held here, that drop could land between the
+/// pump-driven consensus commit and the id advance, leaving the committed op's
+/// reply cached under an id the session still considers unused - the next
+/// write would collide into `Duplicate` and silently receive the prior op's
+/// reply. Detaching moves the whole critical section out of the cancellable
+/// future; a disconnect now only drops the receiver half.
+async fn submit_committed(
+    state: &HttpInner,
+    session: &Rc<HttpSession>,
+    operation: Operation,
+    body: &[u8],
+) -> Result<(RequestHeader, Message<GenericHeader>, Option<String>), WriteError> {
+    // Control writes are authorized in-apply on the metadata STM: a denial
+    // comes back as `Unauthorized` in the committed result section, which
+    // `committed_payload` maps to a 403. No pre-submit gate here, so the
+    // in-apply check is the single source of truth. Self-scoped PAT ops (which
+    // the in-apply gate skips) reach here as any authenticated user, matching
+    // legacy parity.
+    let (result_slot, committed) = oneshot::channel();
+    let shard = Rc::clone(&state.shard);
+    let task_session = Rc::clone(session);
+    let body = body.to_vec();
+    // Detached so a client disconnect cannot split a commit from its id
+    // advance; both happen inside the task regardless of handler liveness.
+    compio::runtime::spawn(async move {
+        let result = submit_gated(&shard, &task_session, operation, &body).await;
+        // A failed send means the handler died mid-await. The id advance
+        // already happened above, which is the invariant that matters.
+        let _ = result_slot.send(result);
+    })
+    .detach();
+    // `Canceled` = the task was dropped before sending (runtime teardown), a
+    // transient server condition like an unanswered submit.
+    let outcome = committed.await.map_err(|_| WriteError::Unavailable)?;
+    // An eviction means this session's VSR slot is gone: forget the entry so
+    // the caller's next request re-registers instead of 401-looping on it.
+    if matches!(&outcome, Err(WriteError::Evicted(_))) {
+        state.forget_session(session);
+    }
+    outcome
+}
+
+/// Gate-locked core of [`submit_committed`], run on a task the HTTP client
+/// cannot cancel: serializes this session's writes behind its gate and holds
+/// it across the submit so request ids reach the primary strictly in order.
+/// The gate advances only on a committed `Reply`: a transient failure or an
+/// eviction leaves the id free for the caller's retry, which the depth-1
+/// dedup requires (the next accepted request must be `committed + 1`, so a
+/// consumed-but-uncommitted id would wedge the session on `RequestGap`). An
+/// eviction means the session is dead: mapped to 401, and [`submit_committed`]
+/// then forgets the entry so the caller's retry re-registers cleanly.
+///
+/// Both shard-0 request rewrites run here before consensus, mirroring the TCP
+/// dispatch path: the PAT rewrite mints a raw token and replicates only its hash
+/// (`CreatePersonalAccessToken`), and the user-password rewrite hashes the new
+/// password and, for `ChangePassword`, strips the current one and verifies it
+/// against the stored hash (`CreateUser` / `ChangePassword`). Both are no-ops
+/// for every other operation, so plaintext secrets never enter consensus on any
+/// write. On either rewrite's rejection (a malformed body, or a failed
+/// current-password check) the gate is released without advancing, leaving the
+/// id free.
+async fn submit_gated(
+    shard: &Rc<ServerNgShard>,
+    session: &HttpSession,
+    operation: Operation,
+    body: &[u8],
+) -> Result<(RequestHeader, Message<GenericHeader>, Option<String>), WriteError> {
+    let mut next_request_id = session.gate.lock().await;
+    let message = build_request_message(
+        operation,
+        session.client_id,
+        session.session,
+        *next_request_id,
+        body,
+    );
+    let (message, raw_token) =
+        rewrite_pat_request_for_user(session.user_id, message).map_err(WriteError::Rejected)?;
+    let message =
+        maybe_rewrite_user_password_request(shard, message).map_err(WriteError::Rejected)?;
+    let request_header = *message.header();
+    let reply = submit_client_request_on_owner(shard, message).await;
+    let Some(reply) = reply else {
+        return Err(WriteError::Unavailable);
+    };
+
+    match reply.header().command {
+        Command2::Reply => {
+            *next_request_id += 1;
+            drop(next_request_id);
+            Ok((request_header, reply, raw_token))
+        }
+        Command2::Eviction => Err(WriteError::Evicted(eviction_error(&reply))),
+        _ => Err(WriteError::Rejected(IggyError::InvalidCommand)),
+    }
+}
+
+/// Run one authenticated control-plane write end to end and return the committed
+/// reply's typed payload. Wraps [`submit_committed`] and decodes the reply body
+/// via [`committed_payload`]: `create_stream` decodes the payload into an entity,
+/// the update/delete/purge routes ignore it (it is empty) and answer 204.
+async fn submit_write(
+    state: &HttpInner,
+    session: &Rc<HttpSession>,
+    operation: Operation,
+    body: &[u8],
+) -> Result<Bytes, WriteError> {
+    let (_request_header, reply, _raw_token) =
+        submit_committed(state, session, operation, body).await?;
+    Ok(Bytes::copy_from_slice(committed_payload(&reply)?))
+}
+
+/// Tear down a caller's session for `DELETE /users/logout`: submit the VSR
+/// `Logout` that releases its client-table slot on every replica (the commit a
+/// TCP disconnect also submits), then forget the local session-table entry and
+/// its reply target so neither leaks.
+///
+/// Best-effort: a transient submit failure is logged and the local entry is
+/// dropped anyway, matching the disconnect path's contract that a logout never
+/// blocks on peer slot release (the orphaned slot LRU-evicts). The bearer is not
+/// revoked here - this listener's JWT half is issue+verify only, with no
+/// revocation list - so the SDK dropping the token client-side is what ends the
+/// credential; a caller that re-presents it just re-registers a fresh session.
+async fn logout_session(state: &HttpInner, session: &Rc<HttpSession>) {
+    // Synthetic request id: the logout apply keys on (client, session) only and
+    // is terminal for this session, so it needs no gate-issued contiguous id
+    // (mirrors the disconnect path's `u64::MAX`).
+    const LOGOUT_REQUEST_ID: u64 = u64::MAX;
+    if let Err(error) = submit_logout_on_owner(
+        &state.shard,
+        session.client_id,
+        session.session,
+        LOGOUT_REQUEST_ID,
+    )
+    .await
+    {
+        warn!(
+            ?error,
+            "server-ng HTTP: VSR Logout submit failed; slot lingers until eviction"
+        );
+    }
+    state.forget_session(session);
+}
+
+/// Encode a validated HTTP `SendMessages` into the `SendMessagesRequest` wire
+/// body, mirroring the SDK's TCP produce encode: identifier + partitioning
+/// wire conversion, then `RawMessage` borrows into [`SendMessagesEncoder`].
+/// Balanced / messages-key partitioning passes through untouched; the
+/// dispatch gates resolve it to a concrete partition server-side.
+fn encode_send_messages(
+    stream_id: &Identifier,
+    topic_id: &Identifier,
+    command: &SendMessages,
+) -> Result<Bytes, IggyError> {
+    let wire_stream_id = identifier_to_wire(stream_id)?;
+    let wire_topic_id = identifier_to_wire(topic_id)?;
+    let wire_partitioning = partitioning_to_wire(&command.partitioning)?;
+    // Two passes because the view accessors' return borrows are tied to the
+    // view value, not the batch buffer, so the views must outlive the borrows.
+    let views: Vec<IggyMessageView<'_>> = command.batch.iter().collect();
+    let raw_messages: Vec<RawMessage<'_>> = views
+        .iter()
+        .map(|view| RawMessage {
+            id: view.header().id(),
+            origin_timestamp: view.header().origin_timestamp(),
+            headers: view.user_headers(),
+            payload: view.payload(),
+        })
+        .collect();
+    let size = SendMessagesEncoder::encoded_size(
+        &wire_stream_id,
+        &wire_topic_id,
+        &wire_partitioning,
+        &raw_messages,
+    );
+    let mut buf = BytesMut::with_capacity(size);
+    SendMessagesEncoder::encode(
+        &mut buf,
+        &wire_stream_id,
+        &wire_topic_id,
+        &wire_partitioning,
+        &raw_messages,
+    );
+    Ok(buf.freeze())
+}
+
+/// Map a validated HTTP poll query onto the wire `PollMessagesRequest` the
+/// shared TCP resolver consumes, so both transports resolve one request shape.
+/// The query's consumer kind is structurally always `Consumer`
+/// (`Consumer::kind` is `#[serde(skip)]` - the flattened `kind` param names
+/// the polling strategy), matching the legacy HTTP server.
+fn poll_wire_request(
+    stream_id: &Identifier,
+    topic_id: &Identifier,
+    query: &PollMessages,
+) -> Result<PollMessagesRequest, IggyError> {
+    Ok(PollMessagesRequest {
+        consumer: WireConsumer {
+            kind: query.consumer.kind.as_code(),
+            id: identifier_to_wire(&query.consumer.id)?,
+        },
+        stream_id: identifier_to_wire(stream_id)?,
+        topic_id: identifier_to_wire(topic_id)?,
+        partition_id: query.partition_id,
+        strategy: WirePollingStrategy {
+            kind: query.strategy.kind.as_code(),
+            value: query.strategy.value,
+        },
+        count: query.count,
+        auto_commit: query.auto_commit,
+    })
+}
+
+/// Map a validated HTTP consumer-offset query onto the wire
+/// `GetConsumerOffsetRequest` the shared TCP resolver consumes. An omitted
+/// `partition_id` defaults to partition 0, matching the legacy server's
+/// `resolve_consumer_with_partition_id` (`partition_id.unwrap_or(0)`).
+fn consumer_offset_wire_request(
+    stream_id: &Identifier,
+    topic_id: &Identifier,
+    query: &GetConsumerOffset,
+) -> Result<GetConsumerOffsetRequest, IggyError> {
+    Ok(GetConsumerOffsetRequest {
+        consumer: WireConsumer {
+            kind: query.consumer.kind.as_code(),
+            id: identifier_to_wire(&query.consumer.id)?,
+        },
+        stream_id: identifier_to_wire(stream_id)?,
+        topic_id: identifier_to_wire(topic_id)?,
+        partition_id: Some(query.partition_id.unwrap_or(DEFAULT_PARTITION_ID)),
+    })
+}
+
+/// Map a validated HTTP store-offset body onto the v2 wire request
+/// (`StoreConsumerOffset2Request`), `ack` pinned to `Quorum` so the route can
+/// await the committed reply. The body's consumer kind is structurally always
+/// `Consumer` (`Consumer::kind` is `#[serde(skip)]`), matching the legacy HTTP
+/// server; `partition_id` passes through as the wire `Option` (flag byte +
+/// u32) for the server-side resolvers to ground.
+fn store_offset_wire_request(
+    stream_id: &Identifier,
+    topic_id: &Identifier,
+    command: &StoreConsumerOffset,
+) -> Result<StoreConsumerOffset2Request, IggyError> {
+    Ok(StoreConsumerOffset2Request {
+        consumer: consumer_to_wire(&command.consumer)?,
+        stream_id: identifier_to_wire(stream_id)?,
+        topic_id: identifier_to_wire(topic_id)?,
+        partition_id: command.partition_id,
+        offset: command.offset,
+        ack: AckLevel::Quorum,
+    })
+}
+
+/// Map a validated HTTP delete-offset request onto the v2 wire request
+/// (`DeleteConsumerOffset2Request`), `ack` pinned to `Quorum` like
+/// [`store_offset_wire_request`].
+fn delete_offset_wire_request(
+    stream_id: &Identifier,
+    topic_id: &Identifier,
+    consumer: &Consumer,
+    partition_id: Option<u32>,
+) -> Result<DeleteConsumerOffset2Request, IggyError> {
+    Ok(DeleteConsumerOffset2Request {
+        consumer: consumer_to_wire(consumer)?,
+        stream_id: identifier_to_wire(stream_id)?,
+        topic_id: identifier_to_wire(topic_id)?,
+        partition_id,
+        ack: AckLevel::Quorum,
+    })
+}
+
+/// The empty `PolledMessages` a fenced consumer-group poll answers, carrying
+/// the re-sync sentinel in `partition_id` exactly as the TCP dispatch replies
+/// it, so an SDK re-syncs its assignment instead of reading end-of-partition.
+const fn resync_required_polled_messages() -> PolledMessages {
+    PolledMessages {
+        partition_id: RESYNC_REQUIRED_PARTITION_SENTINEL,
+        current_offset: 0,
+        count: 0,
+        messages: Vec::new(),
+    }
+}
+
+/// In-flight admission token for one awaited partition write. One guard owns
+/// both releases (session + global) so success, every error return, the reply
+/// timeout, and handler cancellation (the client hanging up mid-await drops
+/// this future) all decrement through the same `Drop`.
+struct InFlightWriteGuard<'a> {
+    session_in_flight: &'a Cell<u32>,
+    global_in_flight: &'a Cell<u32>,
+}
+
+impl Drop for InFlightWriteGuard<'_> {
+    fn drop(&mut self) {
+        self.session_in_flight.set(self.session_in_flight.get() - 1);
+        self.global_in_flight.set(self.global_in_flight.get() - 1);
+    }
+}
+
+/// Admit one awaited partition write against the per-session cap and the
+/// shard-0 global budget, incrementing both counters only when both pass. The
+/// session cap is checked first so a session that saturates itself reads as
+/// its own 429 rather than as server-wide pressure.
+fn admit_partition_write<'a>(
+    session_in_flight: &'a Cell<u32>,
+    global_in_flight: &'a Cell<u32>,
+) -> Result<InFlightWriteGuard<'a>, PartitionWriteError> {
+    if session_in_flight.get() >= MAX_IN_FLIGHT_WRITES_PER_SESSION {
+        return Err(PartitionWriteError::TooManyInFlight);
+    }
+    if global_in_flight.get() >= MAX_IN_FLIGHT_WRITES_GLOBAL {
+        return Err(PartitionWriteError::ServerBusy);
+    }
+    session_in_flight.set(session_in_flight.get() + 1);
+    global_in_flight.set(global_in_flight.get() + 1);
+    Ok(InFlightWriteGuard {
+        session_in_flight,
+        global_in_flight,
+    })
+}
+
+/// Run one awaited partition write (produce / consumer-offset write) end to
+/// end: admit against the in-flight caps, install the reply slot, dispatch
+/// into the partition plane, and wait (bounded) for the committed reply.
+///
+/// Slot-before-dispatch is load-bearing: every pre-dispatch gate failure
+/// inside [`dispatch_partition_request`] replies through `send_to_client`,
+/// which fires an installed slot, so one slot catches every exit. The slot
+/// guard borrows the registry, which is why this whole future runs inside
+/// the caller's `SendWrapper` on shard 0.
+async fn partition_write_replicated(
+    state: &HttpInner,
+    session: &HttpSession,
+    operation: Operation,
+    body: &[u8],
+) -> Result<(), PartitionWriteError> {
+    // Admission sits here rather than before body decode: axum's extractors
+    // already buffered and deserialized the body (bounded by the router-wide
+    // `DefaultBodyLimit`) before the handler ran, so the caps gate what is
+    // actually unbounded - the slot install, the dispatch, and the parked
+    // reply await that pins this request's buffers for up to the reply
+    // timeout. Held across every exit below; released by `Drop`.
+    let _in_flight = admit_partition_write(&session.in_flight_writes, &state.in_flight_writes)?;
+    ensure_in_process_reply_target(state, session);
+    let request_id = session.next_data_request_id();
+    let message = build_request_message(
+        operation,
+        session.client_id,
+        session.session,
+        request_id,
+        body,
+    );
+    let (guard, receiver) = state
+        .shard
+        .bus
+        .clients()
+        .install_reply_slot(session.client_id, request_id)
+        .map_err(|error| {
+            warn!(
+                ?error,
+                ?operation,
+                "server-ng HTTP: partition write reply slot install failed"
+            );
+            PartitionWriteError::Unavailable
+        })?;
+    dispatch_partition_request(
+        &state.shard,
+        message,
+        session.client_id,
+        session.session,
+        session.client_id,
+        Some(session.user_id),
+    )
+    .await;
+    let outcome = compio::time::timeout(PARTITION_WRITE_REPLY_TIMEOUT, receiver).await;
+    // Removes the slot unless the reply already fired, so a late commit
+    // reply after a timeout sheds at the bus instead of leaking a waiter.
+    drop(guard);
+    match outcome {
+        Ok(Ok(reply)) => classify_partition_reply(&reply),
+        // Cancelled (reply target torn down by session eviction mid-wait) or
+        // elapsed: same caller contract either way - outcome unknown, 504.
+        Ok(Err(_)) | Err(_) => Err(PartitionWriteError::Timeout(operation)),
+    }
+}
+
+/// Fire-and-forget produce (`?ack=none`): no reply slot, no wait. The commit
+/// still happens; its reply (and any gate-failure reply) targets a request id
+/// with no slot installed and is shed at the bus by design.
+async fn produce_unacked(state: &HttpInner, session: &HttpSession, body: &[u8]) {
+    let request_id = session.next_data_request_id();
+    let message = build_request_message(
+        Operation::SendMessages,
+        session.client_id,
+        session.session,
+        request_id,
+        body,
+    );
+    dispatch_partition_request(
+        &state.shard,
+        message,
+        session.client_id,
+        session.session,
+        session.client_id,
+        Some(session.user_id),
+    )
+    .await;
+}
+
+/// Install this session's in-process reply target on first data-plane use.
+///
+/// The registry key is the session's shard-0 client id - the same id stamped
+/// into `RequestHeader.client` - so a partition reply routed through
+/// `send_to_client` lands on this entry and resolves the request-keyed slot.
+/// `None` from the registry means the key is already occupied; treat it as
+/// installed but leave the token unset so this session never tears down an
+/// entry it does not own.
+fn ensure_in_process_reply_target(state: &HttpInner, session: &HttpSession) {
+    if session.registry_token.get().is_some() {
+        return;
+    }
+    if let Some(token) = state
+        .shard
+        .bus
+        .clients()
+        .insert_in_process(session.client_id)
+    {
+        session.registry_token.set(Some(token));
+    }
+}
+
... 2436 lines suppressed ...